Skip to content

Commit 7e87d6d

Browse files
committed
Match approval timeout to the parked call identity
1 parent e730cda commit 7e87d6d

1 file changed

Lines changed: 66 additions & 4 deletions

File tree

src/session/approval-resume.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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

7681
function 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+
93146
function 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

Comments
 (0)