@@ -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-
223226function 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+
250262const 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
279272function 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+
299396const 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