Skip to content

Commit efefe8e

Browse files
committed
Add live-agent regression guard for followup_task (CL-6997)
lifecycle-tools.test.ts only exercised interrupt_agent/followup_task against fake registered closures at the tool/store layer, not run.ts's real onAgentReady wiring where followup calls agent!.send() on the same live agent object. Add a test that drives runSubAgent end to end with createAgentWithLiveToolDispatch replaced by a stub Agent, proving the followup send after an interrupt lands on the same agent instance (one construction, one shared message log) rather than a rebuilt one.
1 parent c54be80 commit efefe8e

1 file changed

Lines changed: 160 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* CL-6997 regression guard: lifecycle-tools.test.ts proves interrupt_agent /
3+
* followup_task behave correctly against *fake registered closures* at the
4+
* tool/store layer — it never exercises run.ts's real wiring, where
5+
* `followup` calls `agent!.send()` on the same live agent object created by
6+
* `createAgentWithLiveToolDispatch`. A future refactor could make
7+
* `followup_task` rebuild the agent instead of reusing it (exactly the
8+
* regression this feature exists to prevent — a rebuilt agent means the
9+
* worker re-reads the codebase from scratch) without failing any existing
10+
* test.
11+
*
12+
* This test drives the real `runSubAgent` (run.ts) end to end with the one
13+
* real dependency that would require live inference credentials —
14+
* `createAgentWithLiveToolDispatch` — replaced by a stub `Agent`. Everything
15+
* else (tool assembly, environment gathering, the dispatch brief, the
16+
* onAgentReady wiring, the interrupt/followup closures themselves) is the
17+
* genuine run.ts code path.
18+
*/
19+
import { describe, expect, test } from "bun:test";
20+
import { mkdtemp } from "node:fs/promises";
21+
import { tmpdir } from "node:os";
22+
import { join } from "node:path";
23+
24+
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
25+
import { createPermissionGate } from "../permission/gate.js";
26+
import type { RunSubAgentParams } from "./types.js";
27+
28+
const testPermissionGate = createPermissionGate({
29+
approvals: [],
30+
interactive: false,
31+
skipPermissions: true,
32+
});
33+
34+
async function tmpCwd(): Promise<string> {
35+
return mkdtemp(join(tmpdir(), "cl6997-live-agent-"));
36+
}
37+
38+
/** Minimal stand-in for the vendored `Agent` (dist/agent.d.ts), instrumented
39+
* to prove reuse: `sendLog` accumulates every message across BOTH the
40+
* original send and the later followup send, and rejects like the real
41+
* `Agent.send`'s documented `signal` option when its signal fires. */
42+
function createStubAgent() {
43+
const sendLog: string[] = [];
44+
return {
45+
sendLog,
46+
async send(content: string, opts?: { signal?: AbortSignal }) {
47+
sendLog.push(content);
48+
return await new Promise((resolve, reject) => {
49+
if (opts?.signal?.aborted === true) {
50+
reject(opts.signal.reason instanceof Error ? opts.signal.reason : new Error("aborted"));
51+
return;
52+
}
53+
const timer = setTimeout(
54+
() =>
55+
resolve({
56+
reply: `reply #${sendLog.length}`,
57+
turn: { role: "assistant", content: [] },
58+
}),
59+
20,
60+
);
61+
opts?.signal?.addEventListener(
62+
"abort",
63+
() => {
64+
clearTimeout(timer);
65+
reject(
66+
opts.signal!.reason instanceof Error ? opts.signal!.reason : new Error("aborted"),
67+
);
68+
},
69+
{ once: true },
70+
);
71+
});
72+
},
73+
stream: () => (async function* () {})(),
74+
deliver: () => {},
75+
close: async () => {},
76+
setSource: () => {},
77+
setSources: () => {},
78+
history: async () => [],
79+
checkpoints: async () => [],
80+
readAt: async () => [],
81+
blobReader: {},
82+
};
83+
}
84+
85+
describe("interrupt_agent / followup_task reuse the same live agent (CL-6997)", () => {
86+
test("followup after interrupt sends into the SAME agent instance — not a rebuilt one", async () => {
87+
const cwd = await tmpCwd();
88+
let constructions = 0;
89+
let capturedAgent: ReturnType<typeof createStubAgent> | undefined;
90+
91+
const outcome = await withMockedModuleDuring(
92+
import.meta.resolve("../agent/live-tool-dispatch.js"),
93+
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
94+
...real,
95+
createAgentWithLiveToolDispatch: async () => {
96+
constructions++;
97+
const stub = createStubAgent();
98+
capturedAgent = stub;
99+
return stub as unknown as Awaited<
100+
ReturnType<typeof real.createAgentWithLiveToolDispatch>
101+
>;
102+
},
103+
}),
104+
async () => {
105+
const { runSubAgent } = await import("./run.js");
106+
107+
let handles:
108+
| {
109+
close: (ms?: number) => Promise<void>;
110+
interrupt: () => void;
111+
followup: (message: string) => Promise<string>;
112+
}
113+
| undefined;
114+
115+
const params: RunSubAgentParams = {
116+
cwd,
117+
workdirBase: join(cwd, ".ctx"),
118+
permissionGate: testPermissionGate,
119+
provider: { providerName: "test", baseURL: "http://localhost", model: "test-model" },
120+
description: "live-agent reuse probe",
121+
prompt: "explore the codebase for the bug",
122+
persist: true,
123+
onAgentReady: (h) => {
124+
handles = h;
125+
},
126+
};
127+
128+
const runPromise = runSubAgent(params);
129+
130+
// onAgentReady fires before agent.send() is awaited; poll briefly
131+
// rather than assume a fixed number of ticks.
132+
for (let i = 0; i < 500 && handles === undefined; i++) {
133+
await new Promise((resolve) => setTimeout(resolve, 1));
134+
}
135+
if (handles === undefined) throw new Error("onAgentReady never fired");
136+
137+
handles.interrupt();
138+
const interruptedResult = await runPromise;
139+
140+
const reply = await handles.followup("do X instead, not what the original prompt said");
141+
return { interruptedResult, reply };
142+
},
143+
);
144+
145+
expect(outcome.interruptedResult.interrupted).toBe(true);
146+
// Exactly one agent was ever constructed across the interrupted turn and
147+
// the followup — a rebuild would show up here as constructions === 2.
148+
expect(constructions).toBe(1);
149+
expect(capturedAgent).toBeDefined();
150+
151+
// The load-bearing assertion: the SAME agent's message log holds both
152+
// the original turn's prompt and the followup message, proving the
153+
// followup was sent into the same live object rather than a fresh one
154+
// with empty history.
155+
expect(capturedAgent!.sendLog.length).toBe(2);
156+
expect(capturedAgent!.sendLog[0]).toContain("explore the codebase for the bug");
157+
expect(capturedAgent!.sendLog[1]).toBe("do X instead, not what the original prompt said");
158+
expect(outcome.reply).toBe("reply #2");
159+
});
160+
});

0 commit comments

Comments
 (0)