mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 14:02:19 +00:00
fix(flows): stable issue-order for tool-call timeline (monotonic seq + FIFO result pairing) (#4945)
This commit is contained in:
@@ -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 = (
|
||||
<>
|
||||
|
||||
+9
-4
@@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -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' }],
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user