import { describe, expect, it } from 'vitest'; import { deriveTaskSteps, parseSubagentCompletionInfo } from '@/pages/Chat/task-visualization'; import { formatVisibleChatTranscript, getMessageVisibilityKey, getToolVisibilityId, } from '@/pages/Chat/chat-transcript'; import { stripProcessMessagePrefix } from '@/pages/Chat/message-utils'; import type { RawMessage, ToolStatus } from '@/types/chat'; describe('deriveTaskSteps', () => { it('builds running steps from streaming thinking and tool status', () => { const streamingTools: ToolStatus[] = [ { name: 'web_search', status: 'running', updatedAt: Date.now(), summary: 'Searching docs', }, ]; const steps = deriveTaskSteps({ messages: [], streamingMessage: { role: 'assistant', content: [ { type: 'thinking', thinking: 'Compare a few approaches before coding.' }, { type: 'tool_use', id: 'tool-1', name: 'web_search', input: { query: 'opencode task list' } }, ], }, streamingTools, }); expect(steps).toEqual([ expect.objectContaining({ id: 'stream-thinking-0', label: 'Thinking', status: 'running', kind: 'thinking', }), expect.objectContaining({ label: 'web_search', status: 'running', kind: 'tool', visibilityId: getToolVisibilityId('assistant-streaming', 'tool-1', 0), }), ]); }); it('keeps completed tool steps visible while a later tool is still streaming', () => { const steps = deriveTaskSteps({ messages: [ { role: 'assistant', id: 'assistant-history', content: [ { type: 'tool_use', id: 'tool-read', name: 'read', input: { filePath: '/tmp/a.md' } }, ], }, ], streamingMessage: { role: 'assistant', content: [ { type: 'tool_use', id: 'tool-grep', name: 'grep', input: { pattern: 'TODO' } }, ], }, streamingTools: [ { toolCallId: 'tool-grep', name: 'grep', status: 'running', updatedAt: Date.now(), summary: 'Scanning files', }, ], }); expect(steps).toEqual([ expect.objectContaining({ id: 'tool-read', label: 'read', status: 'completed', kind: 'tool', }), expect.objectContaining({ id: 'tool-grep', label: 'grep', status: 'running', kind: 'tool', }), ]); }); it('upgrades a completed historical tool step when streaming status reports a later state', () => { const steps = deriveTaskSteps({ messages: [ { role: 'assistant', id: 'assistant-history', content: [ { type: 'tool_use', id: 'tool-read', name: 'read', input: { filePath: '/tmp/a.md' } }, ], }, ], streamingMessage: null, streamingTools: [ { toolCallId: 'tool-read', name: 'read', status: 'error', updatedAt: Date.now(), summary: 'Permission denied', }, ], }); expect(steps).toEqual([ expect.objectContaining({ id: 'tool-read', label: 'read', status: 'error', kind: 'tool', detail: expect.stringContaining('"filePath": "/tmp/a.md"'), }), ]); }); it('keeps all steps when the execution graph exceeds the previous max length', () => { const messages: RawMessage[] = Array.from({ length: 9 }, (_, index) => ({ role: 'assistant', id: `assistant-${index}`, content: [ { type: 'tool_use', id: `tool-${index}`, name: `read_${index}`, input: { filePath: `/tmp/${index}.md` } }, ], })); const steps = deriveTaskSteps({ messages, streamingMessage: { role: 'assistant', content: [ { type: 'tool_use', id: 'tool-live', name: 'grep_live', input: { pattern: 'TODO' } }, ], }, streamingTools: [ { toolCallId: 'tool-live', name: 'grep_live', status: 'running', updatedAt: Date.now(), summary: 'Scanning current workspace', }, ], }); expect(steps).toHaveLength(10); expect(steps[0]).toEqual(expect.objectContaining({ id: 'tool-0', label: 'read_0', status: 'completed', })); expect(steps.at(-1)).toEqual(expect.objectContaining({ id: 'tool-live', label: 'grep_live', status: 'running', })); }); it('keeps recent completed steps from assistant history', () => { const messages: RawMessage[] = [ { role: 'assistant', id: 'assistant-1', content: [ { type: 'thinking', thinking: 'Reviewing the code path.' }, { type: 'tool_use', id: 'tool-2', name: 'read_file', input: { path: 'src/App.tsx' } }, ], }, ]; const steps = deriveTaskSteps({ messages, streamingMessage: null, streamingTools: [], }); expect(steps).toEqual([ expect.objectContaining({ id: 'history-thinking-assistant-1-0', label: 'Thinking', status: 'completed', kind: 'thinking', }), expect.objectContaining({ id: 'tool-2', visibilityId: getToolVisibilityId('assistant-1', 'tool-2', 0), label: 'read_file', status: 'completed', kind: 'tool', }), ]); }); it('removes historical and streaming thinking at the ExecutionGraph derivation boundary', () => { const steps = deriveTaskSteps({ messages: [{ id: 'history', role: 'assistant', content: [ { type: 'thinking', thinking: 'historical thought' }, { type: 'text', text: 'historical narration' }, { type: 'tool_use', id: 'read-1', name: 'read', input: { file: 'README.md' } }, ], }], streamingMessage: { role: 'assistant', content: [ { type: 'thinking', thinking: 'stream thought' }, { type: 'text', text: 'stream narration' }, ], }, streamingTools: [], includeThinking: false, }); expect(steps.some((step) => step.kind === 'thinking')).toBe(false); expect(steps.some((step) => step.kind === 'message')).toBe(true); expect(steps.some((step) => step.kind === 'tool' && step.id === 'read-1')).toBe(true); }); it('uses the transcript-wide fallback index for historical tools without IDs', () => { const steps = deriveTaskSteps({ messages: [{ role: 'assistant', content: [{ type: 'tool_use', id: '', name: 'read', input: { file: 'README.md' }, }], }], messageIndexOffset: 4, streamingMessage: null, streamingTools: [], }); expect(steps).toEqual([ expect.objectContaining({ visibilityId: getToolVisibilityId('assistant-4', undefined, 0), }), ]); }); it('shares one fallback visibility ID between an ID-less streaming graph and transcript export', () => { const streamingMessage: RawMessage = { role: 'assistant', content: [{ type: 'tool_use', id: '', name: 'read', input: { visible: 'stream detail' }, }], }; const streamingMessageKey = getMessageVisibilityKey(streamingMessage, 0); const steps = deriveTaskSteps({ messages: [], streamingMessage, streamingMessageKey, streamingTools: [], }); const visibilityId = steps.find((step) => step.kind === 'tool')?.visibilityId; expect(visibilityId).toBe(getToolVisibilityId(streamingMessageKey, undefined, 0)); expect(formatVisibleChatTranscript([streamingMessage], { showThinking: false, showTimestamps: false, expandedToolIds: new Set(visibilityId ? [visibilityId] : []), })).toContain('stream detail'); }); it('uses the matched streaming tool input as the shared visible graph and export detail', () => { const streamingMessage: RawMessage = { id: 'assistant-stream', role: 'assistant', content: [{ type: 'tool_use', id: 'tool-read', name: 'read', input: { marker: 'RAW-UNSEEN-TOKEN' }, }], }; const messageKey = getMessageVisibilityKey(streamingMessage, 0); const visibilityId = getToolVisibilityId(messageKey, 'tool-read', 0); const steps = deriveTaskSteps({ messages: [], streamingMessage, streamingMessageKey: messageKey, streamingTools: [{ toolCallId: 'tool-read', name: 'read', status: 'running', updatedAt: 1, summary: 'Visible status title', }], }); const graphDetail = steps.find((step) => step.visibilityId === visibilityId)?.detail; const markdown = formatVisibleChatTranscript([streamingMessage], { showThinking: false, showTimestamps: false, expandedToolIds: new Set([visibilityId]), }); expect(graphDetail).toContain('RAW-UNSEEN-TOKEN'); expect(graphDetail).not.toContain('Visible status title'); expect(markdown).toContain('RAW-UNSEEN-TOKEN'); }); it('matches reordered same-name streaming tools by call ID before position', () => { const streamingMessage: RawMessage = { id: 'message-same-name', role: 'assistant', content: [ { type: 'tool_use', id: 'call-a', name: 'read', input: { marker: 'A-INPUT' } }, { type: 'tool_use', id: 'call-b', name: 'read', input: { marker: 'B-INPUT' } }, ], }; const steps = deriveTaskSteps({ messages: [], streamingMessage, streamingMessageKey: 'message-same-name', streamingTools: [ { toolCallId: 'call-b', name: 'read', status: 'running', updatedAt: 2, }, { toolCallId: 'call-a', name: 'read', status: 'running', updatedAt: 1, }, ], }).filter((step) => step.kind === 'tool'); expect(steps).toHaveLength(2); expect(steps.find((step) => step.visibilityId === getToolVisibilityId('message-same-name', 'call-a', 0)) ?.detail).toContain('A-INPUT'); expect(steps.find((step) => step.visibilityId === getToolVisibilityId('message-same-name', 'call-b', 1)) ?.detail).toContain('B-INPUT'); }); it('uses the matched message call ID when status has only a different event ID', () => { const steps = deriveTaskSteps({ messages: [], streamingMessage: { id: 'message-event-id', role: 'assistant', content: [{ type: 'tool_use', id: 'call-read', name: 'read', input: { marker: 'READ-INPUT' }, }], }, streamingMessageKey: 'message-event-id', streamingTools: [{ id: 'part-read', name: 'read', status: 'running', updatedAt: 1, }], }).filter((step) => step.kind === 'tool'); expect(steps).toHaveLength(1); expect(steps[0]).toEqual(expect.objectContaining({ id: 'call-read', visibilityId: getToolVisibilityId('message-event-id', 'call-read', 0), detail: expect.stringContaining('READ-INPUT'), })); }); it('preserves meaningful whitespace in the shared tool input detail', () => { const streamingMessage: RawMessage = { id: 'message-whitespace', role: 'assistant', content: [{ type: 'tool_use', id: 'call-write', name: 'write', input: { code: 'const answer = 42;', path: 'Private Notes/file.txt', }, }], }; const visibilityId = getToolVisibilityId( 'message-whitespace', 'call-write', 0, ); const detail = deriveTaskSteps({ messages: [], streamingMessage, streamingMessageKey: 'message-whitespace', streamingTools: [{ toolCallId: 'call-write', name: 'write', status: 'running', updatedAt: 1, }], }).find((step) => step.visibilityId === visibilityId)?.detail; const markdown = formatVisibleChatTranscript([streamingMessage], { showThinking: false, showTimestamps: false, expandedToolIds: new Set([visibilityId]), }); expect(detail).toContain('const answer = 42;'); expect(detail).toContain('Private Notes/file.txt'); expect(markdown).toContain('const answer = 42;'); expect(markdown).toContain('Private Notes/file.txt'); }); it('preserves leading and trailing whitespace in string tool input details', () => { const detail = deriveTaskSteps({ messages: [], streamingMessage: { id: 'message-string-whitespace', role: 'assistant', content: [{ type: 'tool_use', id: 'call-write', name: 'write', input: ' indented code ', }], }, streamingMessageKey: 'message-string-whitespace', streamingTools: [], }).find((step) => step.kind === 'tool')?.detail; expect(detail).toBe(' indented code '); }); it('uses the matched message-tool index when ID-less streaming statuses arrive out of order', () => { const streamingMessage: RawMessage = { role: 'assistant', content: [ { type: 'tool_use', id: '', name: 'read', input: { file: 'read.md' } }, { type: 'tool_use', id: '', name: 'write', input: { file: 'write.md' } }, ], }; const streamingMessageKey = getMessageVisibilityKey(streamingMessage, 0); const steps = deriveTaskSteps({ messages: [], streamingMessage, streamingMessageKey, streamingTools: [ { name: 'write', status: 'running', updatedAt: 2 }, { name: 'read', status: 'running', updatedAt: 1 }, ], }); expect(steps.find((step) => step.label === 'write')?.visibilityId) .toBe(getToolVisibilityId(streamingMessageKey, undefined, 1)); expect(steps.find((step) => step.label === 'read')?.visibilityId) .toBe(getToolVisibilityId(streamingMessageKey, undefined, 0)); }); it('splits cumulative streaming thinking into separate execution steps', () => { const steps = deriveTaskSteps({ messages: [], streamingMessage: { role: 'assistant', content: [ { type: 'thinking', thinking: 'Reviewing X.' }, { type: 'thinking', thinking: 'Reviewing X. Comparing Y.' }, { type: 'thinking', thinking: 'Reviewing X. Comparing Y. Drafting answer.' }, ], }, streamingTools: [], }); expect(steps).toEqual([ expect.objectContaining({ id: 'stream-thinking-0', detail: 'Reviewing X.', status: 'completed', }), expect.objectContaining({ id: 'stream-thinking-1', detail: 'Comparing Y.', status: 'completed', }), expect.objectContaining({ id: 'stream-thinking-2', detail: 'Drafting answer.', status: 'running', }), ]); }); it('keeps earlier reply segments in the graph when the last streaming segment is rendered separately', () => { const steps = deriveTaskSteps({ messages: [], streamingMessage: { role: 'assistant', content: [ { type: 'text', text: 'Checked X.' }, { type: 'text', text: 'Checked X. Checked Snowball.' }, { type: 'text', text: 'Checked X. Checked Snowball. Here is the summary.' }, ], }, streamingTools: [], omitLastStreamingMessageSegment: true, }); expect(steps).toEqual([ expect.objectContaining({ id: 'stream-message-0', detail: 'Checked X.', status: 'completed', }), expect.objectContaining({ id: 'stream-message-1', detail: 'Checked Snowball.', status: 'completed', }), ]); }); it('folds earlier reply segments into the graph but leaves the final answer for the chat bubble', () => { const steps = deriveTaskSteps({ messages: [ { role: 'assistant', id: 'assistant-reply', content: [ { type: 'text', text: 'Checked X.' }, { type: 'text', text: 'Checked X. Checked Snowball.' }, { type: 'text', text: 'Checked X. Checked Snowball. Here is the summary.' }, ], }, ], streamingMessage: null, streamingTools: [], }); expect(steps).toEqual([ expect.objectContaining({ id: 'history-message-assistant-reply-0', detail: 'Checked X.', status: 'completed', }), expect.objectContaining({ id: 'history-message-assistant-reply-1', detail: 'Checked Snowball.', status: 'completed', }), ]); }); it('strips folded process narration from the final reply text', () => { expect(stripProcessMessagePrefix( 'Checked X. Checked Snowball. Here is the summary.', ['Checked X.', 'Checked Snowball.'], )).toBe('Here is the summary.'); }); it('builds a branch for spawned subagents', () => { const messages: RawMessage[] = [ { role: 'assistant', id: 'assistant-2', content: [ { type: 'tool_use', id: 'spawn-1', name: 'sessions_spawn', input: { agentId: 'coder', task: 'inspect repo' }, }, { type: 'tool_use', id: 'yield-1', name: 'sessions_yield', input: { message: 'wait coder finishes' }, }, ], }, ]; const steps = deriveTaskSteps({ messages, streamingMessage: null, streamingTools: [], }); expect(steps).toEqual([ expect.objectContaining({ id: 'spawn-1', label: 'sessions_spawn', depth: 1, }), expect.objectContaining({ id: 'spawn-1:branch', label: 'coder run', depth: 2, parentId: 'spawn-1', }), expect.objectContaining({ id: 'yield-1', label: 'sessions_yield', depth: 3, parentId: 'spawn-1:branch', }), ]); }); it('parses internal subagent completion events from injected user messages', () => { const info = parseSubagentCompletionInfo({ role: 'user', content: [{ type: 'text', text: `[Internal task completion event] source: subagent session_key: agent:coder:subagent:child-123 session_id: child-session-id status: completed successfully`, }], } as RawMessage); expect(info).toEqual({ sessionKey: 'agent:coder:subagent:child-123', sessionId: 'child-session-id', agentId: 'coder', }); }); });