Skip to content

Commit c049af8

Browse files
Merge branch 'main' into cl-9799-extract-corbits-agent-reviewer
2 parents b32536f + 3490291 commit c049af8

33 files changed

Lines changed: 2849 additions & 33 deletions

‎.gitignore‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ subagents/
3030

3131
# Working notes and design spikes — local only, never committed
3232
docs/plans/
33+
# Fleet/harness working notes — local only, never committed
34+
plans/
35+
36+
# Benchmark/eval scratch — local only, never committed
37+
tb-dataset/
38+
tb-rerun-50/
39+
terminal-bench/
40+
evals/**/__pycache__/
3341

3442
# Scratch repro tests — never committed
3543
__repro/
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)