@@ -93,6 +93,15 @@ export interface TurnState {
9393 * onto one `activeToolCalls` entry. See `registerActiveCall`.
9494 */
9595 readonly callIdByName : Readonly < Record < string , string > > ;
96+ /**
97+ * Tool name for each in-flight real call id, so two concurrent calls to
98+ * the same tool each keep their own record. `callIdByName` holds only one
99+ * id per name — the latest registration wins — so when the mapping-owning
100+ * sibling resolves first and clears that slot, the leftover earlier call
101+ * would otherwise lose its name and (for stall-bounded polls like
102+ * `shell_collect`) its stall budget. See `registerActiveCall`.
103+ */
104+ readonly callNameById : Readonly < Record < string , string > > ;
96105 /**
97106 * Tail of the text/thinking output streamed in the current uninterrupted
98107 * streaming cycle. A tool call ends the cycle and clears it: a model
@@ -147,6 +156,7 @@ export function initialTurnState(nowMs: number): TurnState {
147156 quota : null ,
148157 activeToolCalls : [ ] ,
149158 callIdByName : { } ,
159+ callNameById : { } ,
150160 streamText : "" ,
151161 repeating : false ,
152162 repeatingSinceTokenCount : null ,
@@ -189,6 +199,7 @@ export function turnStateOnSubmit(state: TurnState, nowMs: number): TurnState {
189199 lastActivityAt : nowMs ,
190200 activeToolCalls : [ ] ,
191201 callIdByName : { } ,
202+ callNameById : { } ,
192203 streamText : "" ,
193204 repeating : false ,
194205 repeatingSinceTokenCount : null ,
@@ -385,6 +396,23 @@ interface CallTracking {
385396 * the original name-keyed tracking collapsed them identically.
386397 */
387398 readonly callIdByName : Readonly < Record < string , string > > ;
399+ /**
400+ * Tool name for each in-flight real call id. Unlike `callIdByName` this
401+ * keeps one entry per call, so concurrent same-name siblings do not
402+ * overwrite each other — the entry that survives is keyed by the
403+ * leftover call's own id, not the shared name.
404+ */
405+ readonly callNameById : Readonly < Record < string , string > > ;
406+ }
407+
408+ function withoutCallNameById (
409+ callNameById : Readonly < Record < string , string > > ,
410+ id : string ,
411+ ) : Readonly < Record < string , string > > {
412+ if ( ! ( id in callNameById ) ) return callNameById ;
413+ return Object . fromEntries (
414+ Object . entries ( callNameById ) . filter ( ( [ callId ] ) => callId !== id ) ,
415+ ) ;
388416}
389417
390418/**
@@ -396,13 +424,17 @@ function registerActiveCall(
396424 tracking : CallTracking ,
397425 identity : CallIdentity ,
398426) : CallTracking {
399- const { activeToolCalls, callIdByName } = tracking ;
427+ const { activeToolCalls, callIdByName, callNameById } = tracking ;
400428
401429 if ( identity . id !== undefined ) {
402430 const nextCallIdByName =
403431 identity . name !== undefined
404432 ? { ...callIdByName , [ identity . name ] : identity . id }
405433 : callIdByName ;
434+ const nextCallNameById =
435+ identity . name !== undefined
436+ ? { ...callNameById , [ identity . id ] : identity . name }
437+ : callNameById ;
406438 // A provisional entry may already be tracking this call under its name —
407439 // promote it onto the real id in place instead of adding a duplicate.
408440 const withoutPlaceholder =
@@ -412,6 +444,7 @@ function registerActiveCall(
412444 return {
413445 activeToolCalls : withActiveCall ( withoutPlaceholder , identity . id ) ,
414446 callIdByName : nextCallIdByName ,
447+ callNameById : nextCallNameById ,
415448 } ;
416449 }
417450
@@ -420,12 +453,14 @@ function registerActiveCall(
420453 return {
421454 activeToolCalls : withActiveCall ( activeToolCalls , id ) ,
422455 callIdByName,
456+ callNameById,
423457 } ;
424458 }
425459
426460 return {
427461 activeToolCalls : withActiveCall ( activeToolCalls , "tool" ) ,
428462 callIdByName,
463+ callNameById,
429464 } ;
430465}
431466
@@ -455,12 +490,14 @@ function unregisterActiveCall(
455490 tracking : CallTracking ,
456491 identity : CallIdentity ,
457492) : CallTracking {
458- const { activeToolCalls, callIdByName } = tracking ;
493+ const { activeToolCalls, callIdByName, callNameById } = tracking ;
459494
460495 if ( identity . id !== undefined ) {
461496 // Clear the mapping once its call resolves, or a later call reusing the
462497 // same tool name would resolve straight to this now-finished id instead
463498 // of tracking its own — reproducing the leak this function exists to fix.
499+ // The per-id record clears only the finished call's own entry, so a
500+ // concurrent same-name sibling keeps its name (CL-8059).
464501 const resolvedName =
465502 identity . name ?? nameForCallId ( callIdByName , identity . id ) ;
466503 const nextCallIdByName =
@@ -470,6 +507,7 @@ function unregisterActiveCall(
470507 return {
471508 activeToolCalls : withoutActiveCall ( activeToolCalls , identity . id ) ,
472509 callIdByName : nextCallIdByName ,
510+ callNameById : withoutCallNameById ( callNameById , identity . id ) ,
473511 } ;
474512 }
475513
@@ -478,12 +516,14 @@ function unregisterActiveCall(
478516 return {
479517 activeToolCalls : withoutActiveCall ( activeToolCalls , id ) ,
480518 callIdByName : withoutCallIdByName ( callIdByName , identity . name ) ,
519+ callNameById : withoutCallNameById ( callNameById , id ) ,
481520 } ;
482521 }
483522
484523 return {
485524 activeToolCalls : withoutActiveCall ( activeToolCalls , "tool" ) ,
486525 callIdByName,
526+ callNameById,
487527 } ;
488528}
489529
0 commit comments