Skip to content

Commit cd69a29

Browse files
fix(approval): present parked approvals and settle the turns they end
fix(approval): present parked approvals and settle the turns they end
2 parents 4f3aaa6 + 2417791 commit cd69a29

14 files changed

Lines changed: 1690 additions & 11 deletions
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/**
2+
* An ask-tier call parked by a turn that started with a bare `deliver()` (a
3+
* worker wake, a background shell exit, a queued prompt on an idle agent)
4+
* emits `reactor.gate.blocked` with no `send()` awaiting a `suspended`
5+
* result, so nothing raises the approval. These tests drive the real reactor
6+
* to pin both the silent park and the two ways the TUI now surfaces it: the
7+
* stream route, and the watchdog's re-presentation of the persisted approval.
8+
*/
9+
import { describe, expect, test } from "bun:test";
10+
11+
import type { ReactorEmittedEvent } from "@intx/inference";
12+
import type { InboundMessage } from "@intx/types/runtime";
13+
14+
import { OPERATOR_ORIGINATED_FLAG } from "../src/agent/message-provenance.js";
15+
import { createPermissionGate } from "../src/permission/gate.js";
16+
import { createReactorAuthorize } from "../src/permission/reactor-authorize.js";
17+
import {
18+
createApprovalResume,
19+
createSuspendedApprovalRecovery,
20+
resolveParkedCallIdFromStore,
21+
} from "../src/session/approval-resume.js";
22+
import {
23+
closeIntegrationSession,
24+
openIntegrationSession,
25+
toolDoneEvents,
26+
type IntegrationSession,
27+
} from "./integration-harness.js";
28+
import { defined } from "../testkit/defined.js";
29+
30+
const CURL_CALL = {
31+
name: "run_shell",
32+
args: { command: "curl -sS https://example.com" },
33+
};
34+
35+
function deferredApprovalGate() {
36+
const asks: string[] = [];
37+
let release: ((outcome: { allow: boolean }) => void) | undefined;
38+
return {
39+
asks,
40+
gate: createPermissionGate({
41+
approvals: [],
42+
interactive: true,
43+
skipPermissions: false,
44+
auto: false,
45+
reactorGated: true,
46+
requestApproval: (request) => {
47+
asks.push(`${request.tool}:${request.subject}`);
48+
return new Promise((resolve) => {
49+
release = resolve;
50+
});
51+
},
52+
}),
53+
approve: () => release?.({ allow: true }),
54+
};
55+
}
56+
57+
type Ctx = ReturnType<typeof deferredApprovalGate>;
58+
59+
function deliveredPrompt(text: string): InboundMessage {
60+
return {
61+
ref: { uid: 1, mailbox: "INBOX" },
62+
headers: {
63+
from: "user@local",
64+
to: ["agent@local"],
65+
date: new Date().toISOString(),
66+
messageId: `<${crypto.randomUUID()}@local>`,
67+
interchangeType: "conversation.message",
68+
},
69+
flags: [OPERATOR_ORIGINATED_FLAG],
70+
content: text,
71+
signatureStatus: "missing",
72+
};
73+
}
74+
75+
async function waitFor(
76+
predicate: () => boolean,
77+
what: string,
78+
ms = 3_000,
79+
): Promise<void> {
80+
const deadline = Date.now() + ms;
81+
while (!predicate()) {
82+
if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`);
83+
await new Promise((resolve) => setTimeout(resolve, 5));
84+
}
85+
}
86+
87+
/**
88+
* Starts a turn with a bare deliver and returns once the reactor has parked.
89+
* `onParked` stands in for the stream sink's gate-blocked hook.
90+
*/
91+
async function parkWithBareDeliver(
92+
session: IntegrationSession,
93+
onParked?: (data: GateData) => void,
94+
) {
95+
const events: ReactorEmittedEvent[] = [];
96+
let reply: string | undefined;
97+
const stream = session.agent.stream();
98+
void (async () => {
99+
for await (const event of stream) {
100+
events.push(event);
101+
if (event.type === "reactor.gate.blocked") onParked?.(event.data);
102+
if (event.type === "connector.reply") reply = event.data.content;
103+
}
104+
})().catch(() => undefined);
105+
void session.harness
106+
.run({ wallClockBudgetMs: 30_000 })
107+
.catch(() => undefined);
108+
109+
session.agent.deliver(deliveredPrompt("Please fetch example.com with curl."));
110+
await waitFor(
111+
() => events.some((event) => event.type === "reactor.gate.blocked"),
112+
"the reactor to park",
113+
);
114+
const blocked = events.find((event) => event.type === "reactor.gate.blocked");
115+
if (blocked?.type !== "reactor.gate.blocked") throw new Error("not parked");
116+
return { events, blocked: blocked.data, reply: () => reply };
117+
}
118+
119+
type GateData = Extract<
120+
ReactorEmittedEvent,
121+
{ type: "reactor.gate.blocked" }
122+
>["data"];
123+
124+
async function open(ctx: Ctx) {
125+
const session = await openIntegrationSession({
126+
permissionGate: ctx.gate,
127+
authorize: createReactorAuthorize(ctx.gate),
128+
});
129+
session.harness.scenario.replyOnce("anthropic", { toolCalls: [CURL_CALL] });
130+
session.harness.scenario.replyOnce("anthropic", { text: "Fetched." });
131+
return session;
132+
}
133+
134+
function recoveryFor(session: IntegrationSession, ctx: Ctx) {
135+
const resume = createApprovalResume({
136+
getAgent: () => session.agent,
137+
resolveParkedCallId: (correlationId) =>
138+
resolveParkedCallIdFromStore(session.storage, correlationId),
139+
gate: ctx.gate,
140+
});
141+
return {
142+
resume,
143+
recovery: createSuspendedApprovalRecovery({
144+
storage: () => session.storage,
145+
resume,
146+
}),
147+
};
148+
}
149+
150+
describe("integration: ask-tier call parked by a bare deliver", () => {
151+
test.serial(
152+
"parks with a persisted approval and nobody asks the operator",
153+
async () => {
154+
const ctx = deferredApprovalGate();
155+
const session = await open(ctx);
156+
try {
157+
const turn = await parkWithBareDeliver(session);
158+
await new Promise((resolve) => setTimeout(resolve, 100));
159+
160+
expect(turn.blocked.reason).toBe("approval");
161+
expect(turn.blocked.correlationId).toBeDefined();
162+
const parkedCallId = await resolveParkedCallIdFromStore(
163+
session.storage,
164+
defined(turn.blocked.correlationId),
165+
);
166+
expect(parkedCallId).toBeDefined();
167+
// The silent hang: parked, persisted, and never presented.
168+
expect(ctx.asks).toEqual([]);
169+
expect(toolDoneEvents(turn.events)).toHaveLength(0);
170+
expect(turn.reply()).toBeUndefined();
171+
} finally {
172+
await closeIntegrationSession(session);
173+
}
174+
},
175+
);
176+
177+
test.serial(
178+
"routing the gate event raises one ask and the approved call runs once",
179+
async () => {
180+
const ctx = deferredApprovalGate();
181+
const session = await open(ctx);
182+
try {
183+
const { recovery } = recoveryFor(session, ctx);
184+
const turn = await parkWithBareDeliver(session, (data) =>
185+
recovery.observeParked(data, () => true),
186+
);
187+
await waitFor(() => ctx.asks.length === 1, "the operator ask");
188+
189+
ctx.approve();
190+
await waitFor(() => turn.reply() !== undefined, "the resumed reply");
191+
192+
expect(ctx.asks).toHaveLength(1);
193+
expect(
194+
turn.events.some(
195+
(e) => e.type === "tool.start" && e.data.call.name === "run_shell",
196+
),
197+
).toBe(true);
198+
expect(turn.reply()).toBe("Fetched.");
199+
} finally {
200+
await closeIntegrationSession(session);
201+
}
202+
},
203+
);
204+
205+
test.serial(
206+
"the watchdog re-presents the persisted approval when nothing routed it",
207+
async () => {
208+
const ctx = deferredApprovalGate();
209+
const session = await open(ctx);
210+
try {
211+
const { resume, recovery } = recoveryFor(session, ctx);
212+
const turn = await parkWithBareDeliver(session);
213+
expect(ctx.asks).toEqual([]);
214+
215+
recovery.capture(
216+
{
217+
type: "suspended",
218+
correlationId: defined(turn.blocked.correlationId),
219+
...(turn.blocked.approvalSnapshot !== undefined
220+
? { approvalSnapshot: turn.blocked.approvalSnapshot }
221+
: {}),
222+
},
223+
() => true,
224+
);
225+
const attempt = recovery.tryResumeOnce();
226+
await waitFor(() => ctx.asks.length === 1, "the re-presented ask");
227+
ctx.approve();
228+
229+
expect(await attempt).toEqual({ handled: true, code: "resumed" });
230+
await waitFor(() => turn.reply() !== undefined, "the resumed reply");
231+
expect(ctx.asks).toHaveLength(1);
232+
expect(turn.reply()).toBe("Fetched.");
233+
expect(resume.status(defined(turn.blocked.correlationId))).toBe(
234+
"handed-over",
235+
);
236+
} finally {
237+
await closeIntegrationSession(session);
238+
}
239+
},
240+
);
241+
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* A parked call that ends without running is answered by the reactor with a
3+
* synthetic error result and no `tool.done`. These tests capture the real
4+
* reactor's event stream for a rejected and an approved parked call and replay
5+
* it into the TUI bridge, so the bridge is judged against what the reactor
6+
* actually emits rather than a hand-written sequence.
7+
*/
8+
import { describe, expect, test } from "bun:test";
9+
10+
import type { ReactorEmittedEvent } from "@intx/inference";
11+
12+
import { createPermissionGate } from "../src/permission/gate.js";
13+
import { createReactorAuthorize } from "../src/permission/reactor-authorize.js";
14+
import {
15+
createApprovalResume,
16+
resolveParkedCallIdFromStore,
17+
} from "../src/session/approval-resume.js";
18+
import {
19+
attachSessionBridge,
20+
createRecordingPort,
21+
PARKED_CALL_NOT_RUN,
22+
} from "../src/tui/runtime-bridge.js";
23+
import { withAppShell } from "../src/tui/test-helpers.js";
24+
import {
25+
closeIntegrationSession,
26+
openIntegrationSession,
27+
runUntilSuspended,
28+
toolDoneEvents,
29+
} from "./integration-harness.js";
30+
31+
/** Drives one parked call to the operator's decision; returns every event. */
32+
async function parkedCallEvents(
33+
allow: boolean,
34+
): Promise<ReactorEmittedEvent[]> {
35+
let release: ((outcome: { allow: boolean }) => void) | undefined;
36+
const gate = createPermissionGate({
37+
approvals: [],
38+
interactive: true,
39+
skipPermissions: false,
40+
auto: false,
41+
reactorGated: true,
42+
requestApproval: () =>
43+
new Promise((resolve) => {
44+
release = resolve;
45+
}),
46+
});
47+
const session = await openIntegrationSession({
48+
permissionGate: gate,
49+
authorize: createReactorAuthorize(gate),
50+
});
51+
try {
52+
session.harness.scenario.replyOnce("anthropic", {
53+
toolCalls: [
54+
{
55+
name: "run_shell",
56+
args: { command: "curl -sS https://example.com" },
57+
},
58+
],
59+
});
60+
session.harness.scenario.replyOnce("anthropic", { text: "Understood." });
61+
62+
const turn = await runUntilSuspended(session, "Fetch example.com.");
63+
if (turn.result.type !== "suspended") throw new Error("did not park");
64+
const resume = createApprovalResume({
65+
getAgent: () => session.agent,
66+
gate,
67+
resolveParkedCallId: (correlationId) =>
68+
resolveParkedCallIdFromStore(session.storage, correlationId),
69+
});
70+
const handling = resume.handle(turn.result);
71+
for (let i = 0; i < 400 && release === undefined; i++) {
72+
await new Promise((resolve) => setTimeout(resolve, 5));
73+
}
74+
release?.({ allow });
75+
await handling;
76+
await turn.reply();
77+
// Let the run-ended event that follows the reply land.
78+
await new Promise((resolve) => setTimeout(resolve, 50));
79+
return [...turn.events];
80+
} finally {
81+
await closeIntegrationSession(session);
82+
}
83+
}
84+
85+
async function replayIntoBridge(events: readonly ReactorEmittedEvent[]) {
86+
let outcome:
87+
| {
88+
run: string;
89+
active: readonly string[];
90+
rows: { text: string; failed?: boolean; pending?: boolean }[];
91+
}
92+
| undefined;
93+
await withAppShell(
94+
(shell) => {
95+
const bridge = attachSessionBridge(shell, createRecordingPort());
96+
try {
97+
for (const event of events) {
98+
bridge.handle(event as { type: string; data?: unknown });
99+
}
100+
outcome = {
101+
run: shell.session.run,
102+
active: bridge.turn.activeToolCalls,
103+
rows: shell.streamLog.filter(
104+
(row) => row.role === "tool",
105+
) as unknown as {
106+
text: string;
107+
failed?: boolean;
108+
pending?: boolean;
109+
}[],
110+
};
111+
} finally {
112+
bridge.dispose();
113+
}
114+
},
115+
{ shell: { run: "busy" } },
116+
);
117+
if (outcome === undefined) throw new Error("bridge did not run");
118+
return outcome;
119+
}
120+
121+
describe("integration: a parked call's end settles the TUI turn", () => {
122+
test.serial(
123+
"a rejected call emits no tool.done, and the bridge still closes it and settles",
124+
async () => {
125+
const events = await parkedCallEvents(false);
126+
// The premise of the fix: nothing on the stream names the parked call.
127+
expect(toolDoneEvents(events)).toHaveLength(0);
128+
129+
const after = await replayIntoBridge(events);
130+
expect(after.active).toEqual([]);
131+
expect(after.run).toBe("idle");
132+
expect(after.rows).toHaveLength(1);
133+
expect(after.rows[0]?.failed).toBe(true);
134+
expect(after.rows[0]?.text).toBe(PARKED_CALL_NOT_RUN);
135+
},
136+
);
137+
138+
test.serial(
139+
"an approved call reports its own result and is not closed as not run",
140+
async () => {
141+
const events = await parkedCallEvents(true);
142+
expect(toolDoneEvents(events)).toHaveLength(1);
143+
144+
const after = await replayIntoBridge(events);
145+
expect(after.active).toEqual([]);
146+
expect(after.run).toBe("idle");
147+
expect(after.rows).toHaveLength(1);
148+
expect(after.rows[0]?.text).not.toBe(PARKED_CALL_NOT_RUN);
149+
},
150+
);
151+
});

0 commit comments

Comments
 (0)