@@ -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,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+
250264const 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
279278function 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+
299402const 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