|
| 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