Skip to content

Commit 3532ea2

Browse files
committed
Canonicalize tool call identity so it settles activeToolCalls once
A streamed inference.tool_call.start/end with no callId registered the call under its name, while the executed tool.start for the same call registered it under a real id. One tool.done only removed the id-keyed entry, so the name-keyed duplicate leaked and pinned activeToolCalls above zero forever, blocking inference.done and connector.reply from ever settling the turn. Goal mode surfaced this worst since its self-continuing governor has no other terminator. Identity is now resolved once at the event boundary: the first id seen for a tool name is recorded, so a later id-bearing announcement for the same call replaces an earlier name-only placeholder in place instead of adding a second entry. The mapping is cleared once its call resolves, so a later call reusing the same tool name in one turn starts clean rather than inheriting a finished call's id.
1 parent 036e6f5 commit 3532ea2

2 files changed

Lines changed: 244 additions & 52 deletions

File tree

src/tui-opentui/turn-state.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,90 @@ describe("turnStateFromEvent", () => {
8181
).toBe("grep")
8282
})
8383

84+
test("a call's own name-only start and end announcements do not double-count", () => {
85+
const running = fold([
86+
{ type: "inference.start" },
87+
{ type: "inference.tool_call.start", data: { name: "bash" } },
88+
{ type: "inference.tool_call.end", data: { name: "bash" } },
89+
])
90+
expect(running.activeToolCalls).toHaveLength(1)
91+
})
92+
93+
test("a name-only streamed announcement and an id-bearing tool.start for the same call settle on one tool.done", () => {
94+
// Regression for CL-5645: inference.tool_call.start streamed the call
95+
// under its name (no callId yet); tool.start then announced the same
96+
// call under a real id. One tool.done must clear both records, not
97+
// leave a name-keyed duplicate pinning activeToolCalls forever.
98+
const running = fold([
99+
{ type: "inference.start" },
100+
{ type: "inference.tool_call.start", data: { name: "bash" } },
101+
{ type: "tool.start", data: { call: { id: "call_1", name: "bash" } } },
102+
])
103+
expect(running.activeToolCalls).toHaveLength(1)
104+
105+
const done = turnStateFromEvent(
106+
running,
107+
{ type: "tool.done", data: { result: { callId: "call_1" } } },
108+
200,
109+
)
110+
expect(done.activeToolCalls).toHaveLength(0)
111+
112+
const settled = turnStateFromEvent(done, { type: "inference.done" }, 201)
113+
expect(settled.status).toBe("done")
114+
expect(settled.isProcessing).toBe(false)
115+
})
116+
117+
test("two concurrent calls to the same tool resolve independently", () => {
118+
const running = fold([
119+
{ type: "inference.start" },
120+
{ type: "inference.tool_call.start", data: { name: "grep" } },
121+
{ type: "inference.tool_call.start", data: { name: "grep" } },
122+
{ type: "tool.start", data: { call: { id: "call_1", name: "grep" } } },
123+
{ type: "tool.start", data: { call: { id: "call_2", name: "grep" } } },
124+
])
125+
expect(running.activeToolCalls).toHaveLength(2)
126+
127+
const oneDone = turnStateFromEvent(
128+
running,
129+
{ type: "tool.done", data: { result: { callId: "call_1" } } },
130+
200,
131+
)
132+
expect(oneDone.activeToolCalls).toHaveLength(1)
133+
134+
const bothDone = turnStateFromEvent(
135+
oneDone,
136+
{ type: "tool.done", data: { result: { callId: "call_2" } } },
137+
201,
138+
)
139+
expect(bothDone.activeToolCalls).toHaveLength(0)
140+
})
141+
142+
test("a second call to the same tool name does not inherit a finished call's id", () => {
143+
const firstDone = fold([
144+
{ type: "inference.start" },
145+
{ type: "inference.tool_call.start", data: { name: "bash" } },
146+
{ type: "tool.start", data: { call: { id: "call_1", name: "bash" } } },
147+
{ type: "tool.done", data: { result: { callId: "call_1" } } },
148+
])
149+
expect(firstDone.activeToolCalls).toHaveLength(0)
150+
151+
const secondRunning = [
152+
{ type: "inference.tool_call.start", data: { name: "bash" } },
153+
{ type: "tool.start", data: { call: { id: "call_2", name: "bash" } } },
154+
].reduce(
155+
(state, event, i) => turnStateFromEvent(state, event, 100 + i),
156+
firstDone,
157+
)
158+
expect(secondRunning.activeToolCalls).toEqual(["call_2"])
159+
160+
const secondDone = turnStateFromEvent(
161+
secondRunning,
162+
{ type: "tool.done", data: { result: { callId: "call_2" } } },
163+
200,
164+
)
165+
expect(secondDone.activeToolCalls).toHaveLength(0)
166+
})
167+
84168
test("reactor.done settles back to idle", () => {
85169
const s = fold([{ type: "inference.start" }, { type: "reactor.done" }])
86170
expect(s.status).toBe("idle")

src/tui-opentui/turn-state.ts

Lines changed: 160 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ export type TurnState = {
9696
* so the settle decision needs the outstanding ids, not just the last name.
9797
*/
9898
readonly activeToolCalls: readonly string[]
99+
/**
100+
* Real id for a tool name once one has been seen this turn, so a
101+
* name-only announcement and its later id-bearing counterpart collapse
102+
* onto one `activeToolCalls` entry. See `registerActiveCall`.
103+
*/
104+
readonly callIdByName: Readonly<Record<string, string>>
99105
/**
100106
* Tail of the text/thinking output streamed in the current uninterrupted
101107
* streaming cycle. A tool call ends the cycle and clears it: a model
@@ -148,6 +154,7 @@ export function initialTurnState(nowMs: number): TurnState {
148154
lastActivityAt: nowMs,
149155
quota: null,
150156
activeToolCalls: [],
157+
callIdByName: {},
151158
streamText: "",
152159
streamCharsSeen: 0,
153160
repetitionCheckedAt: 0,
@@ -170,6 +177,7 @@ export function turnStateOnSubmit(state: TurnState, nowMs: number): TurnState {
170177
streamTokenCount: 0,
171178
lastActivityAt: nowMs,
172179
activeToolCalls: [],
180+
callIdByName: {},
173181
streamText: "",
174182
streamCharsSeen: 0,
175183
repetitionCheckedAt: 0,
@@ -215,11 +223,6 @@ function deltaText(event: { readonly data?: unknown; readonly text?: string }):
215223
return event.text ?? ""
216224
}
217225

218-
const namedCallData = type({ "name?": "string" })
219-
const toolStartData = type({
220-
call: { "name?": "string" },
221-
})
222-
223226
function quotaFromInferenceError(
224227
data: unknown,
225228
nowMs: number,
@@ -231,49 +234,45 @@ function quotaFromInferenceError(
231234
return { retryAfterMs, retryAt: nowMs + retryAfterMs }
232235
}
233236

234-
function toolName(data: unknown): string | null {
235-
const named = namedCallData(data)
236-
if (!(named instanceof type.errors) && named.name !== undefined) {
237-
return named.name
238-
}
239-
const started = toolStartData(data)
240-
if (!(started instanceof type.errors) && started.call.name !== undefined) {
241-
return started.call.name
237+
type CallIdentity = { readonly id?: string; readonly name?: string }
238+
239+
// Both flat streamed shapes (`{ callId?, name? }`) and the nested tool.start
240+
// shape (`{ call: { id?, callId?, name? } }`) are parsed here so every call
241+
// site — the tool name shown in the UI and the activeToolCalls bookkeeping —
242+
// reads one identity off one parse, instead of two schemas that could drift.
243+
const callEventData = type({
244+
"callId?": "string",
245+
"name?": "string",
246+
"call?": { "id?": "string", "callId?": "string", "name?": "string" },
247+
})
248+
249+
function streamedCallIdentity(data: unknown): CallIdentity {
250+
const parsed = callEventData(data)
251+
if (parsed instanceof type.errors) return {}
252+
const id = parsed.callId ?? parsed.call?.id ?? parsed.call?.callId
253+
const name = parsed.name ?? parsed.call?.name
254+
return {
255+
...(id !== undefined ? { id } : {}),
256+
...(name !== undefined ? { name } : {}),
242257
}
243-
return null
244258
}
245259

246-
const callIdData = type({ "callId?": "string", "name?": "string" })
247-
const toolStartCallData = type({
248-
call: { "id?": "string", "callId?": "string", "name?": "string" },
249-
})
260+
function toolName(data: unknown): string | null {
261+
return streamedCallIdentity(data).name ?? null
262+
}
263+
250264
const toolDoneData = type({
251265
result: { "callId?": "string", "name?": "string" },
252266
})
253267

254-
/**
255-
* Stable handle for one outstanding tool call. Providers that stream a callId
256-
* give a real one; the rest fall back to the name so at least the count is
257-
* right, which is all the settle decision reads.
258-
*/
259-
function streamedCallId(data: unknown): string {
260-
const parsed = callIdData(data)
261-
if (!(parsed instanceof type.errors)) {
262-
if (parsed.callId !== undefined) return parsed.callId
263-
if (parsed.name !== undefined) return parsed.name
264-
}
265-
const started = toolStartCallData(data)
266-
if (!(started instanceof type.errors)) {
267-
const { id, callId, name } = started.call
268-
return id ?? callId ?? name ?? "tool"
269-
}
270-
return "tool"
271-
}
272-
273-
function resultCallId(data: unknown): string {
268+
function resultIdentity(data: unknown): CallIdentity {
274269
const parsed = toolDoneData(data)
275-
if (parsed instanceof type.errors) return "tool"
276-
return parsed.result.callId ?? parsed.result.name ?? "tool"
270+
if (parsed instanceof type.errors) return {}
271+
const { callId, name } = parsed.result
272+
return {
273+
...(callId !== undefined ? { id: callId } : {}),
274+
...(name !== undefined ? { name } : {}),
275+
}
277276
}
278277

279278
function withActiveCall(
@@ -296,6 +295,110 @@ function withoutActiveCall(
296295
return active.slice(1)
297296
}
298297

298+
type CallTracking = {
299+
readonly activeToolCalls: readonly string[]
300+
/**
301+
* Real id for a tool name once one has been seen. A name-only announcement
302+
* (start/end with no callId) and the id-bearing tool.start for the same
303+
* call share this mapping so the second collapses onto the first entry
304+
* instead of adding a duplicate. Two concurrent calls to the same tool
305+
* still collide here — the event stream carries no signal to tell them
306+
* apart until both have real ids — but that ambiguity predates this fix:
307+
* the original name-keyed tracking collapsed them identically.
308+
*/
309+
readonly callIdByName: Readonly<Record<string, string>>
310+
}
311+
312+
/**
313+
* Canonicalize one logical call's identity at the event boundary: a
314+
* name-only announcement (no callId yet) and a later id-bearing one for the
315+
* same call must collapse onto a single activeToolCalls entry, not two.
316+
*/
317+
function registerActiveCall(
318+
tracking: CallTracking,
319+
identity: CallIdentity,
320+
): CallTracking {
321+
const { activeToolCalls, callIdByName } = tracking
322+
323+
if (identity.id !== undefined) {
324+
const nextCallIdByName =
325+
identity.name !== undefined
326+
? { ...callIdByName, [identity.name]: identity.id }
327+
: callIdByName
328+
// A provisional entry may already be tracking this call under its name —
329+
// promote it onto the real id in place instead of adding a duplicate.
330+
const withoutPlaceholder =
331+
identity.name !== undefined && activeToolCalls.includes(identity.name)
332+
? activeToolCalls.filter((c) => c !== identity.name)
333+
: activeToolCalls
334+
return {
335+
activeToolCalls: withActiveCall(withoutPlaceholder, identity.id),
336+
callIdByName: nextCallIdByName,
337+
}
338+
}
339+
340+
if (identity.name !== undefined) {
341+
const id = callIdByName[identity.name] ?? identity.name
342+
return { activeToolCalls: withActiveCall(activeToolCalls, id), callIdByName }
343+
}
344+
345+
return { activeToolCalls: withActiveCall(activeToolCalls, "tool"), callIdByName }
346+
}
347+
348+
function withoutCallIdByName(
349+
callIdByName: Readonly<Record<string, string>>,
350+
name: string,
351+
): Readonly<Record<string, string>> {
352+
if (!(name in callIdByName)) return callIdByName
353+
return Object.fromEntries(
354+
Object.entries(callIdByName).filter(([n]) => n !== name),
355+
)
356+
}
357+
358+
/**
359+
* Which tool name (if any) maps to this id — tool.done rarely carries the
360+
* name itself, so resolving the id back to its name is the only way to clear
361+
* a finished call's entry without depending on the result payload's shape.
362+
*/
363+
function nameForCallId(
364+
callIdByName: Readonly<Record<string, string>>,
365+
id: string,
366+
): string | undefined {
367+
return Object.entries(callIdByName).find(([, v]) => v === id)?.[0]
368+
}
369+
370+
function unregisterActiveCall(
371+
tracking: CallTracking,
372+
identity: CallIdentity,
373+
): CallTracking {
374+
const { activeToolCalls, callIdByName } = tracking
375+
376+
if (identity.id !== undefined) {
377+
// Clear the mapping once its call resolves, or a later call reusing the
378+
// same tool name would resolve straight to this now-finished id instead
379+
// of tracking its own — reproducing the leak this function exists to fix.
380+
const resolvedName = identity.name ?? nameForCallId(callIdByName, identity.id)
381+
const nextCallIdByName =
382+
resolvedName !== undefined
383+
? withoutCallIdByName(callIdByName, resolvedName)
384+
: callIdByName
385+
return {
386+
activeToolCalls: withoutActiveCall(activeToolCalls, identity.id),
387+
callIdByName: nextCallIdByName,
388+
}
389+
}
390+
391+
if (identity.name !== undefined) {
392+
const id = callIdByName[identity.name] ?? identity.name
393+
return {
394+
activeToolCalls: withoutActiveCall(activeToolCalls, id),
395+
callIdByName: withoutCallIdByName(callIdByName, identity.name),
396+
}
397+
}
398+
399+
return { activeToolCalls: withoutActiveCall(activeToolCalls, "tool"), callIdByName }
400+
}
401+
299402
const streaming = (
300403
state: TurnState,
301404
kind: "text" | "thinking",
@@ -432,14 +535,10 @@ export function turnStateFromEvent(
432535
case "inference.tool_call.start":
433536
case "inference.tool_call.end":
434537
case "tool.start": {
435-
const running = runningTool(state, toolName(event.data), nowMs)
436-
return {
437-
...running,
438-
activeToolCalls: withActiveCall(
439-
state.activeToolCalls,
440-
streamedCallId(event.data),
441-
),
442-
}
538+
const identity = streamedCallIdentity(event.data)
539+
const running = runningTool(state, identity.name ?? null, nowMs)
540+
const tracking = registerActiveCall(running, identity)
541+
return { ...running, ...tracking }
443542
}
444543

445544
case "tool_call": {
@@ -455,7 +554,18 @@ export function turnStateFromEvent(
455554

456555
// Tool finished: the model is being called again, so the awaiting-response
457556
// clock restarts rather than the tool clock continuing.
458-
case "tool.done":
557+
case "tool.done": {
558+
const tracking = unregisterActiveCall(state, resultIdentity(event.data))
559+
return {
560+
...state,
561+
...tracking,
562+
awaitingResponse: true,
563+
streamingType: null,
564+
currentToolName: null,
565+
lastActivityAt: nowMs,
566+
}
567+
}
568+
459569
case "tool_result":
460570
return {
461571
...state,
@@ -465,9 +575,7 @@ export function turnStateFromEvent(
465575
lastActivityAt: nowMs,
466576
activeToolCalls: withoutActiveCall(
467577
state.activeToolCalls,
468-
event.type === "tool.done"
469-
? resultCallId(event.data)
470-
: (event.name ?? "tool"),
578+
event.name ?? "tool",
471579
),
472580
}
473581

0 commit comments

Comments
 (0)