import { describe, it, expect, beforeEach, vi } from 'vitest' import { get, writable } from 'svelte/store' import type { OikosEvent } from './events' // The store is driven entirely by SSE events plus a log fetch, so both are // mocked. What matters is the correlation logic: an execution.output event is // matched to a chat session by correlation_id, which MCP-initiated executions // now carry (it used to be a random per-execution UUID that correlated // nothing). const liveEvents = writable([]) const subscribeEvents = vi.fn(() => () => {}) const fetchExecutionLogs = vi.fn(async (id: string) => ({ items: [], combined: `output-for-${id}` })) vi.mock('./events', () => ({ liveEvents, subscribeEvents })) vi.mock('$lib/api', () => ({ fetchExecutionLogs: (id: string) => fetchExecutionLogs(id) })) let mod: typeof import('./execstream') function event(partial: Partial): OikosEvent { return { id: Math.floor(Math.random() * 1e9), ts: new Date().toISOString(), type: 'execution.output', entity_id: 'exec-1', severity: 'info', source: 'actuator', data: {}, correlation_id: 'session-1', ...partial } as OikosEvent } // The store fetches asynchronously; let the microtask queue drain. const settle = () => new Promise((r) => setTimeout(r, 0)) beforeEach(async () => { liveEvents.set([]) fetchExecutionLogs.mockClear() vi.resetModules() mod = await import('./execstream') }) describe('liveExecutionOutputFor', () => { it('picks up output for its own session', async () => { const store = mod.liveExecutionOutputFor('session-1') const stop = store.subscribe(() => {}) liveEvents.set([event({ entity_id: 'exec-1', correlation_id: 'session-1' })]) await settle() expect(fetchExecutionLogs).toHaveBeenCalledWith('exec-1') expect(get(store)).toEqual({ executionId: 'exec-1', output: 'output-for-exec-1' }) stop() }) // Without this every open chat window would tail every other session's // commands. it('ignores output belonging to a different session', async () => { const store = mod.liveExecutionOutputFor('session-1') const stop = store.subscribe(() => {}) liveEvents.set([event({ entity_id: 'exec-9', correlation_id: 'session-2' })]) await settle() expect(fetchExecutionLogs).not.toHaveBeenCalled() expect(get(store)).toBeNull() stop() }) it('ignores unrelated event types', async () => { const store = mod.liveExecutionOutputFor('session-1') const stop = store.subscribe(() => {}) liveEvents.set([event({ type: 'signal.raised' })]) await settle() expect(fetchExecutionLogs).not.toHaveBeenCalled() stop() }) // A session runs commands one after another; the second must not inherit // the first one's output. it('resets when a new execution starts in the same session', async () => { const store = mod.liveExecutionOutputFor('session-1') const stop = store.subscribe(() => {}) liveEvents.set([event({ entity_id: 'exec-1' })]) await settle() expect(get(store)?.executionId).toBe('exec-1') liveEvents.set([event({ entity_id: 'exec-2' })]) await settle() expect(get(store)).toEqual({ executionId: 'exec-2', output: 'output-for-exec-2' }) stop() }) // Once the command finishes its output belongs to the tool_result, not to a // still-"running" entry — leaving it set would show stale output against // the next command. it('clears on a terminal execution event', async () => { const store = mod.liveExecutionOutputFor('session-1') const stop = store.subscribe(() => {}) liveEvents.set([event({ entity_id: 'exec-1' })]) await settle() expect(get(store)).not.toBeNull() liveEvents.set([event({ type: 'execution.completed', entity_id: 'exec-1' })]) await settle() expect(get(store)).toBeNull() stop() }) it('does not clear on another session completing', async () => { const store = mod.liveExecutionOutputFor('session-1') const stop = store.subscribe(() => {}) liveEvents.set([event({ entity_id: 'exec-1' })]) await settle() liveEvents.set([ event({ type: 'execution.completed', entity_id: 'exec-5', correlation_id: 'session-2' }) ]) await settle() expect(get(store)).not.toBeNull() stop() }) })