Skip to content

Commit e408348

Browse files
committed
fix(tui): present approvals parked by turns that started without a send
An ask-tier call parks on a reactor gate and persists a pending approval. Only a send() turns that park into a suspended result, which is what hands the approval to the operator. A turn started by deliver() (a worker wake, a background shell exit, a queued prompt on an idle agent) emits reactor.gate.blocked with no send awaiting it, so nothing asked the operator. The call sat parked, with no ask and no tool activity, until the approval timed out. The stream sink now routes an approval gate-blocked event through the same correlated approval path a send uses. Whichever of this route and a send's suspended result arrives first presents the approval and the other joins it, so the operator is asked once. If presenting fails, the approval stays as the stall watchdog's candidate. The watchdog resume reports a content-free outcome code instead of a boolean. It counts only a delivered decision as resumed, since the handler also settles true when it drops a timed-out or superseded decision. The resume status is shown only once it is about to present, and a stall that ended no longer makes the next stall wait out a budget. An end-to-end test drives the real reactor with a bare deliver and pins the silent park, the stream route, and the watchdog re-presenting the persisted approval. Handler tests now run against the real approval handler rather than mocks.
1 parent dfd5685 commit e408348

7 files changed

Lines changed: 691 additions & 91 deletions

File tree

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+
});

0 commit comments

Comments
 (0)