Skip to content

Commit 1ef7f47

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 2b6b0ec commit 1ef7f47

2 files changed

Lines changed: 238 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: 154 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,39 @@ 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+
return {
253+
id: parsed.callId ?? parsed.call?.id ?? parsed.call?.callId,
254+
name: parsed.name ?? parsed.call?.name,
242255
}
243-
return null
244256
}
245257

246-
const callIdData = type({ "callId?": "string", "name?": "string" })
247-
const toolStartCallData = type({
248-
call: { "id?": "string", "callId?": "string", "name?": "string" },
249-
})
258+
function toolName(data: unknown): string | null {
259+
return streamedCallIdentity(data).name ?? null
260+
}
261+
250262
const toolDoneData = type({
251263
result: { "callId?": "string", "name?": "string" },
252264
})
253265

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 {
266+
function resultIdentity(data: unknown): CallIdentity {
274267
const parsed = toolDoneData(data)
275-
if (parsed instanceof type.errors) return "tool"
276-
return parsed.result.callId ?? parsed.result.name ?? "tool"
268+
if (parsed instanceof type.errors) return {}
269+
return { id: parsed.result.callId, name: parsed.result.name }
277270
}
278271

279272
function withActiveCall(
@@ -296,6 +289,110 @@ function withoutActiveCall(
296289
return active.slice(1)
297290
}
298291

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

445538
case "tool_call": {
@@ -455,7 +548,18 @@ export function turnStateFromEvent(
455548

456549
// Tool finished: the model is being called again, so the awaiting-response
457550
// clock restarts rather than the tool clock continuing.
458-
case "tool.done":
551+
case "tool.done": {
552+
const tracking = unregisterActiveCall(state, resultIdentity(event.data))
553+
return {
554+
...state,
555+
...tracking,
556+
awaitingResponse: true,
557+
streamingType: null,
558+
currentToolName: null,
559+
lastActivityAt: nowMs,
560+
}
561+
}
562+
459563
case "tool_result":
460564
return {
461565
...state,
@@ -465,9 +569,7 @@ export function turnStateFromEvent(
465569
lastActivityAt: nowMs,
466570
activeToolCalls: withoutActiveCall(
467571
state.activeToolCalls,
468-
event.type === "tool.done"
469-
? resultCallId(event.data)
470-
: (event.name ?? "tool"),
572+
event.name ?? "tool",
471573
),
472574
}
473575

0 commit comments

Comments
 (0)