@@ -78,23 +78,24 @@ export function requestFromApprovalSnapshot(
7878// along and match block.callId; without it the text-only scan stays as the
7979// fallback so a genuinely late decision is still dropped.
8080
81- function settledAfterSuspend (
81+ function timeoutResultAfterSuspend (
8282 turns : Awaited < ReturnType < Agent [ "history" ] > > ,
8383 fromIndex : number ,
8484 parkedCallId : string | undefined ,
85- ) : boolean {
86- return turns
87- . slice ( fromIndex )
88- . flatMap ( ( turn ) => turn . content )
89- . some (
90- ( block ) =>
91- block . type === "tool_result" &&
92- ( parkedCallId === undefined || block . callId === parkedCallId ) &&
93- block . content . some (
94- ( part ) =>
95- part . type === "text" && part . text === APPROVAL_TIMEOUT_RESULT_TEXT ,
96- ) ,
97- ) ;
85+ ) : string | undefined {
86+ for ( const block of turns . slice ( fromIndex ) . flatMap ( ( turn ) => turn . content ) ) {
87+ if (
88+ block . type === "tool_result" &&
89+ ( parkedCallId === undefined || block . callId === parkedCallId ) &&
90+ block . content . some (
91+ ( part ) =>
92+ part . type === "text" && part . text === APPROVAL_TIMEOUT_RESULT_TEXT ,
93+ )
94+ ) {
95+ return block . callId ;
96+ }
97+ }
98+ return undefined ;
9899}
99100
100101function stableArgs ( value : unknown ) : string {
@@ -111,10 +112,18 @@ function stableArgs(value: unknown): string {
111112
112113// Derive this suspension's parked call id from history: the pre-watermark
113114// tool_call matching the snapshot's name and arguments that has no tool
114- // result anywhere yet. A parked call is neither run nor answered, so the only
115- // unanswered match is this suspension's own call; a timed-out sibling is
116- // answered by its timeout result and drops out of the candidates. Returns
117- // undefined unless exactly one candidate matches.
115+ // result anywhere yet. A parked call is neither run nor answered, so an
116+ // unanswered match is normally this suspension's own call; a timed-out
117+ // sibling is answered by its timeout result and drops out of the candidates.
118+ // Returns undefined unless exactly one candidate matches.
119+ //
120+ // Known limitation: identical name+args twins are ambiguous. When this
121+ // suspension's own timeout fires while an identical twin sits unanswered,
122+ // the twin is the exact-one survivor, so the settled check misses and the
123+ // late decision is delivered instead of dropped. The sibling-timeout mirror
124+ // (identical args, sibling answered) still resolves to the live call and
125+ // delivers. Telling identical twins apart needs the resolveParkedCallId
126+ // lookup below; the history heuristic cannot do it.
118127function parkedCallIdFromHistory (
119128 turns : Awaited < ReturnType < Agent [ "history" ] > > ,
120129 fromIndex : number ,
@@ -193,6 +202,14 @@ export function createApprovalResume(args: {
193202 // the minted correlationId, so this is what ties them together. Takes
194203 // precedence over the history derivation below; absent callers fall back
195204 // to it.
205+ //
206+ // Heuristic-only in production: neither the exec nor the TUI caller wires
207+ // this, because the vendored reactor surface ({ start, deliver, abort })
208+ // exposes no correlationId-to-call lookup (see
209+ // permission/decline-markers.ts), and the suspension snapshot carries
210+ // name+arguments without the call id. Wire this if upstream ever exports
211+ // the pending-operation lookup; until then the history derivation is the
212+ // live path.
196213 resolveParkedCallId ?: ( correlationId : string ) => string | undefined ;
197214 gate : PermissionGate ;
198215} ) : ApprovalResume {
@@ -290,11 +307,18 @@ export function createApprovalResume(args: {
290307 const parkedCallId =
291308 args . resolveParkedCallId ?.( correlationId ) ??
292309 parkedCallIdFromHistory ( history , turnsAtSuspend , approvalSnapshot ) ;
293- if ( settledAfterSuspend ( history , turnsAtSuspend , parkedCallId ) ) {
310+ const matchedTimeoutCallId = timeoutResultAfterSuspend (
311+ history ,
312+ turnsAtSuspend ,
313+ parkedCallId ,
314+ ) ;
315+ if ( matchedTimeoutCallId !== undefined ) {
294316 // The reactor already answered the parked call (its approval timeout
295317 // fired while the surface was still up). Delivering now would append
296318 // the raw decision JSON as an uncorrelated user turn — drop and log.
297- logger . warn `late approval decision dropped correlation=${ correlationId } outcome=${ outcome ?. allow === true ? "approved" : "rejected" } ` ;
319+ // The matched call id rides along so a fallback drop (parkedCallId
320+ // undefined) can still be attributed to the timeout that caused it.
321+ logger . warn `late approval decision dropped correlation=${ correlationId } timeoutCall=${ matchedTimeoutCallId } outcome=${ outcome ?. allow === true ? "approved" : "rejected" } ` ;
298322 return true ;
299323 }
300324 if ( outcome === undefined || ! outcome . allow ) {
0 commit comments