// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { TimelineModeRenderer } from '@/components/ChatBox/TimelineModes'; import { composeTimelineRuns, reconcileTimelineRun, } from '@/lib/projector/chat/presentation'; import type { ChatProjectionNode } from '@/lib/projector/chat/types'; import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore'; import { SessionMode } from '@/types/constants'; import { act, fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; const motionPreference = vi.hoisted(() => ({ reduced: false })); vi.mock('framer-motion', async () => { const actual = await vi.importActual('framer-motion'); return { ...actual, useReducedMotion: () => motionPreference.reduced, }; }); const base = { projectId: 'project-1', runId: 'run-1', cloudCursor: 1, legacyStep: null, } as const; function nodes(status: 'running' | 'completed'): ChatProjectionNode[] { const result: ChatProjectionNode[] = [ { ...base, kind: 'message', id: 'user-1', eventId: 'user-1', eventType: 'user.message', runSequence: 1, createdAt: '2026-08-19T00:00:00Z', role: 'user', purpose: 'query', status: 'complete', content: 'Review the event timeline', attachments: [ { fileName: 'timeline-notes.md', filePath: 'uploads/timeline-notes.md', source: 'upload', }, ], }, { ...base, kind: 'message', id: 'narration-1', eventId: 'narration-1', eventType: 'message.completed', runSequence: 2, createdAt: '2026-08-19T00:00:01Z', role: 'assistant', purpose: 'narration', status: 'complete', content: 'I will inspect the implementation.', agentName: 'Developer Agent', }, { ...base, kind: 'activity', id: 'tool-start', eventId: 'tool-start', eventType: 'tool.started', runSequence: 3, createdAt: '2026-08-19T00:00:02Z', activityType: 'tool', phase: 'started', status: 'running', title: 'read_file', input: 'src/components/ChatBox/index.tsx', toolCallId: 'call-1', toolkitName: 'File Toolkit', methodName: 'read_file', }, ]; if (status === 'completed') { result.push( { ...base, kind: 'activity', id: 'tool-end', eventId: 'tool-end', eventType: 'tool.completed', runSequence: 4, createdAt: '2026-08-19T00:00:03Z', activityType: 'tool', phase: 'completed', status: 'completed', title: 'read_file', output: 'Loaded 220 lines', toolCallId: 'call-1', toolkitName: 'File Toolkit', methodName: 'read_file', }, { ...base, kind: 'artifact', id: 'artifact-1', eventId: 'artifact-1', eventType: 'artifact.updated', runSequence: 5, createdAt: '2026-08-19T00:00:04Z', operation: 'updated', path: 'src/timeline.tsx', relativePath: 'src/timeline.tsx', }, { ...base, kind: 'run_status', id: 'run-completed', eventId: 'run-completed', eventType: 'run.completed', runSequence: 6, createdAt: '2026-08-19T00:00:05Z', status: 'completed', } ); } return result; } function interactionNodes( status: 'requested' | 'responded' ): ChatProjectionNode[] { return [ { ...base, kind: 'interaction', id: 'interaction-request', eventId: 'interaction-request', eventType: status === 'requested' ? 'interaction.requested' : 'interaction.resolved', runSequence: 1, createdAt: '2026-08-19T00:00:00Z', interactionId: 'format-choice', interactionType: 'choice', status, prompt: 'Which output format should I use?', response: status === 'responded' ? 'Markdown file' : undefined, options: [{ id: 'markdown', label: 'Markdown file' }], }, ]; } function runStatusNodes( status: 'completed' | 'running' | 'failed' | 'cancelling' | 'cancelled' ): ChatProjectionNode[] { return [ { ...base, kind: 'run_status', id: `run-${status}`, eventId: `run-${status}`, eventType: `run.${status}`, runSequence: 1, createdAt: '2026-08-19T00:00:00Z', status, }, ]; } function normalToolActivity({ id, runSequence, status = 'running', toolkitName = 'File Toolkit', methodName = 'read_file', title = `${toolkitName} · ${methodName}`, toolCallId = id, agentName, }: { id: string; runSequence: number; status?: 'running' | 'completed'; toolkitName?: string; methodName?: string; title?: string; toolCallId?: string; agentName?: string; }): ChatProjectionNode { return { ...base, kind: 'activity', id, eventId: id, eventType: status === 'running' ? 'tool.started' : 'tool.completed', runSequence, createdAt: `2026-08-19T00:00:${String(runSequence).padStart(2, '0')}Z`, activityType: 'tool', phase: status === 'running' ? 'started' : 'completed', status, title, output: status === 'completed' ? `${title} complete` : undefined, toolCallId, toolkitName, methodName, agentName, }; } function runningRunStatus(runSequence: number): ChatProjectionNode { return { ...base, kind: 'run_status', id: `running-run-${runSequence}`, eventId: `running-run-${runSequence}`, eventType: 'run.running', runSequence, createdAt: `2026-08-19T00:00:${String(runSequence).padStart(2, '0')}Z`, status: 'running', }; } /** * Narrative folds its calls by default. Tests that assert on individual call * rows open every segment first, which is also the real drill-down path. */ function openNarrativeSegments(container: HTMLElement) { for (const trigger of container.querySelectorAll( '[data-narrative-segment-trigger]' )) { fireEvent.click(trigger as HTMLElement); } } describe('ChatBox timeline modes', () => { afterEach(() => { vi.useRealTimers(); motionPreference.reduced = false; usePageTabStore.setState({ sessionPreviewProjectId: null, sessionPreviewByProject: {}, }); }); it('renders Detailed as labelled rows with vertical Input then Output', () => { const runs = composeTimelineRuns(nodes('completed')); const { container } = render( ); expect( container.querySelector('[data-timeline-mode="trajectory"]') ).toBeTruthy(); expect(screen.getByText('USER')).toBeInTheDocument(); expect(screen.getByText('ASSISTANT')).toBeInTheDocument(); expect(screen.getByText('TOOL')).toBeInTheDocument(); const rows = container.querySelectorAll('[data-detailed-trace-row]'); expect(rows.length).toBeGreaterThan(0); expect(container.querySelectorAll('[data-trace-chevron]')).toHaveLength( rows.length ); rows.forEach((row) => { const rowButton = row.querySelector(':scope > button'); expect(row).toHaveAttribute('data-expanded', 'false'); expect(row).toHaveClass( 'bg-ds-bg-neutral-subtle-default', 'hover:bg-ds-bg-neutral-default-default' ); expect(row.querySelectorAll(':scope > button')).toHaveLength(1); expect(rowButton).toHaveClass('flex', 'flex-row', 'items-center'); expect(rowButton).not.toHaveClass('grid'); expect(row.querySelector('[data-trace-summary]')).toHaveClass( 'flex-1', 'truncate', '!text-label-xs' ); expect(row.querySelector('[data-trace-tag-column]')).toHaveClass( 'w-28', 'shrink-0', 'justify-end' ); expect(row.querySelector('[data-trace-tag]')).toHaveClass( 'max-w-full', 'text-right', '!text-label-xs' ); expect(row.querySelector('[data-trace-ending]')).toHaveClass( 'ml-auto', 'shrink-0' ); }); expect( container.querySelector('[data-timeline-mode="trajectory"] ol') ).toHaveClass('bg-transparent'); const contextTag = container.querySelector( '[data-trace-category="context"] [data-trace-tag]' ); const userTag = container.querySelector( '[data-trace-category="user"] [data-trace-tag]' ); const assistantTag = container.querySelector( '[data-trace-category="assistant"] [data-trace-tag]' ); const toolTag = container.querySelector( '[data-trace-category="tool"] [data-trace-tag]' ); const fileTag = container.querySelector( '[data-trace-category="file"] [data-trace-tag]' ); expect(userTag).toHaveClass( 'bg-ds-category-blue-background-default', 'text-ds-category-blue-text-strong' ); expect(contextTag).toHaveClass( 'bg-ds-category-slate-background-default', 'text-ds-category-slate-text-strong' ); expect(assistantTag).toHaveClass( 'bg-ds-category-indigo-background-default', 'text-ds-category-indigo-text-strong' ); expect(toolTag).toHaveClass( 'bg-ds-category-teal-background-default', 'text-ds-category-teal-text-strong' ); expect(fileTag).toHaveClass( 'bg-ds-category-green-background-default', 'text-ds-category-green-text-strong' ); expect(contextTag?.className).not.toBe(toolTag?.className); const toolRow = container.querySelector( '[data-trace-category="tool"]' ) as HTMLElement; fireEvent.click(within(toolRow).getByRole('button')); const input = within(toolRow).getByText('Input'); const output = within(toolRow).getByText('Output'); expect( input.compareDocumentPosition(output) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); expect( within(toolRow).getByText('src/components/ChatBox/index.tsx') ).toBeInTheDocument(); expect(within(toolRow).getByText('Loaded 220 lines')).toBeInTheDocument(); expect(within(toolRow).getByText('completed').parentElement).toHaveClass( '!text-label-xs' ); }); it('opens a trajectory Artifact in the owning Run review', () => { usePageTabStore.setState({ sessionPreviewProjectId: 'project-1', sessionPreviewByProject: {}, }); const { container } = render( ); const fileRow = container.querySelector( '[data-trace-category="file"]' ) as HTMLElement; fireEvent.click(fileRow.querySelector(':scope > button') as HTMLElement); fireEvent.click(within(fileRow).getByTitle('Review this change')); const preview = getSessionPreviewSlice(usePageTabStore.getState()); expect(preview.tabs[0]).toMatchObject({ type: 'review', reviewTarget: { scope: 'run', runId: 'run-1', focusPath: 'src/timeline.tsx', }, }); }); it('auto-expands a pending question and folds it after the answer arrives', () => { const { container, rerender } = render( ); const row = container.querySelector( '[data-trace-category="input-required"]' ) as HTMLElement; expect(row).toHaveAttribute('data-expanded', 'true'); expect(row.querySelector('[data-trace-tag]')).toHaveClass( 'bg-ds-category-amber-background-default', 'text-ds-category-amber-text-strong' ); expect(within(row).getByText('Question')).toHaveClass('!text-label-xs'); expect( within(row).getAllByText('Which output format should I use?')[1] ).toHaveClass('!text-label-xs'); rerender( ); const resolvedRow = container.querySelector( '[data-trace-category="input-required"]' ) as HTMLElement; expect(resolvedRow).toHaveAttribute('data-expanded', 'false'); expect(within(resolvedRow).queryByText('Question')).toBeNull(); expect(within(resolvedRow).queryByText('Answer')).toBeNull(); fireEvent.click(within(resolvedRow).getByRole('button')); expect(within(resolvedRow).getByText('Question')).toHaveClass( '!text-label-xs' ); expect(within(resolvedRow).getByText('Answer')).toHaveClass( '!text-label-xs' ); expect(within(resolvedRow).getByText('Markdown file')).toHaveClass( '!text-label-xs' ); }); it('uses lightly tinted backgrounds with strong text for agent tags', () => { const agentNode: ChatProjectionNode = { ...base, kind: 'activity', id: 'agent-status', eventId: 'agent-status', eventType: 'agent.completed', runSequence: 1, createdAt: '2026-08-19T00:00:00Z', activityType: 'agent', phase: 'completed', status: 'completed', title: 'question_confirm_agent', }; const { container } = render( ); const agentTag = container.querySelector( '[data-trace-category="agent"] [data-trace-tag]' ); expect(agentTag).toHaveClass( 'bg-ds-category-purple-background-default', 'text-ds-category-purple-text-strong' ); }); it('uses the same design-system status color for detailed labels and icons', () => { const cases = [ [ 'completed', 'text-ds-text-status-completed-default-default', '!text-ds-text-status-completed-default-default', ], [ 'running', 'text-ds-text-status-running-default-default', '!text-ds-text-status-running-default-default', ], [ 'failed', 'text-ds-text-status-error-default-default', '!text-ds-text-status-error-default-default', ], [ 'cancelling', 'text-ds-text-status-cancelled-default-default', '!text-ds-text-status-cancelled-default-default', ], [ 'cancelled', 'text-ds-text-status-cancelled-default-default', '!text-ds-text-status-cancelled-default-default', ], ] as const; cases.forEach(([status, labelTone, iconTone]) => { const { container, unmount } = render( ); const statusLabel = container.querySelector( `[data-detailed-status="${status}"]` ) as HTMLElement; expect(statusLabel).toHaveClass(labelTone); expect(statusLabel.querySelector('svg')).toHaveClass(iconTone); expect(statusLabel.querySelector('span')).toHaveClass(labelTone); unmount(); }); const { container } = render( ); const inputStatus = container.querySelector( '[data-detailed-status="requested"]' ) as HTMLElement; expect(inputStatus).toHaveClass( 'text-ds-text-status-pending-default-default' ); expect(inputStatus.querySelector('svg')).toHaveClass( '!text-ds-text-status-pending-default-default' ); expect(inputStatus.querySelector('span')).toHaveClass( 'text-ds-text-status-pending-default-default' ); }); it('renders event-native Normal with the legacy work-log disclosure', () => { const runs = composeTimelineRuns(nodes('running')); const { container } = render( ); expect( container.querySelector('[data-timeline-mode="narrative"]') ).toBeTruthy(); expect(screen.getByText('Review the event timeline')).toBeInTheDocument(); expect(screen.getByText('timeline-notes.md')).toHaveClass('!text-label-xs'); expect(screen.getByText(/Working on tasks for/)).toBeInTheDocument(); expect( container.querySelector('[data-narrative-timeline]') ).toBeInTheDocument(); // Narration is primary text; the calls behind it stay folded until asked // for, which is the whole point of the narrative mode. expect( container.querySelector('[data-narrative-segment-narration]') ).toHaveClass('text-ds-text-neutral-default-default'); expect(container.querySelector('[data-timeline-call-id]')).toBeNull(); expect(screen.queryByText('Developer Agent')).toBeNull(); expect(container.querySelector('[data-narrative-agent-group]')).toBeNull(); openNarrativeSegments(container); const tool = container.querySelector( '[data-timeline-call-id="call-1"]' ) as HTMLElement; const trigger = tool.querySelector( '[data-timeline-call-trigger]' ) as HTMLElement; expect(tool).toHaveAttribute('data-timeline-call-status', 'running'); expect(tool).toHaveAttribute('data-timeline-call-executor', 'toolkit'); expect(trigger.querySelectorAll('svg')).toHaveLength(1); expect(within(tool).getByText('Request')).toBeInTheDocument(); expect( within(tool).getByText('src/components/ChatBox/index.tsx') ).toBeInTheDocument(); expect(within(tool).getByText('Response')).toBeInTheDocument(); expect( within(tool).getByText('Waiting for a response.') ).toBeInTheDocument(); }); it('keeps Preparing visible until the narrative work band can render', () => { const { container, rerender } = render( ); expect(screen.getByText('Preparing to start tasks')).toBeInTheDocument(); expect(container.querySelector('[data-narrative-run-work-log]')).toBeNull(); rerender( ); expect(screen.queryByText('Preparing to start tasks')).toBeNull(); expect( container.querySelector('[data-narrative-run-work-log]') ).toBeInTheDocument(); }); it('highlights only the latest running tool and hands off when it completes', () => { const firstTool = normalToolActivity({ id: 'concurrent-read-start', runSequence: 1, methodName: 'read_file', title: 'Read the repository index', toolCallId: 'concurrent-read', }); const secondTool = normalToolActivity({ id: 'concurrent-write-start', runSequence: 2, methodName: 'write_file', title: 'Write the audit notes', toolCallId: 'concurrent-write', }); const activeNodes = [firstTool, secondTool, runningRunStatus(9)]; const { container, rerender } = render( ); openNarrativeSegments(container); const first = container.querySelector( '[data-timeline-call-id="concurrent-read"]' ) as HTMLElement; const second = container.querySelector( '[data-timeline-call-id="concurrent-write"]' ) as HTMLElement; expect(first).toHaveAttribute('data-timeline-call-status', 'running'); expect(second).toHaveAttribute('data-timeline-call-status', 'running'); expect(first).not.toHaveAttribute('data-timeline-call-highlighted'); expect(second).toHaveAttribute('data-timeline-call-highlighted', 'true'); expect(container.querySelectorAll('.shiny-text')).toHaveLength(1); expect( within(second).getByText('Waiting for a response.') ).toBeInTheDocument(); fireEvent.click( first.querySelector('[data-timeline-call-trigger]') as HTMLElement ); expect( within(first).getByText('Waiting for a response.') ).toBeInTheDocument(); rerender( ); const updatedFirst = container.querySelector( '[data-timeline-call-id="concurrent-read"]' ) as HTMLElement; const updatedSecond = container.querySelector( '[data-timeline-call-id="concurrent-write"]' ) as HTMLElement; expect(updatedFirst).toHaveAttribute( 'data-timeline-call-highlighted', 'true' ); expect(updatedSecond).not.toHaveAttribute('data-timeline-call-highlighted'); expect(updatedSecond).toHaveAttribute( 'data-timeline-call-status', 'completed' ); expect(container.querySelectorAll('.shiny-text')).toHaveLength(1); }); it('renders running reasoning activity in the default text color without shimmer', () => { const reasoningNode: ChatProjectionNode = { ...base, kind: 'activity', id: 'readme-reasoning', eventId: 'readme-reasoning', eventType: 'work_log.progress', runSequence: 1, createdAt: '2026-08-19T00:00:00Z', activityType: 'work_log', phase: 'progress', status: 'running', title: 'Searching for README files in the project', }; const { container } = render( ); const reasoning = screen.getByText( 'Searching for README files in the project' ); expect(reasoning).toHaveClass('text-ds-text-neutral-default-default'); expect(reasoning).not.toHaveClass( 'text-ds-text-neutral-subtle-default', 'shiny-text' ); expect(container.querySelector('.shiny-text')).toBeNull(); }); it('moves the only repeated-tool shimmer from the closed group to the latest child', () => { const repeatedTools = [ normalToolActivity({ id: 'repeat-one', runSequence: 1, title: 'Search the first README', toolCallId: 'repeat-one', }), normalToolActivity({ id: 'repeat-two', runSequence: 2, title: 'Search the second README', toolCallId: 'repeat-two', }), runningRunStatus(9), ]; const { container } = render( ); // Two identical consecutive calls form one segment. While it is folded the // shimmer sits on the segment label; opening it hands the shimmer to the // call that actually owns it, so exactly one indicator shows either way. const segmentTrigger = container.querySelector( '[data-narrative-segment-trigger]' ) as HTMLElement; expect(segmentTrigger.querySelector('.shiny-text')).toBeInTheDocument(); expect(container.querySelectorAll('.shiny-text')).toHaveLength(1); fireEvent.click(segmentTrigger); const latest = container.querySelector( '[data-timeline-call-id="repeat-two"]' ) as HTMLElement; expect(segmentTrigger.querySelector('.shiny-text')).toBeNull(); expect(latest).toHaveAttribute('data-timeline-call-highlighted', 'true'); expect(latest.querySelector('.shiny-text')).toBeInTheDocument(); expect(container.querySelectorAll('.shiny-text')).toHaveLength(1); }); it('animates the work-log lifecycle when the first live event arrives', () => { const { container, rerender } = render( ); expect(container.querySelector('[data-narrative-run-motion]')).toBeNull(); const liveRuns = composeTimelineRuns([ normalToolActivity({ id: 'first-live-event', runSequence: 1 }), runningRunStatus(9), ]); rerender( ); const workLogMotion = container.querySelector( '[data-narrative-run-motion="standard"]' ) as HTMLElement; expect(workLogMotion).toHaveAttribute('data-narrative-run-work-log'); expect(workLogMotion).toHaveAttribute( 'data-narrative-run-motion-id', liveRuns[0]!.id ); expect(workLogMotion).toHaveStyle({ opacity: '0', transform: 'translateY(6px)', }); expect( workLogMotion.querySelector('[data-narrative-segment-id]') ).toBeInTheDocument(); }); it('marks event entries as reduced when reduced motion is requested', () => { motionPreference.reduced = true; const { container } = render( ); expect( container.querySelector('[data-narrative-run-motion="reduced"]') ).toBeInTheDocument(); expect( container.querySelector('[data-narrative-run-motion="standard"]') ).toBeNull(); }); it('renders tool calls as status-free accordions and colors failures as errors', () => { const toolNodes: ChatProjectionNode[] = [ { ...base, kind: 'activity', id: 'success-tool', eventId: 'success-tool', eventType: 'tool.completed', runSequence: 1, createdAt: '2026-08-19T00:00:00Z', activityType: 'tool', phase: 'completed', status: 'completed', title: 'Success Toolkit · Read', output: 'Safe response value', toolCallId: 'success-tool', toolkitName: 'Success Toolkit', methodName: 'Read', }, { ...base, kind: 'activity', id: 'failed-tool', eventId: 'failed-tool', eventType: 'tool.failed', runSequence: 2, createdAt: '2026-08-19T00:00:01Z', activityType: 'tool', phase: 'completed', status: 'failed', title: 'Failure Toolkit · Write', toolCallId: 'failed-tool', toolkitName: 'Failure Toolkit', methodName: 'Write', }, { ...base, kind: 'activity', id: 'timed-out-tool', eventId: 'timed-out-tool', eventType: 'tool.timed_out', runSequence: 3, createdAt: '2026-08-19T00:00:02Z', activityType: 'tool', phase: 'completed', status: 'timed_out', title: 'Timeout Toolkit · Fetch', toolCallId: 'timed-out-tool', toolkitName: 'Timeout Toolkit', methodName: 'Fetch', }, { ...base, kind: 'activity', id: 'unknown-tool', eventId: 'unknown-tool', eventType: 'tool.outcome_unknown', runSequence: 4, createdAt: '2026-08-19T00:00:03Z', activityType: 'tool', phase: 'completed', status: 'outcome_unknown', title: 'Unknown Toolkit · Execute', toolCallId: 'unknown-tool', toolkitName: 'Unknown Toolkit', methodName: 'Execute', }, { ...base, kind: 'run_status', id: 'tool-status-run', eventId: 'tool-status-run', eventType: 'run.running', runSequence: 5, createdAt: '2026-08-19T00:00:04Z', status: 'running', }, ]; const { container } = render( ); openNarrativeSegments(container); const success = container.querySelector( '[data-timeline-call-id="success-tool"]' ) as HTMLElement; const successTrigger = success.querySelector( '[data-timeline-call-trigger]' ) as HTMLElement; expect(successTrigger).not.toHaveClass( 'text-ds-text-status-error-default-default' ); expect(successTrigger.querySelectorAll('svg')).toHaveLength(1); fireEvent.click(successTrigger); expect(within(success).getByText('Request')).toBeInTheDocument(); expect( within(success).getByText('No request was recorded for this event.') ).toBeInTheDocument(); expect(within(success).getByText('Response')).toBeInTheDocument(); expect( within(success).getByText('Safe response value') ).toBeInTheDocument(); for (const status of ['failed', 'timed_out', 'outcome_unknown']) { const tool = container.querySelector( '[data-timeline-call-status="' + status + '"]' ) as HTMLElement; const trigger = tool.querySelector( '[data-timeline-call-trigger]' ) as HTMLElement; expect(trigger).toHaveClass('text-ds-text-status-error-default-default'); expect(trigger.querySelectorAll('svg')).toHaveLength(1); } }); it('renders single-agent Normal as one flat chronological event list', () => { const chronologicalNodes: ChatProjectionNode[] = [ { ...base, kind: 'plan', id: 'plan-1', eventId: 'plan-1', eventType: 'plan.created', runSequence: 1, createdAt: '2026-08-19T00:00:00Z', status: 'active', title: 'Plan', tasks: [], }, { ...base, kind: 'message', id: 'reasoning-before', eventId: 'reasoning-before', eventType: 'message.completed', runSequence: 2, createdAt: '2026-08-19T00:00:01Z', role: 'assistant', purpose: 'narration', status: 'complete', content: 'I will search two indexes first.', agentName: 'single_agent', }, ...['first', 'second'].map((suffix, index): ChatProjectionNode => ({ ...base, kind: 'activity', id: `search-${suffix}`, eventId: `search-${suffix}`, eventType: 'tool.completed', runSequence: index + 3, createdAt: `2026-08-19T00:00:0${index + 2}Z`, activityType: 'tool', phase: 'completed', status: 'completed', title: 'WebFetchToolkit · Search', toolCallId: `search-${suffix}`, toolkitName: 'WebFetchToolkit', methodName: 'Search', })), { ...base, kind: 'message', id: 'reasoning-between', eventId: 'reasoning-between', eventType: 'message.completed', runSequence: 5, createdAt: '2026-08-19T00:00:04Z', role: 'assistant', purpose: 'narration', status: 'complete', content: 'The first indexes disagree, so I need your input.', }, { ...base, kind: 'interaction', id: 'permission-request', eventId: 'permission-request', eventType: 'approval.requested', runSequence: 6, createdAt: '2026-08-19T00:00:05Z', interactionId: 'approval:search-third', interactionType: 'approval', status: 'requested', prompt: 'Allow another search?', }, { ...base, kind: 'activity', id: 'search-third', eventId: 'search-third', eventType: 'tool.completed', runSequence: 7, createdAt: '2026-08-19T00:00:06Z', activityType: 'tool', phase: 'completed', status: 'completed', title: 'WebFetchToolkit · Search', toolCallId: 'search-third', toolkitName: 'WebFetchToolkit', methodName: 'Search', }, { ...base, kind: 'activity', id: 'system-event', eventId: 'system-event', eventType: 'agent.completed', runSequence: 8, createdAt: '2026-08-19T00:00:07Z', activityType: 'agent', phase: 'completed', status: 'completed', title: 'Agent finished', }, ]; const { container } = render( ); expect( container.querySelector('[data-narrative-timeline]') ).toBeInTheDocument(); // Reasoning, then the interrupt it triggered, then the work that followed. // Narrative keeps source order; it only changes how each item is drawn. const reasoning = screen.getByText( 'The first indexes disagree, so I need your input.' ); const permission = container.querySelector( '[data-timeline-call-executor="human"]' ) as HTMLElement; const laterSegments = [ ...container.querySelectorAll('[data-narrative-segment-id]'), ]; const thirdTool = laterSegments.at(-1) as HTMLElement; expect(permission).toBeInTheDocument(); expect( reasoning.compareDocumentPosition(permission) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); expect( permission.compareDocumentPosition(thirdTool) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); // Agent lifecycle frames supply identity metadata, never timeline rows. expect(screen.queryByText('Agent finished')).toBeNull(); }); it('nests each workforce agent in its own accordion', () => { const workforceNodes: ChatProjectionNode[] = [ { ...base, kind: 'message', id: 'alpha-narration', eventId: 'alpha-narration', eventType: 'message.completed', runSequence: 1, createdAt: '2026-08-19T00:00:01Z', role: 'assistant', purpose: 'narration', status: 'complete', content: 'Alpha will search first.', agentId: 'alpha', agentName: 'Alpha Agent', }, { ...base, kind: 'activity', id: 'alpha-search', eventId: 'alpha-search', eventType: 'tool.completed', runSequence: 2, createdAt: '2026-08-19T00:00:02Z', activityType: 'tool', phase: 'completed', status: 'completed', title: 'WebFetchToolkit · Search', toolCallId: 'alpha-search', toolkitName: 'WebFetchToolkit', methodName: 'Search', agentId: 'alpha', agentName: 'Alpha Agent', }, { ...base, kind: 'message', id: 'beta-narration', eventId: 'beta-narration', eventType: 'message.completed', runSequence: 3, createdAt: '2026-08-19T00:00:03Z', role: 'assistant', purpose: 'narration', status: 'complete', content: 'Beta will write the notes.', agentId: 'beta', agentName: 'Beta Agent', }, { ...base, kind: 'run_status', id: 'run-running', eventId: 'run-running', eventType: 'run.running', runSequence: 4, createdAt: '2026-08-19T00:00:04Z', status: 'running', }, ]; const { container, rerender } = render( ); const groups = [ ...container.querySelectorAll('[data-narrative-agent-group]'), ].map((element) => element.getAttribute('data-narrative-agent-group')); expect(groups).toEqual(['Alpha Agent', 'Beta Agent']); expect( screen.queryByText('Alpha will search first.') ).not.toBeInTheDocument(); expect(screen.getByText('Beta will write the notes.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Alpha Agent' })).toHaveAttribute( 'aria-expanded', 'false' ); expect(screen.getByRole('button', { name: 'Beta Agent' })).toHaveAttribute( 'aria-expanded', 'true' ); rerender( ); expect(screen.getByRole('button', { name: 'Alpha Agent' })).toHaveAttribute( 'aria-expanded', 'false' ); expect(screen.getByRole('button', { name: 'Beta Agent' })).toHaveAttribute( 'aria-expanded', 'true' ); rerender( ); expect(screen.getByRole('button', { name: 'Alpha Agent' })).toHaveAttribute( 'aria-expanded', 'false' ); fireEvent.click(screen.getByRole('button', { name: 'Alpha Agent' })); expect(screen.getByText('Alpha will search first.')).toBeInTheDocument(); }); it.each(['completed', 'failed', 'cancelled', 'interrupted'] as const)( 'shows terminal %s Run artifacts without a final assistant response', (status) => { const artifact = nodes('completed').find( (node) => node.kind === 'artifact' )!; const terminalStatus: ChatProjectionNode = { ...base, kind: 'run_status', id: `run-${status}`, eventId: `run-${status}`, eventType: `run.${status}`, runSequence: 6, createdAt: '2026-08-19T00:00:05Z', status, }; render( ); expect(screen.getByText('Files changed')).toBeInTheDocument(); } ); it('keeps approvals at their chronological position after a repeated event group', () => { const approvalNodes: ChatProjectionNode[] = [ { ...base, kind: 'activity', id: 'mcp-call-1-start', eventId: 'mcp-call-1-start', eventType: 'tool.started', runSequence: 1, createdAt: '2026-08-19T00:00:00Z', activityType: 'tool', phase: 'started', status: 'running', title: 'MCPToolkit · Execute_action one', toolCallId: 'mcp-call-1', toolkitName: 'MCPToolkit', methodName: 'Execute_action', }, { ...base, kind: 'activity', id: 'mcp-call-2-start', eventId: 'mcp-call-2-start', eventType: 'tool.started', runSequence: 2, createdAt: '2026-08-19T00:00:01Z', activityType: 'tool', phase: 'started', status: 'running', title: 'MCPToolkit · Execute_action two', toolCallId: 'mcp-call-2', toolkitName: 'MCPToolkit', methodName: 'Execute_action', }, { ...base, kind: 'interaction', id: 'approval-call-1', eventId: 'approval-call-1', eventType: 'approval.decided', runSequence: 3, createdAt: '2026-08-19T00:00:02Z', interactionId: 'approval:mcp-call-1', interactionType: 'approval', status: 'responded', prompt: 'Allow the first MCP action?', response: 'approved', }, { ...base, kind: 'interaction', id: 'approval-call-2', eventId: 'approval-call-2', eventType: 'approval.decided', runSequence: 4, createdAt: '2026-08-19T00:00:03Z', interactionId: 'approval:mcp-call-2', interactionType: 'approval', status: 'responded', prompt: 'Allow the second MCP action?', response: 'rejected', }, { ...base, kind: 'activity', id: 'mcp-call-1-end', eventId: 'mcp-call-1-end', eventType: 'tool.completed', runSequence: 5, createdAt: '2026-08-19T00:00:04Z', activityType: 'tool', phase: 'completed', status: 'completed', title: 'MCPToolkit · Execute_action one', toolCallId: 'mcp-call-1', toolkitName: 'MCPToolkit', methodName: 'Execute_action', }, { ...base, kind: 'activity', id: 'mcp-call-2-end', eventId: 'mcp-call-2-end', eventType: 'tool.completed', runSequence: 6, createdAt: '2026-08-19T00:00:05Z', activityType: 'tool', phase: 'completed', status: 'completed', title: 'MCPToolkit · Execute_action two', toolCallId: 'mcp-call-2', toolkitName: 'MCPToolkit', methodName: 'Execute_action', }, { ...base, kind: 'run_status', id: 'approval-run-running', eventId: 'approval-run-running', eventType: 'run.running', runSequence: 7, createdAt: '2026-08-19T00:00:06Z', status: 'running', }, ]; const { container } = render( ); const toolSegment = container.querySelector( '[data-narrative-segment-id]' ) as HTMLElement; const receipts = container.querySelectorAll( '[data-timeline-call-executor="human"]' ); // Both decisions are recorded like tool calls, with the human as the // executor, and neither is folded into the toolkit segment before them. expect(receipts).toHaveLength(2); expect( within(receipts[0] as HTMLElement).getByText('You · Allowed') ).toBeInTheDocument(); expect( within(receipts[1] as HTMLElement).getByText('You · Rejected') ).toBeInTheDocument(); expect( toolSegment.compareDocumentPosition(receipts[0]!) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); expect( receipts[0]!.compareDocumentPosition(receipts[1]!) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); openNarrativeSegments(container); expect( container.querySelector('[data-timeline-call-id="mcp-call-1"]') ).toBeInTheDocument(); expect( container.querySelector('[data-timeline-call-id="mcp-call-2"]') ).toBeInTheDocument(); }); it('keeps a human question after its triggering tool in the same agent block', () => { const humanToolNodes: ChatProjectionNode[] = [ { ...base, kind: 'activity', id: 'human-tool-start', eventId: 'human-tool-start', eventType: 'legacy.activate_toolkit', legacyStep: 'activate_toolkit', runSequence: 1, createdAt: '2026-08-19T00:00:00Z', activityType: 'tool', phase: 'started', status: 'running', title: 'Human Toolkit · Ask human via gui', input: 'Which mesh should be the reference?', agentName: 'single_agent', taskId: 'task-1', toolkitName: 'Human Toolkit', methodName: 'Ask human via gui', }, { ...base, kind: 'interaction', id: 'mesh-choice', eventId: 'mesh-choice', eventType: 'interaction.resolved', runSequence: 2, createdAt: '2026-08-19T00:00:01Z', interactionId: 'mesh-choice', interactionType: 'question', status: 'responded', prompt: 'Which mesh should be the reference?', response: 'Use the right hemisphere', }, { ...base, kind: 'activity', id: 'human-tool-end', eventId: 'human-tool-end', eventType: 'legacy.deactivate_toolkit', legacyStep: 'deactivate_toolkit', runSequence: 3, createdAt: '2026-08-19T00:00:02Z', activityType: 'tool', phase: 'completed', status: 'completed', title: 'Human Toolkit · Ask human via gui', output: 'Use the right hemisphere', agentName: 'single_agent', taskId: 'task-1', toolkitName: 'Human Toolkit', methodName: 'Ask human via gui', }, { ...base, kind: 'run_status', id: 'human-run-running', eventId: 'human-run-running', eventType: 'run.running', runSequence: 4, createdAt: '2026-08-19T00:00:03Z', status: 'running', }, ]; const { container } = render( ); const timeline = container.querySelector( '[data-narrative-timeline]' ) as HTMLElement; const tool = timeline.querySelector( '[data-narrative-segment-id]' ) as HTMLElement; const receipt = timeline.querySelector( '[data-timeline-call-executor="human"]' ) as HTMLElement; expect(tool).toBeInTheDocument(); expect(receipt).toBeInTheDocument(); expect(within(receipt).getByText('You · Answered')).toBeInTheDocument(); // The answer is the response half of a human-executed call, so it reads // through the same Request/Response disclosure a tool call uses. fireEvent.click( receipt.querySelector('[data-timeline-call-trigger]') as HTMLElement ); expect(within(receipt).getByText('Question')).toBeInTheDocument(); expect(within(receipt).getByText('Answer')).toBeInTheDocument(); expect( within(receipt).getByText('Use the right hemisphere') ).toBeInTheDocument(); expect( tool.compareDocumentPosition(receipt) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); }); it('advances elapsed time from the authoritative attempt anchor', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-19T00:00:10Z')); const composed = composeTimelineRuns(nodes('running'))[0]!; const run = reconcileTimelineRun(composed, { runId: 'run-1', status: 'running', lastSequence: 3, runVersion: 1, updatedAt: '2026-08-19T00:00:05Z', totalAttemptElapsedMs: 3_000, }); render(); expect(screen.getByText('8s')).toBeInTheDocument(); act(() => vi.advanceTimersByTime(2_000)); expect(screen.getByText('10s')).toBeInTheDocument(); }); it('holds elapsed time and the shimmer while the user has taken control', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-19T00:00:10Z')); const composed = composeTimelineRuns(nodes('running'))[0]!; const run = reconcileTimelineRun(composed, { runId: 'run-1', status: 'running', lastSequence: 3, runVersion: 1, updatedAt: '2026-08-19T00:00:05Z', totalAttemptElapsedMs: 3_000, }); const { container, rerender } = render( ); expect(screen.getByText('8s')).toBeInTheDocument(); expect(container.querySelectorAll('.shiny-text')).toHaveLength(1); // Pause: the timer freezes and the running shimmer stops. rerender( ); act(() => vi.advanceTimersByTime(5_000)); expect(screen.getByText('8s')).toBeInTheDocument(); expect(container.querySelectorAll('.shiny-text')).toHaveLength(0); expect(screen.getByText(/Paused after/)).toBeInTheDocument(); // A pause is not an ending, so the work log stays open. expect( container.querySelector('[data-narrative-timeline]') ).toBeInTheDocument(); // Resume: counting continues from where it stopped, not from wall clock. rerender(); act(() => vi.advanceTimersByTime(2_000)); expect(screen.getByText('10s')).toBeInTheDocument(); expect(container.querySelectorAll('.shiny-text')).toHaveLength(1); expect(screen.getByText(/Working on tasks for/)).toBeInTheDocument(); }); });