Skip to content

Commit e730cda

Browse files
committed
Add regression test for parallel parked approval timeout
1 parent a76f1c6 commit e730cda

1 file changed

Lines changed: 168 additions & 0 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { Agent, SendResult } from "@intx/agent";
3+
import type {
4+
ApprovalSnapshot,
5+
ConversationTurn,
6+
InboundMessage,
7+
} from "@intx/types/runtime";
8+
9+
import { APPROVAL_TIMEOUT_RESULT_TEXT } from "../permission/decline-markers.js";
10+
import type { PermissionGate } from "../permission/gate.js";
11+
import { createApprovalResume } from "./approval-resume.js";
12+
13+
function assistantTurn(
14+
calls: { id: string; name: string; command: string }[],
15+
): ConversationTurn {
16+
return {
17+
role: "assistant",
18+
content: calls.map((call) => ({
19+
type: "tool_call" as const,
20+
id: call.id,
21+
name: call.name,
22+
arguments: { command: call.command },
23+
})),
24+
timestamp: 1,
25+
};
26+
}
27+
28+
function timeoutTurn(callId: string): ConversationTurn {
29+
return {
30+
role: "user",
31+
content: [
32+
{
33+
type: "tool_result" as const,
34+
callId,
35+
content: [
36+
{ type: "text" as const, text: APPROVAL_TIMEOUT_RESULT_TEXT },
37+
],
38+
isError: true,
39+
},
40+
],
41+
timestamp: 2,
42+
};
43+
}
44+
45+
function suspension(
46+
correlationId: string,
47+
command: string,
48+
): Extract<SendResult, { type: "suspended" }> {
49+
const snapshot: ApprovalSnapshot = {
50+
name: "run_shell",
51+
description: "run a shell command",
52+
inputSchema: {},
53+
arguments: { command },
54+
};
55+
return { type: "suspended", correlationId, approvalSnapshot: snapshot };
56+
}
57+
58+
function setup(args: {
59+
preTurns: ConversationTurn[];
60+
onGate: (turns: ConversationTurn[]) => void;
61+
resolveParkedCallId?: (correlationId: string) => string | undefined;
62+
}) {
63+
const turns: ConversationTurn[] = [...args.preTurns];
64+
const delivered: InboundMessage[] = [];
65+
const agent = {
66+
history: async () => turns,
67+
deliver: (message: InboundMessage) => {
68+
delivered.push(message);
69+
},
70+
};
71+
const gate = {
72+
resolveSuspended: async () => {
73+
args.onGate(turns);
74+
return { allow: true };
75+
},
76+
} as unknown as PermissionGate;
77+
const resume = createApprovalResume({
78+
getAgent: () => agent as Pick<Agent, "deliver" | "history">,
79+
gate,
80+
...(args.resolveParkedCallId !== undefined
81+
? { resolveParkedCallId: args.resolveParkedCallId }
82+
: {}),
83+
});
84+
return { resume, delivered };
85+
}
86+
87+
function decisionBody(message: InboundMessage): { outcome: string } {
88+
if (message.content === undefined)
89+
throw new Error("expected a decision body");
90+
return JSON.parse(message.content) as { outcome: string };
91+
}
92+
93+
function deliveredCorrelationId(message: InboundMessage): string {
94+
const correlationId = message.headers.interchangeCorrelationId;
95+
if (correlationId === undefined)
96+
throw new Error("expected an interchange correlation id");
97+
return correlationId;
98+
}
99+
100+
describe("approval-resume parallel-parked approvals", () => {
101+
test("delivers A's decision when a different parked call's approval times out", async () => {
102+
const { resume, delivered } = setup({
103+
preTurns: [
104+
assistantTurn([
105+
{ id: "call-A", name: "run_shell", command: "echo alpha" },
106+
{ id: "call-B", name: "run_shell", command: "echo bravo" },
107+
]),
108+
],
109+
onGate: (turns) => {
110+
turns.push(timeoutTurn("call-B"));
111+
},
112+
});
113+
114+
const handled = await resume.handle(suspension("corr-A", "echo alpha"));
115+
116+
expect(handled).toBe(true);
117+
expect(delivered).toHaveLength(1);
118+
const message = delivered[0];
119+
if (message === undefined) throw new Error("expected a delivered decision");
120+
expect(deliveredCorrelationId(message)).toBe("corr-A");
121+
expect(decisionBody(message).outcome).toBe("approved");
122+
});
123+
124+
test("still drops a genuinely late decision for the same parked call", async () => {
125+
const { resume, delivered } = setup({
126+
preTurns: [
127+
assistantTurn([
128+
{ id: "call-A", name: "run_shell", command: "echo alpha" },
129+
{ id: "call-B", name: "run_shell", command: "echo bravo" },
130+
]),
131+
],
132+
onGate: (turns) => {
133+
turns.push(timeoutTurn("call-A"));
134+
},
135+
});
136+
137+
const handled = await resume.handle(suspension("corr-A", "echo alpha"));
138+
139+
expect(handled).toBe(true);
140+
expect(delivered).toHaveLength(0);
141+
});
142+
143+
test("pending-operation lookup identifies the parked call without history tool calls", async () => {
144+
const { resume, delivered } = setup({
145+
preTurns: [
146+
{
147+
role: "user",
148+
content: [{ type: "text" as const, text: "run two shell commands" }],
149+
timestamp: 1,
150+
},
151+
],
152+
onGate: (turns) => {
153+
turns.push(timeoutTurn("call-B"));
154+
},
155+
resolveParkedCallId: (correlationId) =>
156+
correlationId === "corr-A" ? "call-A" : undefined,
157+
});
158+
159+
const handled = await resume.handle(suspension("corr-A", "echo alpha"));
160+
161+
expect(handled).toBe(true);
162+
expect(delivered).toHaveLength(1);
163+
const message = delivered[0];
164+
if (message === undefined) throw new Error("expected a delivered decision");
165+
expect(deliveredCorrelationId(message)).toBe("corr-A");
166+
expect(decisionBody(message).outcome).toBe("approved");
167+
});
168+
});

0 commit comments

Comments
 (0)