diff --git a/apps/x/apps/renderer/src/hooks/use-agent-run-transcript.test.tsx b/apps/x/apps/renderer/src/hooks/use-agent-run-transcript.test.tsx new file mode 100644 index 00000000..eda45598 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/use-agent-run-transcript.test.tsx @@ -0,0 +1,77 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { completedTurnLog } from '@/lib/session-chat/test-fixtures' +import { useAgentRunTranscript } from './use-agent-run-transcript' + +// Same preload stub as use-turn.test.tsx: the hook rides useTurn (real +// turn-feed singleton) plus a runs:fetch legacy fallback. +let handlers: Record Promise> = {} + +;(window as unknown as { ipc: unknown }).ipc = { + on: () => () => undefined, + invoke: (channel: string, args: unknown) => { + const handler = handlers[channel] + return handler ? handler(args) : Promise.resolve({ success: true }) + }, +} + +beforeEach(() => { + handlers = {} +}) + +describe('useAgentRunTranscript', () => { + it('renders a turn-backed transcript', async () => { + handlers['sessions:getTurn'] = async () => ({ + turnId: 'run-1', + events: completedTurnLog('run-1', 's1', 'do the task', 'task done'), + }) + const { result } = renderHook(() => useAgentRunTranscript('run-1')) + + await waitFor(() => expect(result.current.transcript).not.toBeNull()) + expect(result.current.transcript?.id).toBe('run-1') + expect(result.current.transcript?.summary).toBe('task done') + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('falls back to legacy runs:fetch when the id is not a turn', async () => { + handlers['sessions:getTurn'] = async () => { + throw new Error('turn not found: legacy id') + } + handlers['runs:fetch'] = async (args) => ({ + id: (args as { runId: string }).runId, + createdAt: '2026-07-01T00:00:00Z', + subUseCase: 'cron', + log: [], + }) + const { result } = renderHook(() => useAgentRunTranscript('legacy-run')) + + await waitFor(() => expect(result.current.transcript).not.toBeNull()) + expect(result.current.transcript?.id).toBe('legacy-run') + expect(result.current.transcript?.trigger).toBe('cron') + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('surfaces an error when both the turn and legacy paths fail', async () => { + handlers['sessions:getTurn'] = async () => { + throw new Error('turn not found') + } + handlers['runs:fetch'] = async () => { + throw new Error('run not found either') + } + const { result } = renderHook(() => useAgentRunTranscript('ghost-run')) + + await waitFor(() => expect(result.current.error).not.toBeNull()) + expect(result.current.error).toMatch(/run not found either/) + expect(result.current.transcript).toBeNull() + expect(result.current.loading).toBe(false) + }) + + it('is idle without a run id', () => { + const { result } = renderHook(() => useAgentRunTranscript(null)) + expect(result.current.transcript).toBeNull() + expect(result.current.loading).toBe(false) + expect(result.current.error).toBeNull() + }) +}) diff --git a/apps/x/apps/renderer/src/hooks/use-turn.test.tsx b/apps/x/apps/renderer/src/hooks/use-turn.test.tsx new file mode 100644 index 00000000..60e58f98 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/use-turn.test.tsx @@ -0,0 +1,148 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TurnBusEvent } from '@x/shared/src/turns.js' +import { + assistantText, + completed, + completedTurnLog, + created, + requested, + turnCompleted, + user, + type TEvent, +} from '@/lib/session-chat/test-fixtures' +import { useTurn } from './use-turn' + +// The hook wires the real turn-feed singleton and window.ipc, so the tests +// stub the preload surface: `on` captures the feed listener (the feed +// attaches lazily on first subscribe), `invoke` routes by channel through a +// per-test handler map. +let feedListener: ((event: TurnBusEvent) => void) | null = null +let handlers: Record Promise> = {} + +;(window as unknown as { ipc: unknown }).ipc = { + on: (channel: string, handler: (event: TurnBusEvent) => void) => { + if (channel === 'turns:events') feedListener = handler + return () => undefined + }, + invoke: (channel: string, args: unknown) => { + const handler = handlers[channel] + return handler ? handler(args) : Promise.resolve({ success: true }) + }, +} + +function emit(event: TurnBusEvent): void { + act(() => feedListener?.(event)) +} + +function durable(turnId: string, event: TEvent, offset: number): TurnBusEvent { + return { turnId, sessionId: null, event, offset } +} + +function serveTurns(turns: Record): void { + handlers['sessions:getTurn'] = async (args) => { + const { turnId } = args as { turnId: string } + const events = turns[turnId] + if (!events) throw new Error(`turn not found: ${turnId}`) + return { turnId, events: [...events] } + } +} + +beforeEach(() => { + handlers = {} +}) + +describe('useTurn', () => { + it('fetches the snapshot and applies contiguous feed events', async () => { + const T = 'turn-live' + const full: TEvent[] = [ + created(T, 's1', user('go')), + requested(T, 0), + completed(T, 0, assistantText('done')), + turnCompleted(T, 'done'), + ] + serveTurns({ [T]: full.slice(0, 2) }) + const { result } = renderHook(() => useTurn(T)) + + await waitFor(() => expect(result.current.state).not.toBeNull()) + expect(result.current.state?.terminal).toBeUndefined() + + emit(durable(T, full[2], 3)) + emit(durable(T, full[3], 4)) + expect(result.current.state?.terminal?.type).toBe('turn_completed') + expect(result.current.error).toBeNull() + }) + + it('resets state when turnId changes instead of showing the stale turn', async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + serveTurns({ 'turn-a': completedTurnLog('turn-a', 's1', 'qa', 'aa') }) + const base = handlers['sessions:getTurn'] + handlers['sessions:getTurn'] = async (args) => { + if ((args as { turnId: string }).turnId === 'turn-b') { + await gate + return { turnId: 'turn-b', events: completedTurnLog('turn-b', 's1', 'qb', 'ab') } + } + return base(args) + } + + const { result, rerender } = renderHook(({ id }) => useTurn(id), { + initialProps: { id: 'turn-a' }, + }) + await waitFor(() => expect(result.current.state).not.toBeNull()) + + rerender({ id: 'turn-b' }) + // turn-b's snapshot is gated: the hook must show nothing, not turn-a. + expect(result.current.state).toBeNull() + + release() + await waitFor(() => + expect(result.current.state?.definition.turnId).toBe('turn-b'), + ) + }) + + it('keeps the last state while disabled and refetches on re-enable', async () => { + const getTurn = vi.fn(async () => ({ + turnId: 'turn-a', + events: completedTurnLog('turn-a', 's1', 'q', 'a'), + })) + handlers['sessions:getTurn'] = getTurn + + const { result, rerender } = renderHook( + ({ enabled }) => useTurn('turn-a', { enabled }), + { initialProps: { enabled: true } }, + ) + await waitFor(() => expect(result.current.state).not.toBeNull()) + const fetches = getTurn.mock.calls.length + + rerender({ enabled: false }) + expect(result.current.state).not.toBeNull() // kept while hidden + + rerender({ enabled: true }) + await waitFor(() => + expect(getTurn.mock.calls.length).toBeGreaterThan(fetches), + ) + expect(result.current.state).not.toBeNull() + }) + + it('reports snapshotFailed after retries and recovers via a feed event', async () => { + const T = 'turn-late' + const full = completedTurnLog(T, 's1', 'q', 'a') + let available = false + handlers['sessions:getTurn'] = async () => { + if (!available) throw new Error('not created yet') + return { turnId: T, events: [...full] } + } + + const { result } = renderHook(() => useTurn(T, { maxRetries: 0 })) + await waitFor(() => expect(result.current.snapshotFailed).toBe(true)) + expect(result.current.state).toBeNull() + + available = true + emit(durable(T, full[full.length - 1], full.length)) + await waitFor(() => expect(result.current.state).not.toBeNull()) + expect(result.current.snapshotFailed).toBe(false) + }) +})