fix(flows): stable issue-order for tool-call timeline (monotonic seq + FIFO result pairing) (#4945)

This commit is contained in:
Cyrus Gray
2026-07-16 13:48:42 +05:30
committed by GitHub
parent b48b62327b
commit 4a68fd727d
17 changed files with 321 additions and 60 deletions
@@ -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
@@ -16,6 +16,7 @@ function failedEntry(overrides: Partial<ToolTimelineEntry> = {}): ToolTimelineEn
id: 'call-1',
name: 'read_file',
round: 1,
seq: 0,
status: 'error',
failure: {
class: 'MissingPermission',
@@ -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({
</button>
) : 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 = (
<>
@@ -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', () => {
<AgentProcessSourcePanel
open
entries={[
{ id: 'c1', name: 'file_read', round: 1, status: 'success' },
{ id: 'c2', name: 'file_read', round: 1, status: 'success' },
{ id: 'c1', name: 'file_read', round: 1, seq: 0, status: 'success' },
{ id: 'c2', name: 'file_read', round: 1, seq: 0, status: 'success' },
]}
transcript={[
{ kind: 'narration', round: 1, seq: 0, text: 'Let me check both docs first.' },
@@ -132,6 +134,7 @@ 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(
<AgentProcessSourcePanel
open
entries={[{ id: 'x', name: 'file_read', round: 1, status: 'success' }]}
entries={[{ id: 'x', name: 'file_read', round: 1, seq: 0, status: 'success' }]}
onClose={() => {}}
/>
);
@@ -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', () => {
@@ -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(<ToolTimelineBlock entries={entries} />);
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(<ToolTimelineBlock entries={[]} />);
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(<ToolTimelineBlock entries={running} />);
// 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(
<Provider store={store}>
@@ -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(<ToolTimelineBlock entries={turn1Settled} />);
// 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(
<Provider store={store}>
@@ -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(
<Provider store={store}>
@@ -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(<ToolTimelineBlock entries={running} />);
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(
<Provider store={store}>
@@ -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(<ToolTimelineBlock entries={turn1Running} />);
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(
<Provider store={store}>
@@ -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(<ToolTimelineBlock entries={entries} expandAllRows />);
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(
<ToolTimelineBlock
@@ -496,7 +531,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => {
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(<ToolTimelineBlock entries={entries} />);
expect(screen.queryByTestId('agent-live-response')).toBeNull();
@@ -504,7 +539,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => {
it('strips a leaked <tool_call> 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(
<ToolTimelineBlock
@@ -527,6 +562,7 @@ describe('ToolTimelineBlock — coalescing repeated rows', () => {
id: `dup-${i}`,
name: 'integrations_agent',
round: 1,
seq: 0,
status: 'success' as const,
}));
renderInStore(<ToolTimelineBlock entries={entries} />);
@@ -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(<ToolTimelineBlock entries={entries} />);
// 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(<ToolTimelineBlock entries={entries} expandAllRows />);
// 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(<ToolTimelineBlock entries={[entry]} />);
@@ -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',
@@ -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: [] },
};
@@ -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: [] },
};
@@ -17,6 +17,7 @@ function subagentEntry(overrides: Partial<ToolTimelineEntry> = {}): 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<ToolTimelineEntry> = {}): 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('<SubMascotLayer />', () => {
it('renders nothing when no subagent rows are present', () => {
const { container } = render(
<SubMascotLayer
entries={[{ id: 'tool-1', name: 'web_search', round: 1, status: 'running' }]}
entries={[{ id: 'tool-1', name: 'web_search', round: 1, seq: 0, status: 'running' }]}
/>
);
@@ -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' }],
})
);
});
@@ -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' }),
},
@@ -18,6 +18,7 @@ function withSubagentRow(): ReturnType<typeof reducer> {
id: ROW_ID,
name: 'subagent:researcher',
round: 1,
seq: 0,
status: 'running',
subagent: { taskId: 'sub-1', agentId: 'researcher', toolCalls: [], transcript: [] },
};
@@ -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(
+13 -4
View File
@@ -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' }],
})
);
+75 -7
View File
@@ -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<string, string>;
toolTimelineByThread: Record<string, ToolTimelineEntry[]>;
/**
* 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<string, number>;
/**
* 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',
@@ -14,7 +14,7 @@ import {
} from '../toolTimelineFormatting';
function entry(overrides: Partial<ToolTimelineEntry>): 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', () => {
@@ -4,7 +4,7 @@ import type { ToolTimelineEntry } from '../store/chatRuntimeSlice';
import { formatTimelineEntry, formatToolName } from './toolTimelineFormatting';
function entry(partial: Partial<ToolTimelineEntry> & { 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', () => {