@@ -71,25 +71,78 @@ export function requestFromApprovalSnapshot(
7171// The reactor's approval timeout answers the parked call with this exact
7272// upstream text (see permission/decline-markers.ts) before removing the
7373// correlation, so its presence after the suspension watermark marks the
74- // correlation as settled.
74+ // correlation as settled — but only when the result answers this very call.
75+ // Parallel-parked calls each time out into their own tool result (callId is
76+ // the original tool-call id, not the minted correlationId), so matching text
77+ // alone abandons a still-valid sibling decision. The parked call id must come
78+ // along and match block.callId; without it the text-only scan stays as the
79+ // fallback so a genuinely late decision is still dropped.
7580
7681function settledAfterSuspend (
7782 turns : Awaited < ReturnType < Agent [ "history" ] > > ,
7883 fromIndex : number ,
84+ parkedCallId : string | undefined ,
7985) : boolean {
8086 return turns
8187 . slice ( fromIndex )
8288 . flatMap ( ( turn ) => turn . content )
8389 . some (
8490 ( block ) =>
8591 block . type === "tool_result" &&
92+ ( parkedCallId === undefined || block . callId === parkedCallId ) &&
8693 block . content . some (
8794 ( part ) =>
8895 part . type === "text" && part . text === APPROVAL_TIMEOUT_RESULT_TEXT ,
8996 ) ,
9097 ) ;
9198}
9299
100+ function stableArgs ( value : unknown ) : string {
101+ if ( Array . isArray ( value ) ) return `[${ value . map ( stableArgs ) . join ( "," ) } ]` ;
102+ if ( typeof value === "object" && value !== null ) {
103+ const record = value as Record < string , unknown > ;
104+ return `{${ Object . keys ( record )
105+ . sort ( )
106+ . map ( ( key ) => `${ JSON . stringify ( key ) } :${ stableArgs ( record [ key ] ) } ` )
107+ . join ( "," ) } }`;
108+ }
109+ return JSON . stringify ( value ) ?? "null" ;
110+ }
111+
112+ // Derive this suspension's parked call id from history: the pre-watermark
113+ // 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.
118+ function parkedCallIdFromHistory (
119+ turns : Awaited < ReturnType < Agent [ "history" ] > > ,
120+ fromIndex : number ,
121+ snapshot : ApprovalSnapshot ,
122+ ) : string | undefined {
123+ const answered = new Set < string > ( ) ;
124+ for ( const turn of turns ) {
125+ for ( const block of turn . content ) {
126+ if ( block . type === "tool_result" ) answered . add ( block . callId ) ;
127+ }
128+ }
129+ const wanted = stableArgs ( snapshot . arguments ?? { } ) ;
130+ const candidates = new Set < string > ( ) ;
131+ for ( const turn of turns . slice ( 0 , fromIndex ) ) {
132+ for ( const block of turn . content ) {
133+ if (
134+ block . type === "tool_call" &&
135+ block . name === snapshot . name &&
136+ stableArgs ( block . arguments ) === wanted &&
137+ ! answered . has ( block . id )
138+ ) {
139+ candidates . add ( block . id ) ;
140+ }
141+ }
142+ }
143+ return candidates . size === 1 ? [ ...candidates ] [ 0 ] : undefined ;
144+ }
145+
93146function decisionMessage (
94147 correlationId : string ,
95148 outcome : "approved" | "rejected" ,
@@ -134,6 +187,13 @@ export function createApprovalResume(args: {
134187 // TUI: interrupt/clear bump this to reject the parked call on the old
135188 // agent before close/rebuild. Cleared when handle returns.
136189 registerParkedCancel ?: ( cancel : ( ( ) => void ) | undefined ) => void ;
190+ // Pending-operation lookup: map this suspension's correlationId to the
191+ // parked tool-call id (PendingOperation.suspendedCall.id). The timeout
192+ // result carries the original call id while the suspension carries only
193+ // the minted correlationId, so this is what ties them together. Takes
194+ // precedence over the history derivation below; absent callers fall back
195+ // to it.
196+ resolveParkedCallId ?: ( correlationId : string ) => string | undefined ;
137197 gate : PermissionGate ;
138198} ) : ApprovalResume {
139199 const { getAgent, gate } = args ;
@@ -226,9 +286,11 @@ export function createApprovalResume(args: {
226286 return true ;
227287 }
228288 args . registerParkedCancel ?.( undefined ) ;
229- if (
230- settledAfterSuspend ( await requireAgent ( ) . history ( ) , turnsAtSuspend )
231- ) {
289+ const history = await requireAgent ( ) . history ( ) ;
290+ const parkedCallId =
291+ args . resolveParkedCallId ?.( correlationId ) ??
292+ parkedCallIdFromHistory ( history , turnsAtSuspend , approvalSnapshot ) ;
293+ if ( settledAfterSuspend ( history , turnsAtSuspend , parkedCallId ) ) {
232294 // The reactor already answered the parked call (its approval timeout
233295 // fired while the surface was still up). Delivering now would append
234296 // the raw decision JSON as an uncorrelated user turn — drop and log.
0 commit comments