diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 271ddbb65..aa3e6ef24 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -2691,6 +2691,7 @@ const Conversations = ({ id: 'active-tool', name: selectedInferenceStatus.activeTool ?? 'tool', round: selectedInferenceStatus.iteration, + seq: 0, status: 'running', } ).title @@ -2702,6 +2703,7 @@ const Conversations = ({ id: 'active-subagent', name: `subagent:${selectedInferenceStatus.activeSubagent ?? ''}`, round: selectedInferenceStatus.iteration, + seq: 0, status: 'running', } ).title diff --git a/app/src/features/conversations/components/ProcessingTranscriptView.test.tsx b/app/src/features/conversations/components/ProcessingTranscriptView.test.tsx index 94a6926b7..7ff928381 100644 --- a/app/src/features/conversations/components/ProcessingTranscriptView.test.tsx +++ b/app/src/features/conversations/components/ProcessingTranscriptView.test.tsx @@ -16,6 +16,7 @@ function failedEntry(overrides: Partial = {}): ToolTimelineEn id: 'call-1', name: 'read_file', round: 1, + seq: 0, status: 'error', failure: { class: 'MissingPermission', diff --git a/app/src/features/conversations/components/ToolTimelineBlock.tsx b/app/src/features/conversations/components/ToolTimelineBlock.tsx index d66d926f8..b78330de5 100644 --- a/app/src/features/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/features/conversations/components/ToolTimelineBlock.tsx @@ -481,7 +481,6 @@ export function ToolTimelineBlock({ liveResponse?: string; }) { const { t } = useT(); - const latestRunningEntryId = [...entries].reverse().find(entry => entry.status === 'running')?.id; // Sticky override for the outer "Agentic task insights" group: `null` means // the user hasn't explicitly toggled it on THIS mount yet, so the group @@ -502,6 +501,18 @@ export function ToolTimelineBlock({ if (entries.length === 0) return null; + // The rows + the parent's streaming response — shared by both the collapsible + // (in-flight) and static (settled) header layouts below. + // Sort by issue order (`seq`), not arrival order: a `tool_args_delta` for a + // later parallel call can reach the store before an earlier call's own + // event, which would otherwise create rows in the wrong order. + // Sort a copy — `entries` may be a state slice other callers still rely on. + const ordered = [...entries].sort((a, b) => a.seq - b.seq); + // "Latest running" must be derived from the same seq-ordered list the rows + // render from — not raw arrival order — or a running row that arrived late + // but sorts earlier (e.g. seq [2, 0, 1]) gets treated as "latest" and the + // wrong step stays expanded/linked in compact chat mode. + const latestRunningEntryId = [...ordered].reverse().find(entry => entry.status === 'running')?.id; const isRunning = latestRunningEntryId != null; const titleLabel = ( @@ -527,11 +538,9 @@ export function ToolTimelineBlock({ ) : null; - // The rows + the parent's streaming response — shared by both the collapsible - // (in-flight) and static (settled) header layouts below. // Coalesce runs of identical, body-less rows (e.g. a retry loop that spawns // the same integrations step 25×) into single `×N` rows before rendering. - const rows = coalesceTimelineEntries(entries); + const rows = coalesceTimelineEntries(ordered); const body = ( <> diff --git a/app/src/features/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx b/app/src/features/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx index cfdcf1890..39a726369 100644 --- a/app/src/features/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx +++ b/app/src/features/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx @@ -15,6 +15,7 @@ const fetchEntry = (id: string, url: string): ToolTimelineEntry => ({ id, name: 'web_fetch', round: 1, + seq: 0, status: 'success', argsBuffer: JSON.stringify({ url }), }); @@ -82,6 +83,7 @@ describe('AgentProcessSourcePanel', () => { id: 'sa', name: 'subagent:researcher', round: 1, + seq: 0, status: 'success', subagent: { taskId: 'sub-1', @@ -103,8 +105,8 @@ describe('AgentProcessSourcePanel', () => { { id: 'sa-1', name: 'subagent:researcher', round: 1, + seq: 0, status: 'success', subagent: { taskId: 'task-1', @@ -160,6 +163,7 @@ describe('AgentProcessSourcePanel', () => { id: 'sa-scope', name: 'subagent:researcher', round: 1, + seq: 0, status: 'success', subagent: { taskId: 'task-9', @@ -174,7 +178,7 @@ describe('AgentProcessSourcePanel', () => { entries={[ scoped, // A second, unrelated step that must NOT show in the scoped view. - { id: 'other', name: 'web_fetch', round: 1, status: 'success' }, + { id: 'other', name: 'web_fetch', round: 1, seq: 0, status: 'success' }, ]} transcript={[{ kind: 'narration', round: 1, seq: 0, text: 'whole-run narration' }]} scopedEntry={scoped} @@ -195,6 +199,7 @@ describe('AgentProcessSourcePanel', () => { id: 'tool-result-only', name: 'run_code', round: 1, + seq: 0, status: 'success', argsBuffer: '{"command":"pnpm test"}', result: 'exit 0\nAll checks passed.', @@ -211,7 +216,7 @@ describe('AgentProcessSourcePanel', () => { renderPanel( {}} /> ); diff --git a/app/src/features/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx b/app/src/features/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx index 3feb487ab..661287518 100644 --- a/app/src/features/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx +++ b/app/src/features/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx @@ -22,7 +22,7 @@ function entry( subagent?: SubagentActivity, name = 'subagent:researcher' ): ToolTimelineEntry { - return { id: `e-${subagent?.taskId ?? name}`, name, round: 0, status, subagent }; + return { id: `e-${subagent?.taskId ?? name}`, name, round: 0, seq: 0, status, subagent }; } describe('selectBackgroundProcesses', () => { diff --git a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx index d0c692f34..6743380e6 100644 --- a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx +++ b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx @@ -299,11 +299,19 @@ describe('SubagentActivityBlock', () => { describe('ToolTimelineBlock — agentic task insights surface', () => { it('wraps rows in the "Agentic task insights" group and conveys run state on the name', () => { const entries: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'running', argsBuffer: '{"query":"f1"}' }, + { + id: 'r', + name: 'web_search', + round: 1, + seq: 0, + status: 'running', + argsBuffer: '{"query":"f1"}', + }, { id: 'd', name: 'file_read', round: 1, + seq: 0, status: 'success', argsBuffer: '{"path":"/a/b.txt"}', }, @@ -324,6 +332,25 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { expect(done.className).not.toContain('animate-pulse'); }); + it('renders rows in seq (issue) order, not array (arrival) order', () => { + // Simulates the out-of-order-arrival bug: a `tool_args_delta` for + // a later parallel call can land — and create its row — before an + // earlier call's own event, so the entries array ends up scrambled + // relative to the order the agent actually issued the calls. `seq` is + // the source of truth for display order; the array position is not. + const entries: ToolTimelineEntry[] = [ + { id: 'third', name: 'run_code', round: 1, seq: 2, status: 'success' }, + { id: 'first', name: 'web_search', round: 1, seq: 0, status: 'success' }, + { id: 'second', name: 'file_read', round: 1, seq: 1, status: 'success' }, + ]; + renderInStore(); + const rows = screen.getAllByTestId('agent-timeline-row'); + expect(rows).toHaveLength(3); + expect(rows[0].textContent).toContain('Searching the web'); + expect(rows[1].textContent).toContain('Reading file'); + expect(rows[2].textContent).toContain('Run Code'); + }); + it('renders nothing for an empty timeline', () => { const { container } = renderInStore(); expect(container.querySelector('[data-testid="agent-task-insights"]')).toBeNull(); @@ -331,7 +358,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { it('stays open while running and collapses once settled so a finished run does not dominate', () => { const running: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'running' }, + { id: 'r', name: 'web_search', round: 1, seq: 0, status: 'running' }, ]; const { rerender } = renderInStore(); // In flight → the group is open so the live activity is visible. @@ -340,7 +367,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { // Settled (no running row) → collapsed by default; the rows stay in the DOM // one click away, but no longer flood the conversation. const settled: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'success' }, + { id: 'r', name: 'web_search', round: 1, seq: 0, status: 'success' }, ]; rerender( @@ -371,7 +398,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { describe('agentic task insights — sticky user expand/collapse across turns', () => { it('keeps an explicit user expand across a new turn that starts and settles', () => { const turn1Settled: ToolTimelineEntry[] = [ - { id: 't1', name: 'web_search', round: 1, status: 'success' }, + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'success' }, ]; const { rerender } = renderInStore(); // Default: settled and collapsed (unchanged behaviour). @@ -384,7 +411,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { // A new turn/feedback starts streaming onto the SAME mounted block. const turn2Running: ToolTimelineEntry[] = [ ...turn1Settled, - { id: 't2', name: 'file_read', round: 2, status: 'running' }, + { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'running' }, ]; rerender( @@ -397,7 +424,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { // involuntarily re-collapse, wiping out the user's choice. const turn2Settled: ToolTimelineEntry[] = [ ...turn1Settled, - { id: 't2', name: 'file_read', round: 2, status: 'success' }, + { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'success' }, ]; rerender( @@ -409,13 +436,13 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { it('leaves the default open-while-running/collapsed-when-settled behaviour unchanged absent any user interaction', () => { const running: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'running' }, + { id: 'r', name: 'web_search', round: 1, seq: 0, status: 'running' }, ]; const { rerender } = renderInStore(); expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); const settled: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'success' }, + { id: 'r', name: 'web_search', round: 1, seq: 0, status: 'success' }, ]; rerender( @@ -427,7 +454,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { it('also persists an explicit user collapse across a new turn (does not force it back open)', () => { const turn1Running: ToolTimelineEntry[] = [ - { id: 't1', name: 'web_search', round: 1, status: 'running' }, + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'running' }, ]; const { rerender } = renderInStore(); expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); @@ -439,8 +466,8 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { // A new turn starts running — the auto rule alone would force it back // open, but the user's explicit collapse must win. const turn2Running: ToolTimelineEntry[] = [ - { id: 't1', name: 'web_search', round: 1, status: 'success' }, - { id: 't2', name: 'file_read', round: 2, status: 'running' }, + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'success' }, + { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'running' }, ]; rerender( @@ -457,6 +484,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { id: 'd', name: 'web_search', round: 1, + seq: 0, status: 'success', argsBuffer: '{"query":"f1"}', result: 'Top result: https://openhuman.dev', @@ -470,8 +498,8 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { it('makes a row expandable on a result alone and omits the block without one', () => { const entries: ToolTimelineEntry[] = [ // No argsBuffer / detail / subagent — the result is the only body. - { id: 'a', name: 'run_code', round: 1, status: 'success', result: 'exit 0' }, - { id: 'b', name: 'run_code', round: 2, status: 'success' }, + { id: 'a', name: 'run_code', round: 1, seq: 0, status: 'success', result: 'exit 0' }, + { id: 'b', name: 'run_code', round: 2, seq: 0, status: 'success' }, ]; renderInStore(); const outputs = screen.getAllByTestId('tool-result-output'); @@ -481,7 +509,14 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { it('renders the parent live response inside the panel under a Response heading', () => { const entries: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'running', argsBuffer: '{"query":"f1"}' }, + { + id: 'r', + name: 'web_search', + round: 1, + seq: 0, + status: 'running', + argsBuffer: '{"query":"f1"}', + }, ]; renderInStore( { it('omits the Response block when there is no live response', () => { const entries: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'running' }, + { id: 'r', name: 'web_search', round: 1, seq: 0, status: 'running' }, ]; renderInStore(); expect(screen.queryByTestId('agent-live-response')).toBeNull(); @@ -504,7 +539,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { it('strips a leaked envelope from the live response', () => { const entries: ToolTimelineEntry[] = [ - { id: 'r', name: 'web_search', round: 1, status: 'running' }, + { id: 'r', name: 'web_search', round: 1, seq: 0, status: 'running' }, ]; renderInStore( { id: `dup-${i}`, name: 'integrations_agent', round: 1, + seq: 0, status: 'success' as const, })); renderInStore(); @@ -537,12 +573,12 @@ describe('ToolTimelineBlock — coalescing repeated rows', () => { it('does not merge across differing status or the live running row', () => { const entries: ToolTimelineEntry[] = [ - { id: 'a', name: 'integrations_agent', round: 1, status: 'success' }, - { id: 'b', name: 'integrations_agent', round: 1, status: 'success' }, + { id: 'a', name: 'integrations_agent', round: 1, seq: 0, status: 'success' }, + { id: 'b', name: 'integrations_agent', round: 1, seq: 0, status: 'success' }, // Different status breaks the run. - { id: 'c', name: 'integrations_agent', round: 1, status: 'error' }, + { id: 'c', name: 'integrations_agent', round: 1, seq: 0, status: 'error' }, // The live running row is never folded away. - { id: 'd', name: 'integrations_agent', round: 1, status: 'running' }, + { id: 'd', name: 'integrations_agent', round: 1, seq: 0, status: 'running' }, ]; renderInStore(); // success×2 (merged) + error (single) + running (single) = 3 rows. @@ -554,8 +590,8 @@ describe('ToolTimelineBlock — coalescing repeated rows', () => { it('never merges rows that carry a unique result body', () => { const entries: ToolTimelineEntry[] = [ - { id: 'a', name: 'run_code', round: 1, status: 'success', result: 'exit 0' }, - { id: 'b', name: 'run_code', round: 1, status: 'success', result: 'exit 1' }, + { id: 'a', name: 'run_code', round: 1, seq: 0, status: 'success', result: 'exit 0' }, + { id: 'b', name: 'run_code', round: 1, seq: 0, status: 'success', result: 'exit 1' }, ]; renderInStore(); // Both keep their own row — distinct results are never coalesced. @@ -570,6 +606,7 @@ describe('ToolTimelineBlock — subagent rendering', () => { id: 'tid:subagent:sub-1:researcher', name: 'subagent:researcher', round: 1, + seq: 0, status: 'running', subagent: { taskId: 'sub-1', @@ -593,6 +630,7 @@ describe('ToolTimelineBlock — subagent rendering', () => { id: 'plain', name: 'list_threads', round: 0, + seq: 0, status: 'success', }; renderInStore(); @@ -620,6 +658,7 @@ describe('ToolTimelineBlock — worker thread ref status propagation', () => { id: `tid:subagent:task-42:researcher:${status}`, name: 'subagent:researcher', round: 1, + seq: 0, status, detail: WORKER_REF_DETAIL, }; @@ -664,6 +703,7 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => { id: 'tl-1', name: 'agent_prepare_context', round: 1, + seq: 0, status: 'success', detail: 'fetch X', result: 'Prepared context from 3 sources.', @@ -673,6 +713,7 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => { id: 'sa-1', name: 'subagent:researcher', round: 1, + seq: 0, status: 'running', subagent: { taskId: 'task-1', @@ -713,6 +754,7 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => { id: 'sa-done', name: 'subagent:researcher', round: 1, + seq: 0, status: 'success', subagent: { taskId: 'task-2', diff --git a/app/src/features/conversations/timeline/ConversationTimeline.test.tsx b/app/src/features/conversations/timeline/ConversationTimeline.test.tsx index 96fb5a504..6a5df2877 100644 --- a/app/src/features/conversations/timeline/ConversationTimeline.test.tsx +++ b/app/src/features/conversations/timeline/ConversationTimeline.test.tsx @@ -33,13 +33,14 @@ function agentMsg(id: string, content: string): ThreadMessage { }; } function tool(id: string, name: string, round = 0): ToolTimelineEntry { - return { id, name, round, status: 'success' }; + return { id, name, round, seq: 0, status: 'success' }; } function subagentRow(id: string, taskId: string): ToolTimelineEntry { return { id, name: 'subagent:researcher', round: 0, + seq: 0, status: 'running', subagent: { taskId, agentId: 'researcher', toolCalls: [] }, }; diff --git a/app/src/features/conversations/timeline/selectors.test.ts b/app/src/features/conversations/timeline/selectors.test.ts index 108cb2af0..64b63b5e8 100644 --- a/app/src/features/conversations/timeline/selectors.test.ts +++ b/app/src/features/conversations/timeline/selectors.test.ts @@ -42,13 +42,14 @@ function tool( round = 0, status: ToolTimelineEntry['status'] = 'success' ): ToolTimelineEntry { - return { id, name, round, status }; + return { id, name, round, seq: 0, status }; } function subagentRow(id: string, taskId: string, round = 0): ToolTimelineEntry { return { id, name: 'subagent:researcher', round, + seq: 0, status: 'running', subagent: { taskId, agentId: 'researcher', toolCalls: [] }, }; diff --git a/app/src/features/human/SubMascotLayer.test.tsx b/app/src/features/human/SubMascotLayer.test.tsx index 928992983..c2a0285cd 100644 --- a/app/src/features/human/SubMascotLayer.test.tsx +++ b/app/src/features/human/SubMascotLayer.test.tsx @@ -17,6 +17,7 @@ function subagentEntry(overrides: Partial = {}): ToolTimeline id: 'thread-1:subagent:sub-1:researcher', name: 'subagent:researcher', round: 1, + seq: 0, status: 'running', detail: 'Research the relevant docs.', subagent: { @@ -33,7 +34,7 @@ function subagentEntry(overrides: Partial = {}): ToolTimeline describe('subMascotModelsFromTimeline', () => { it('builds visible models only from subagent timeline rows', () => { const models = subMascotModelsFromTimeline([ - { id: 'thread-1:tool:search', name: 'web_search', round: 1, status: 'running' }, + { id: 'thread-1:tool:search', name: 'web_search', round: 1, seq: 0, status: 'running' }, subagentEntry(), ]); @@ -190,7 +191,7 @@ describe('', () => { it('renders nothing when no subagent rows are present', () => { const { container } = render( ); diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index c4df668e6..060e8bb11 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -665,7 +665,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { store!.dispatch( setTurnTimelinesForThread({ threadId: thread.id, - timelines: { 'req-1': [{ id: 'tc-1', name: 'read_file', round: 0, status: 'success' }] }, + timelines: { + 'req-1': [{ id: 'tc-1', name: 'read_file', round: 0, seq: 0, status: 'success' }], + }, }) ); }); @@ -1567,7 +1569,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { store!.dispatch( setToolTimelineForThread({ threadId: thread.id, - entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'running' }], + entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, seq: 0, status: 'running' }], }) ); }); @@ -1703,7 +1705,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { store!.dispatch( setToolTimelineForThread({ threadId: 'some-other-thread', - entries: [{ id: 'other-1', name: 'web_fetch', round: 1, status: 'running' }], + entries: [{ id: 'other-1', name: 'web_fetch', round: 1, seq: 0, status: 'running' }], }) ); }); @@ -2091,11 +2093,12 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', setToolTimelineForThread({ threadId: thread.id, entries: [ - { id: 'tl-1', name: 'web_fetch', round: 1, status: 'success' }, + { id: 'tl-1', name: 'web_fetch', round: 1, seq: 0, status: 'success' }, { id: 'sa-1', name: 'subagent:researcher', round: 1, + seq: 0, status: 'running', subagent: { taskId: 'task-1', agentId: 'researcher', toolCalls: [] }, }, @@ -2187,7 +2190,7 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', store!.dispatch( setToolTimelineForThread({ threadId: thread.id, - entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'success' }], + entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, seq: 0, status: 'success' }], }) ); }); @@ -2243,7 +2246,7 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', store!.dispatch( setToolTimelineForThread({ threadId: thread.id, - entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'running' }], + entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, seq: 0, status: 'running' }], }) ); }); @@ -2364,7 +2367,7 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', store!.dispatch( setToolTimelineForThread({ threadId: thread.id, - entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'cancelled' }], + entries: [{ id: 'tl-1', name: 'web_fetch', round: 1, seq: 0, status: 'cancelled' }], }) ); }); diff --git a/app/src/pages/dev/AgentInsightsPreview.tsx b/app/src/pages/dev/AgentInsightsPreview.tsx index 9ff2cd129..7aada9d23 100644 --- a/app/src/pages/dev/AgentInsightsPreview.tsx +++ b/app/src/pages/dev/AgentInsightsPreview.tsx @@ -24,6 +24,7 @@ const RUNNING_ENTRIES: ToolTimelineEntry[] = [ id: 's-slack', name: 'subagent:integrations_agent', round: 1, + seq: 0, status: 'running', sourceToolName: 'slack', subagent: { @@ -44,6 +45,7 @@ const RUNNING_ENTRIES: ToolTimelineEntry[] = [ id: 'e-search', name: 'web_search', round: 1, + seq: 1, status: 'success', argsBuffer: JSON.stringify({ query: 'monaco gp 2026 results' }), }, @@ -51,6 +53,7 @@ const RUNNING_ENTRIES: ToolTimelineEntry[] = [ id: 'e-fetch1', name: 'web_fetch', round: 1, + seq: 2, status: 'success', argsBuffer: JSON.stringify({ url: 'https://news-gazette.com/sport/f1-monaco' }), }, @@ -58,6 +61,7 @@ const RUNNING_ENTRIES: ToolTimelineEntry[] = [ id: 'e-fetch2', name: 'web_fetch', round: 1, + seq: 3, status: 'success', argsBuffer: JSON.stringify({ url: 'https://example.org/standings' }), }, @@ -65,6 +69,7 @@ const RUNNING_ENTRIES: ToolTimelineEntry[] = [ id: 'e-shell', name: 'shell', round: 2, + seq: 4, status: 'success', argsBuffer: JSON.stringify({ command: 'cat report.py | head -20' }), }, @@ -72,6 +77,7 @@ const RUNNING_ENTRIES: ToolTimelineEntry[] = [ id: 'e-err', name: 'file_read', round: 2, + seq: 5, status: 'error', argsBuffer: JSON.stringify({ path: '/tmp/missing.txt' }), }, diff --git a/app/src/store/__tests__/chatRuntimeSlice.subagentStream.test.ts b/app/src/store/__tests__/chatRuntimeSlice.subagentStream.test.ts index 0dd40bde4..01621bf92 100644 --- a/app/src/store/__tests__/chatRuntimeSlice.subagentStream.test.ts +++ b/app/src/store/__tests__/chatRuntimeSlice.subagentStream.test.ts @@ -18,6 +18,7 @@ function withSubagentRow(): ReturnType { id: ROW_ID, name: 'subagent:researcher', round: 1, + seq: 0, status: 'running', subagent: { taskId: 'sub-1', agentId: 'researcher', toolCalls: [], transcript: [] }, }; diff --git a/app/src/store/__tests__/chatRuntimeSlice.test.ts b/app/src/store/__tests__/chatRuntimeSlice.test.ts index 36cf57ee3..d64713865 100644 --- a/app/src/store/__tests__/chatRuntimeSlice.test.ts +++ b/app/src/store/__tests__/chatRuntimeSlice.test.ts @@ -118,6 +118,7 @@ describe('chatRuntimeSlice', () => { id: 'call-1', name: 'search', round: 1, + seq: 0, status: 'running', argsBuffer: '{"q":"hello"}', }, @@ -126,7 +127,14 @@ describe('chatRuntimeSlice', () => { ); expect(withTimeline.toolTimelineByThread['thread-1']).toEqual([ - { id: 'call-1', name: 'search', round: 1, status: 'running', argsBuffer: '{"q":"hello"}' }, + { + id: 'call-1', + name: 'search', + round: 1, + seq: 0, + status: 'running', + argsBuffer: '{"q":"hello"}', + }, ]); const cleared = reducer(withTimeline, clearToolTimelineForThread({ threadId: 'thread-1' })); @@ -223,6 +231,7 @@ describe('chatRuntimeSlice', () => { id: 'tc-1', name: 'shell', round: 3, + seq: 0, status: 'running', argsBuffer: '{"cmd":"ls"}', displayName: undefined, @@ -478,7 +487,7 @@ describe('chatRuntimeSlice', () => { ), setToolTimelineForThread({ threadId: 'thread-1', - entries: [{ id: 'call-1', name: 'search', round: 1, status: 'running' }], + entries: [{ id: 'call-1', name: 'search', round: 1, seq: 0, status: 'running' }], }) ); @@ -943,9 +952,73 @@ describe('toolCallReceived (Phase 3 reducer-side merge)', () => { const rows = state.toolTimelineByThread['t1']; expect(rows).toHaveLength(1); expect(rows[0].round).toBe(1); + // The row keeps its original `seq` across the upsert — issue order is set + // once, at first creation, and must not shift on a later update event. + expect(rows[0].seq).toBe(0); // Processing pointer is recorded once for the stable callId. expect(state.processingByThread['t1']).toHaveLength(1); }); + + it('assigns a monotonically increasing seq to each newly created row per thread', () => { + let state = reducer( + undefined, + toolCallReceived({ threadId: 't1', round: 0, toolName: 'search', toolCallId: 'c1' }) + ); + state = reducer( + state, + toolCallReceived({ threadId: 't1', round: 0, toolName: 'search', toolCallId: 'c2' }) + ); + state = reducer( + state, + toolCallReceived({ threadId: 't1', round: 0, toolName: 'search', toolCallId: 'c3' }) + ); + const rows = state.toolTimelineByThread['t1']; + expect(rows.map(r => r.seq)).toEqual([0, 1, 2]); + // A different thread gets its own independent counter. + state = reducer( + state, + toolCallReceived({ threadId: 't2', round: 0, toolName: 'search', toolCallId: 'd1' }) + ); + expect(state.toolTimelineByThread['t2'][0].seq).toBe(0); + }); + + it('keeps the seq from first creation when args arrive before the tool_call event', () => { + // A `tool_args_delta` for call B can race ahead of call A's `tool_call` + // event when the agent issues two parallel calls in one turn. The row + // created by whichever event lands first gets seq 0; the row's `seq` + // must not change when the (later) sibling event for the same call + // arrives and only updates the existing row in place. + let state = reducer( + undefined, + toolArgsDeltaReceived({ + threadId: 't1', + round: 0, + delta: '{"q":"b"}', + toolName: 'search', + toolCallId: 'call-b', + }) + ); + state = reducer( + state, + toolCallReceived({ threadId: 't1', round: 0, toolName: 'search', toolCallId: 'call-a' }) + ); + // call-b's row was created first (seq 0) even though call-a's + // `tool_call` event is semantically "first" in the pair — the point is + // that whichever event creates the row locks in the seq, and a later + // `toolCallReceived` for that same id does not reassign it. + state = reducer( + state, + toolCallReceived({ threadId: 't1', round: 0, toolName: 'search', toolCallId: 'call-b' }) + ); + const rows = state.toolTimelineByThread['t1']; + const rowB = rows.find(r => r.id === 'call-b'); + const rowA = rows.find(r => r.id === 'call-a'); + expect(rowB?.seq).toBe(0); + expect(rowA?.seq).toBe(1); + // Re-receiving call-b's tool_call event (args arrived first) must not + // bump its seq to a later value. + expect(rowB?.seq).toBe(0); + }); }); describe('toolResultReceived (Phase 3 reducer-side merge)', () => { @@ -954,7 +1027,7 @@ describe('toolResultReceived (Phase 3 reducer-side merge)', () => { undefined, setToolTimelineForThread({ threadId: 't1', - entries: [{ id: 'call-1', name: 'shell', round: 0, status: 'running' }], + entries: [{ id: 'call-1', name: 'shell', round: 0, seq: 0, status: 'running' }], }) ); @@ -976,7 +1049,7 @@ describe('toolResultReceived (Phase 3 reducer-side merge)', () => { }); }); - it('falls back to the newest running row of the same name+round when no id matches', () => { + it('falls back to the (only) running row of the same name+round when no id matches', () => { const state = reducer( withRunningRow(), toolResultReceived({ threadId: 't1', round: 0, toolName: 'shell', success: false }) @@ -984,6 +1057,42 @@ describe('toolResultReceived (Phase 3 reducer-side merge)', () => { expect(state.toolTimelineByThread['t1'][0].status).toBe('error'); }); + it('FIFO: settles the oldest (not newest) running row of the same name+round when no id matches', () => { + // Two parallel `get_tool_contract` calls with the same name+round and no + // toolCallId on the result (mirrors a legacy/incomplete socket payload). + // The fallback must settle the row that was issued FIRST (lowest seq), + // not the most recently created one — otherwise a result for the first + // call can incorrectly settle the second call's still-running row. + let state = reducer( + undefined, + setToolTimelineForThread({ + threadId: 't1', + entries: [ + { id: 'call-old', name: 'get_tool_contract', round: 0, seq: 0, status: 'running' }, + { id: 'call-new', name: 'get_tool_contract', round: 0, seq: 1, status: 'running' }, + ], + }) + ); + state = reducer( + state, + toolResultReceived({ + threadId: 't1', + round: 0, + toolName: 'get_tool_contract', + success: true, + output: 'first result', + }) + ); + const rows = state.toolTimelineByThread['t1']; + const oldRow = rows.find(r => r.id === 'call-old'); + const newRow = rows.find(r => r.id === 'call-new'); + expect(oldRow?.status).toBe('success'); + expect(oldRow?.result).toBe('first result'); + // The newer call's row is untouched — still running, awaiting its own result. + expect(newRow?.status).toBe('running'); + expect(newRow?.result).toBeUndefined(); + }); + it('is a no-op when nothing matches (mirrors the provider changed-guard)', () => { const before = withRunningRow(); const after = reducer( @@ -1105,6 +1214,7 @@ describe('subagent event reducers (Phase 3)', () => { id: 'spawn-1', name: 'spawn_subagent', round: 0, + seq: 0, status: 'running', detail: 'go research', }, @@ -1259,7 +1369,9 @@ describe('toolArgsDeltaReceived (Phase 3 reducer-side merge)', () => { undefined, setToolTimelineForThread({ threadId: 't1', - entries: [{ id: 'r1', name: 'search', round: 0, status: 'running', argsBuffer: '{' }], + entries: [ + { id: 'r1', name: 'search', round: 0, seq: 0, status: 'running', argsBuffer: '{' }, + ], }) ); state = reducer( diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index 769a8baaa..316c7da7f 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -417,6 +417,7 @@ describe('chatRuntimeSlice queue status', () => { id: 't-dup:subagent:run-1:spawn_subagent', name: 'subagent:tinyplace_agent', round: 1, + seq: 0, status: 'running', subagent: { taskId: 'run-1', agentId: 'tinyplace_agent', toolCalls: [] }, }, @@ -499,6 +500,7 @@ describe('hydrateRuntimeFromSnapshot — sub-agent prose persistence', () => { id: 't9:subagent:task-x:spawn_subagent', name: 'subagent:researcher', round: 1, + seq: 0, status: 'running', subagent: { taskId: 'task-x', @@ -655,8 +657,15 @@ describe('hydrateRuntimeFromSnapshot — live-driver guard', () => { setToolTimelineForThread({ threadId: 't-live', entries: [ - { id: 'c1', name: 'web_search', round: 1, status: 'success', result: 'found 3 hits' }, - { id: 'c2', name: 'read_file', round: 2, status: 'running' }, + { + id: 'c1', + name: 'web_search', + round: 1, + seq: 0, + status: 'success', + result: 'found 3 hits', + }, + { id: 'c2', name: 'read_file', round: 2, seq: 0, status: 'running' }, ], }) ); @@ -803,7 +812,7 @@ describe('hydrateRuntimeFromSnapshot — streaming/timeline race guard', () => { store.dispatch( setToolTimelineForThread({ threadId: 't-settled', - entries: [{ id: 'c-live', name: 'read_file', round: 1, status: 'success' }], + entries: [{ id: 'c-live', name: 'read_file', round: 1, seq: 0, status: 'success' }], }) ); @@ -830,7 +839,7 @@ describe('hydrateRuntimeFromSnapshot — streaming/timeline race guard', () => { store.dispatch( setToolTimelineForThread({ threadId: 't-crashed', - entries: [{ id: 'c-stale', name: 'read_file', round: 1, status: 'running' }], + entries: [{ id: 'c-stale', name: 'read_file', round: 1, seq: 0, status: 'running' }], }) ); diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index d0cad6d80..7e01b5fbf 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -287,6 +287,16 @@ export interface ToolTimelineEntry { id: string; name: string; round: number; + /** + * Monotonic per-thread issue-order index, assigned once when the row is + * FIRST created (by `toolCallReceived`, `toolArgsDeltaReceived`, or + * `subagentSpawned`) from {@link ChatRuntimeState.toolTimelineSeqByThread}. + * Unlike arrival order — which a `tool_args_delta` for a later parallel + * call can race ahead of — `seq` reflects the order the agent actually + * issued the calls, so the timeline can be sorted deterministically + * (see `ToolTimelineBlock`) regardless of socket delivery order. + */ + seq: number; status: ToolTimelineEntryStatus; argsBuffer?: string; displayName?: string; @@ -612,6 +622,15 @@ interface ChatRuntimeState { */ parallelRequestThreads: Record; toolTimelineByThread: Record; + /** + * Per-thread monotonic counter backing {@link ToolTimelineEntry.seq}. Bumped + * once per NEW row created in {@link toolTimelineByThread} (never on an + * update to an existing row), so `seq` always reflects issue order even + * when socket events for parallel tool calls arrive out of order. Reset + * alongside the timeline itself so a new turn/thread starts counting from + * zero. + */ + toolTimelineSeqByThread: Record; /** * Per-turn tool timelines for *past* (settled) turns of a thread, keyed * `threadId -> requestId -> entries`. Hydrated from `turn_state_history` on @@ -705,6 +724,7 @@ const initialState: ChatRuntimeState = { parallelStreamsByThread: {}, parallelRequestThreads: {}, toolTimelineByThread: {}, + toolTimelineSeqByThread: {}, turnTimelinesByThread: {}, processingByThread: {}, taskBoardByThread: {}, @@ -841,11 +861,23 @@ function subagentActivityFromPersisted(activity: PersistedSubagentActivity): Sub }; } -function toolTimelineFromPersisted(entry: PersistedToolTimelineEntry): ToolTimelineEntry { +/** + * `seq` defaults to the array index the caller maps over — persisted + * `toolTimeline` order IS issue order (the core appends rows as it issues + * calls), so the index is a faithful stand-in for the live monotonic + * counter. Callers that hydrate the *live* timeline (as opposed to a past, + * settled turn) additionally seed {@link ChatRuntimeState.toolTimelineSeqByThread} + * with the row count so subsequent live events keep counting up from there. + */ +function toolTimelineFromPersisted( + entry: PersistedToolTimelineEntry, + seq: number +): ToolTimelineEntry { return { id: entry.id, name: entry.name, round: entry.round, + seq, status: entry.status, argsBuffer: entry.argsBuffer, displayName: entry.displayName, @@ -917,7 +949,7 @@ function timelineStatusFromRun(status: AgentRun['status']): ToolTimelineEntrySta } } -function timelineEntryFromRun(run: AgentRun): ToolTimelineEntry | null { +function timelineEntryFromRun(run: AgentRun, seq: number): ToolTimelineEntry | null { if (!['subagent', 'worker_thread', 'workflow_child', 'team_member'].includes(run.kind)) { return null; } @@ -931,6 +963,7 @@ function timelineEntryFromRun(run: AgentRun): ToolTimelineEntry | null { id: `subagent:${run.id}`, name: `subagent:${agentId}`, round: 0, + seq, status: timelineStatusFromRun(run.status), displayName, detail: run.summary ?? run.error ?? undefined, @@ -1035,6 +1068,7 @@ const chatRuntimeSlice = createSlice({ }, clearToolTimelineForThread: (state, action: PayloadAction<{ threadId: string }>) => { delete state.toolTimelineByThread[action.payload.threadId]; + delete state.toolTimelineSeqByThread[action.payload.threadId]; delete state.processingByThread[action.payload.threadId]; }, /** @@ -1122,11 +1156,14 @@ const chatRuntimeSlice = createSlice({ detail: displayDetail ?? prev.detail, }); } else { + const seq = state.toolTimelineSeqByThread[threadId] ?? 0; + state.toolTimelineSeqByThread[threadId] = seq + 1; entries.push( decorateEntry({ id: rowId, name: toolName, round, + seq, status: 'running', displayName: displayLabel, detail: displayDetail, @@ -1176,7 +1213,14 @@ const chatRuntimeSlice = createSlice({ return; } } - for (let i = entries.length - 1; i >= 0; i -= 1) { + // FIFO, not LIFO: entries are appended in `seq` (issue) order and never + // reordered in place, so scanning forward from index 0 finds the OLDEST + // still-running row of this name+round — settling the call that was + // actually issued first. A backward (newest-first) scan mis-pairs a + // result with the wrong row when 2+ calls with the same name are + // in-flight in parallel (e.g. `get_tool_contract` ×2) and results land + // out of order. + for (let i = 0; i < entries.length; i += 1) { const entry = entries[i]; if (entry.status === 'running' && entry.name === toolName && entry.round === round) { entry.status = status; @@ -1272,11 +1316,14 @@ const chatRuntimeSlice = createSlice({ name: prev.name.length === 0 && toolName ? toolName : prev.name, }); } else { + const seq = state.toolTimelineSeqByThread[threadId] ?? 0; + state.toolTimelineSeqByThread[threadId] = seq + 1; entries.push( decorateEntry({ id: toolCallId ?? '', name: toolName ?? '', round, + seq, status: 'running', argsBuffer: delta, }) @@ -1326,11 +1373,14 @@ const chatRuntimeSlice = createSlice({ const spawnIdx = entries.findIndex(e => e.id === pending.spawnEntryId); if (spawnIdx >= 0) entries.splice(spawnIdx, 1); } + const seq = state.toolTimelineSeqByThread[threadId] ?? 0; + state.toolTimelineSeqByThread[threadId] = seq + 1; entries.push( decorateEntry({ id: rowId, name: `subagent:${agentId}`, round, + seq, status: 'running', detail: pending.prompt, sourceToolName: pending.sourceToolName, @@ -1818,6 +1868,7 @@ const chatRuntimeSlice = createSlice({ delete state.parallelStreamsByThread[action.payload.threadId]; } delete state.toolTimelineByThread[action.payload.threadId]; + delete state.toolTimelineSeqByThread[action.payload.threadId]; delete state.processingByThread[action.payload.threadId]; delete state.taskBoardByThread[action.payload.threadId]; delete state.inferenceTurnLifecycleByThread[action.payload.threadId]; @@ -1840,6 +1891,7 @@ const chatRuntimeSlice = createSlice({ state.parallelStreamsByThread = {}; state.parallelRequestThreads = {}; state.toolTimelineByThread = {}; + state.toolTimelineSeqByThread = {}; state.turnTimelinesByThread = {}; state.processingByThread = {}; state.taskBoardByThread = {}; @@ -2009,8 +2061,14 @@ const chatRuntimeSlice = createSlice({ // (no-op for an already-completed snapshot whose rows are terminal). state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( state.toolTimelineByThread[threadId], - snapshot.toolTimeline.map(toolTimelineFromPersisted).map(settleOrphanedTimelineEntry) + snapshot.toolTimeline + .map((e, seq) => toolTimelineFromPersisted(e, seq)) + .map(settleOrphanedTimelineEntry) ); + // Persisted order is issue order — seed the live counter with the + // row count so events arriving after this hydration keep counting + // up rather than restarting at 0 and colliding with existing seqs. + state.toolTimelineSeqByThread[threadId] = snapshot.toolTimeline.length; } state.processingByThread[threadId] = snapshot.transcript ?? []; return; @@ -2040,8 +2098,11 @@ const chatRuntimeSlice = createSlice({ state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( state.toolTimelineByThread[threadId], - snapshot.toolTimeline.map(toolTimelineFromPersisted) + snapshot.toolTimeline.map((e, seq) => toolTimelineFromPersisted(e, seq)) ); + // Persisted order is issue order — seed the live counter with the row + // count so events arriving after this hydration keep counting up. + state.toolTimelineSeqByThread[threadId] = snapshot.toolTimeline.length; state.processingByThread[threadId] = snapshot.transcript ?? []; }, /** @@ -2065,8 +2126,13 @@ const chatRuntimeSlice = createSlice({ existing.map(entry => entry.subagent?.taskId).filter(Boolean) as string[] ); for (const run of runs) { - const entry = timelineEntryFromRun(run); + // Ledger rows are historical/durable, not live-issued — assign the + // next counter value like any other newly-created row so they still + // get a stable, monotonically increasing `seq` for sorting. + const seq = state.toolTimelineSeqByThread[threadId] ?? 0; + const entry = timelineEntryFromRun(run, seq); if (!entry || byId.has(entry.id) || liveTaskIds.has(run.id)) continue; + state.toolTimelineSeqByThread[threadId] = seq + 1; byId.set(entry.id, entry); } state.toolTimelineByThread[threadId] = Array.from(byId.values()); @@ -2200,7 +2266,9 @@ export const fetchAndHydrateTurnHistory = createAsyncThunk( for (const turn of history.slice(1)) { if (turn.lifecycle !== 'completed' && turn.lifecycle !== 'interrupted') continue; if (!turn.requestId || turn.toolTimeline.length === 0) continue; - timelines[turn.requestId] = turn.toolTimeline.map(toolTimelineFromPersisted); + timelines[turn.requestId] = turn.toolTimeline.map((e, seq) => + toolTimelineFromPersisted(e, seq) + ); } turnStateLog( 'hydrated turn history thread=%s turns=%d', diff --git a/app/src/utils/__tests__/toolTimelineFormatting.test.ts b/app/src/utils/__tests__/toolTimelineFormatting.test.ts index a08f28bec..0ccb87609 100644 --- a/app/src/utils/__tests__/toolTimelineFormatting.test.ts +++ b/app/src/utils/__tests__/toolTimelineFormatting.test.ts @@ -14,7 +14,7 @@ import { } from '../toolTimelineFormatting'; function entry(overrides: Partial): ToolTimelineEntry { - return { id: 'x', name: 'delegate_notion', round: 1, status: 'running', ...overrides }; + return { id: 'x', name: 'delegate_notion', round: 1, seq: 0, status: 'running', ...overrides }; } describe('formatTimelineEntry', () => { diff --git a/app/src/utils/toolTimelineFormatting.agentPrepareContext.test.ts b/app/src/utils/toolTimelineFormatting.agentPrepareContext.test.ts index 1c95178b5..a892b230e 100644 --- a/app/src/utils/toolTimelineFormatting.agentPrepareContext.test.ts +++ b/app/src/utils/toolTimelineFormatting.agentPrepareContext.test.ts @@ -4,7 +4,7 @@ import type { ToolTimelineEntry } from '../store/chatRuntimeSlice'; import { formatTimelineEntry, formatToolName } from './toolTimelineFormatting'; function entry(partial: Partial & { name: string }): ToolTimelineEntry { - return { id: 'e1', round: 0, status: 'running', ...partial }; + return { id: 'e1', round: 0, seq: 0, status: 'running', ...partial }; } describe('toolTimelineFormatting — agent_prepare_context / context_scout', () => {