Skip to content

Commit 310e181

Browse files
committed
Key transcript tool rows by call id so parallel sub-agent dispatches resolve correctly
A result is matched to its call by tool name alone when no call id is threaded through — fine for one call in flight, but three parallel `task` dispatches all carry meta === "task", so the newest pending row absorbs whichever result lands first. The other calls strand pending forever, and any later result for an already-resolved row appends as an orphan line instead of merging, producing duplicate and misattributed rows with no error surfaced. Thread the call id already present on BridgeInboundEvent, SubAgentTranscriptEntry and saved history tool_call/tool_result blocks through StreamRow, ToolCallRowInput and ToolResultRowInput, and prefer an exact id match in pendingCallIndex before falling back to the old name-based lookup for history saved before ids were carried this far.
1 parent cf3bb84 commit 310e181

10 files changed

Lines changed: 112 additions & 7 deletions

src/tui-opentui/diff.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,8 @@ export type ToolCallRowInput = {
445445
readonly name: string
446446
/** Raw JSON arguments as streamed by the model; may be absent or partial. */
447447
readonly arguments?: string
448+
/** Runtime call id, when the source (live bridge, saved history) carried one. */
449+
readonly callId?: string
448450
}
449451

450452
/**
@@ -492,6 +494,7 @@ export function toolCallRow(input: ToolCallRowInput): StreamRow {
492494
meta,
493495
pending: true,
494496
callKey,
497+
...(input.callId !== undefined ? { callId: input.callId } : {}),
495498
...(diff !== null ? { diff } : {}),
496499
...(verb !== undefined ? { verb } : {}),
497500
// A summarised call may deliberately have no subject — its verb already

src/tui-opentui/history-hydrate.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,23 @@ describe("hydrateHistoryRows", () => {
208208
])
209209
})
210210

211+
// CL-5562: a resumed transcript with three parallel `task` dispatches has
212+
// three tool_call blocks that all share name "task" — the callId each
213+
// block carries is what tells them apart on replay.
214+
test("resolves parallel same-name tool_call/tool_result pairs by callId", () => {
215+
const rows = hydrateHistoryRows([
216+
{ type: "tool_call", name: "task", arguments: '{"description":"Fix CL-5559"}', callId: "c1" },
217+
{ type: "tool_call", name: "task", arguments: '{"description":"Fix CL-5560"}', callId: "c2" },
218+
{ type: "tool_call", name: "task", arguments: '{"description":"Fix CL-5561"}', callId: "c3" },
219+
{ type: "tool_result", name: "task", content: "done c2", callId: "c2" },
220+
{ type: "tool_result", name: "task", content: "done c1", callId: "c1" },
221+
{ type: "tool_result", name: "task", content: "done c3", callId: "c3" },
222+
])
223+
expect(rows.length).toBe(3)
224+
expect(rows.every((r) => r.pending !== true)).toBe(true)
225+
expect(rows.map((r) => r.text)).toEqual(["done c1", "done c2", "done c3"])
226+
})
227+
211228
test("non-array returns empty", () => {
212229
expect(hydrateHistoryRows(undefined)).toEqual([])
213230
expect(hydrateHistoryRows(null)).toEqual([])

src/tui-opentui/history-hydrate.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ export type HistoryBlock = {
2424
readonly isError?: boolean
2525
/** tool_call argument payload when `content` is absent (ContentBlockData). */
2626
readonly arguments?: string
27+
/**
28+
* Call id carried by a `tool_call` / `tool_result` block. Two saved calls to
29+
* the same tool are indistinguishable by name alone — a resumed transcript
30+
* with parallel sub-agent dispatches needs this to pair each result with
31+
* its own call rather than the newest pending call of that name.
32+
*/
33+
readonly callId?: string
2734
/** view block payload — validated before it reaches the layout pass. */
2835
readonly node?: unknown
2936
/** plan block payload. */
@@ -51,6 +58,7 @@ function asHistoryBlock(raw: unknown): HistoryBlock | null {
5158
message?: string
5259
isError?: boolean
5360
arguments?: string
61+
callId?: string
5462
node?: unknown
5563
steps?: unknown
5664
tasks?: unknown
@@ -60,6 +68,7 @@ function asHistoryBlock(raw: unknown): HistoryBlock | null {
6068
if (typeof o.message === "string") out.message = o.message
6169
if (typeof o.isError === "boolean") out.isError = o.isError
6270
if (typeof o.arguments === "string") out.arguments = o.arguments
71+
if (typeof o.callId === "string") out.callId = o.callId
6372
if (o.node !== undefined) out.node = o.node
6473
if (o.steps !== undefined) out.steps = o.steps
6574
if (o.tasks !== undefined) out.tasks = o.tasks
@@ -135,13 +144,15 @@ export function rowFromHistoryBlock(block: HistoryBlock): StreamRow | null {
135144
return toolCallRow({
136145
name: block.name ?? "tool",
137146
...(args !== undefined ? { arguments: args } : {}),
147+
...(block.callId !== undefined ? { callId: block.callId } : {}),
138148
})
139149
}
140150
case "tool_result":
141151
return toolResultRow({
142152
name: block.name ?? "tool",
143153
content: block.content ?? (block.isError ? "error" : "ok"),
144154
isError: block.isError === true,
155+
...(block.callId !== undefined ? { callId: block.callId } : {}),
145156
})
146157
case "view": {
147158
const text = viewText(block.node) || block.content?.trim() || ""
@@ -211,6 +222,7 @@ function pushHistoryBlock(rows: StreamRow[], block: HistoryBlock): void {
211222
pushToolCall(rows, {
212223
name: block.name ?? "tool",
213224
...(args !== undefined ? { arguments: args } : {}),
225+
...(block.callId !== undefined ? { callId: block.callId } : {}),
214226
})
215227
return
216228
}
@@ -219,6 +231,7 @@ function pushHistoryBlock(rows: StreamRow[], block: HistoryBlock): void {
219231
name: block.name ?? "tool",
220232
content: block.content ?? (block.isError ? "error" : "ok"),
221233
isError: block.isError === true,
234+
...(block.callId !== undefined ? { callId: block.callId } : {}),
222235
})
223236
return
224237
}

src/tui-opentui/mcp-view.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,8 @@ export type ToolResultRowInput = {
264264
readonly name: string
265265
readonly content: string
266266
readonly isError?: boolean
267+
/** Runtime call id this result answers, when the source carried one. */
268+
readonly callId?: string
267269
}
268270

269271
/** Bodies at or under this many lines read faster than a sentence about them. */
@@ -487,6 +489,7 @@ export function toolResultRow(input: ToolResultRowInput): StreamRow {
487489
role: "tool" as const,
488490
text: input.content,
489491
meta: input.name,
492+
...(input.callId !== undefined ? { callId: input.callId } : {}),
490493
}
491494
if (failed) return { ...base, failed: true }
492495

src/tui-opentui/observe-map.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,14 @@ export function rowFromBridgeEvent(event: BridgeInboundEvent): StreamRow | null
3333
return toolCallRow({
3434
name: event.name,
3535
...(event.detail !== undefined ? { arguments: event.detail } : {}),
36+
...(event.callId !== undefined ? { callId: event.callId } : {}),
3637
})
3738
case "tool_result":
3839
return toolResultRow({
3940
name: event.name,
4041
content: event.detail ?? (event.isError ? "error" : "ok"),
4142
isError: event.isError === true,
43+
...(event.callId !== undefined ? { callId: event.callId } : {}),
4244
})
4345
case "system":
4446
return { role: "system", text: event.text }
@@ -94,6 +96,7 @@ function pushBridgeEvent(
9496
pushToolCall(rows, {
9597
name: event.name,
9698
...(event.detail !== undefined ? { arguments: event.detail } : {}),
99+
...(event.callId !== undefined ? { callId: event.callId } : {}),
97100
})
98101
return
99102
}
@@ -102,6 +105,7 @@ function pushBridgeEvent(
102105
name: event.name,
103106
content: event.detail ?? (event.isError ? "error" : "ok"),
104107
isError: event.isError === true,
108+
...(event.callId !== undefined ? { callId: event.callId } : {}),
105109
})
106110
return
107111
}

src/tui-opentui/runner-host.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ describe("rowFromTranscriptEntry", () => {
7272
verb: "Grep",
7373
pending: true,
7474
callKey: "grep Grep ",
75+
callId: "c",
7576
})
7677
expect(
7778
rowFromTranscriptEntry({
@@ -81,7 +82,7 @@ describe("rowFromTranscriptEntry", () => {
8182
content: "boom",
8283
isError: true,
8384
}),
84-
).toEqual({ role: "tool", text: "boom", meta: "grep", failed: true })
85+
).toEqual({ role: "tool", text: "boom", meta: "grep", failed: true, callId: "c" })
8586
expect(rowFromTranscriptEntry({ kind: "report", content: "done" })).toEqual({
8687
role: "assistant",
8788
text: "done",

src/tui-opentui/runner-host.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,13 @@ export function rowFromTranscriptEntry(entry: SubAgentTranscriptEntry): StreamRo
141141
case "thinking":
142142
return { role: "system", text: entry.content, meta: "thinking" }
143143
case "tool":
144-
return toolCallRow({ name: entry.name, arguments: entry.arguments })
144+
return toolCallRow({ name: entry.name, arguments: entry.arguments, callId: entry.callId })
145145
case "tool_result":
146146
return toolResultRow({
147147
name: entry.name,
148148
content: entry.content,
149149
isError: entry.isError,
150+
callId: entry.callId,
150151
})
151152
case "report":
152153
return { role: "assistant", text: entry.content, meta: "report" }
@@ -164,14 +165,15 @@ export function rowsFromTranscript(
164165
const rows: StreamRow[] = []
165166
for (const entry of entries) {
166167
if (entry.kind === "tool") {
167-
pushToolCall(rows, { name: entry.name, arguments: entry.arguments })
168+
pushToolCall(rows, { name: entry.name, arguments: entry.arguments, callId: entry.callId })
168169
continue
169170
}
170171
if (entry.kind === "tool_result") {
171172
pushToolResult(rows, {
172173
name: entry.name,
173174
content: entry.content,
174175
isError: entry.isError,
176+
callId: entry.callId,
175177
})
176178
continue
177179
}

src/tui-opentui/stream.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ export type StreamRow = {
7171
* onto a single row.
7272
*/
7373
readonly callKey?: string
74+
/**
75+
* Runtime id of the call this row answers, when the source carried one
76+
* (a live reactor callId, a resumed transcript's saved id). A result finds
77+
* the exact row it resolves by this id first — the tool name alone is
78+
* ambiguous the moment two calls to the same tool are in flight at once,
79+
* which parallel sub-agent dispatch does on every turn that fires more
80+
* than one `task` call.
81+
*/
82+
readonly callId?: string
7483
/**
7584
* Row standing for a run of repeated calls. Its subject stays the call the
7685
* run repeats (never a total across them, which would be a claim the

src/tui-opentui/tool-rows.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,48 @@ describe("a run of identical calls", () => {
124124
})
125125
})
126126

127+
describe("parallel calls to the same tool", () => {
128+
// CL-5562: three `task` calls dispatched in one turn all carry
129+
// meta === "task" — name alone cannot tell them apart, so a result must
130+
// find its own row by call id or it resolves whichever pending "task" row
131+
// happens to be newest, leaving the others stranded pending forever and
132+
// turning any later same-name result into an orphaned extra row.
133+
test("each result resolves its own call by id, not the newest pending call of that name", () => {
134+
const rows: StreamRow[] = []
135+
pushToolCall(rows, {
136+
name: "task",
137+
arguments: JSON.stringify({ agent: "intern", description: "Fix CL-5559 heading shake" }),
138+
callId: "c1",
139+
})
140+
pushToolCall(rows, {
141+
name: "task",
142+
arguments: JSON.stringify({ agent: "intern", description: "Fix CL-5560 approval UI" }),
143+
callId: "c2",
144+
})
145+
pushToolCall(rows, {
146+
name: "task",
147+
arguments: JSON.stringify({ agent: "intern", description: "Fix CL-5561 scroll/history" }),
148+
callId: "c3",
149+
})
150+
expect(rows.length).toBe(3)
151+
152+
// Results land out of dispatch order, as real sub-agent completion does.
153+
pushToolResult(rows, { name: "task", content: "done c2", callId: "c2" })
154+
pushToolResult(rows, { name: "task", content: "done c1", callId: "c1" })
155+
pushToolResult(rows, { name: "task", content: "done c3", callId: "c3" })
156+
157+
expect(rows.length).toBe(3)
158+
expect(rows.every((r) => r.pending !== true)).toBe(true)
159+
expect(rows.every((r) => r.failed !== true)).toBe(true)
160+
expect(rows[0]?.summary).toBe("Fix CL-5559 heading shake")
161+
expect(rows[0]?.text).toBe("done c1")
162+
expect(rows[1]?.summary).toBe("Fix CL-5560 approval UI")
163+
expect(rows[1]?.text).toBe("done c2")
164+
expect(rows[2]?.summary).toBe("Fix CL-5561 scroll/history")
165+
expect(rows[2]?.text).toBe("done c3")
166+
})
167+
})
168+
127169
describe("a long subject", () => {
128170
test("is cut to one line rather than wrapped", () => {
129171
const row = toolCallRow({

src/tui-opentui/tool-rows.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,14 +158,25 @@ export function coalesceCallRows(tail: StreamRow, next: StreamRow): StreamRow {
158158
}
159159

160160
/**
161-
* Index of the call row a result belongs to: the newest unanswered call by the
162-
* same tool, else the newest unanswered call at all. -1 when the result answers
163-
* nothing on the log (a hydrated transcript that kept only results, say).
161+
* Index of the call row a result belongs to.
162+
*
163+
* A carried call id is exact and wins outright — it is the only thing that
164+
* tells two in-flight calls to the same tool apart, which parallel sub-agent
165+
* dispatch produces on every turn that fires more than one `task` call (three
166+
* dispatches all show `meta === "task"`; name alone cannot tell them apart).
167+
* Falling back to name-based matching keeps older saved history — recorded
168+
* before ids were threaded through this path — resolving as it always did.
164169
*/
165170
export function pendingCallIndex(
166171
rows: readonly StreamRow[],
167172
name: string,
173+
callId?: string,
168174
): number {
175+
if (callId !== undefined) {
176+
for (let i = rows.length - 1; i >= 0; i--) {
177+
if (rows[i]?.callId === callId) return i
178+
}
179+
}
169180
let fallback = -1
170181
for (let i = rows.length - 1; i >= 0; i--) {
171182
const row = rows[i]
@@ -196,7 +207,7 @@ export function pushToolResult(
196207
input: ToolResultRowInput,
197208
): void {
198209
const result = toolResultRow(input)
199-
const index = pendingCallIndex(rows, input.name)
210+
const index = pendingCallIndex(rows, input.name, input.callId)
200211
const call = index === -1 ? undefined : rows[index]
201212
if (call === undefined) {
202213
rows.push(result)

0 commit comments

Comments
 (0)