|
1 | 1 | import { describe, expect, test } from "bun:test"; |
2 | 2 |
|
3 | | -import { createCloseAgentTool, createResumeAgentTool } from "./lifecycle-tools.js"; |
| 3 | +import { |
| 4 | + createCloseAgentTool, |
| 5 | + createResumeAgentTool, |
| 6 | + createInterruptAgentTool, |
| 7 | + createFollowupTaskTool, |
| 8 | +} from "./lifecycle-tools.js"; |
4 | 9 | import { createSubAgentSessionStore } from "./session-store.js"; |
5 | 10 |
|
6 | 11 | async function callTool( |
7 | | - tool: ReturnType<typeof createCloseAgentTool> | ReturnType<typeof createResumeAgentTool>, |
| 12 | + tool: |
| 13 | + | ReturnType<typeof createCloseAgentTool> |
| 14 | + | ReturnType<typeof createResumeAgentTool> |
| 15 | + | ReturnType<typeof createInterruptAgentTool> |
| 16 | + | ReturnType<typeof createFollowupTaskTool>, |
8 | 17 | args: Record<string, unknown>, |
9 | 18 | ): Promise<Record<string, unknown>> { |
10 | 19 | if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`); |
@@ -101,3 +110,151 @@ describe("resume_agent", () => { |
101 | 110 | expect(rawResult.isError).toBe(true); |
102 | 111 | }); |
103 | 112 | }); |
| 113 | + |
| 114 | +describe("interrupt_agent / followup_task", () => { |
| 115 | + test("interrupt then followup keeps prior context — the worker does not re-read from scratch", async () => { |
| 116 | + const sessions = createSubAgentSessionStore(); |
| 117 | + const worker = sessions.start({ |
| 118 | + description: "worker", |
| 119 | + agentId: "a", |
| 120 | + brief: "b", |
| 121 | + retained: true, |
| 122 | + }); |
| 123 | + sessions.markRunning(worker.id); |
| 124 | + |
| 125 | + // Simulates the live agent's own message history (what run.ts's |
| 126 | + // `followup`/`interrupt` closures actually close over) — a shared array, |
| 127 | + // not something recreated per call. |
| 128 | + const history: string[] = ["read src/index.ts", "found the bug on line 12"]; |
| 129 | + let interruptFired = false; |
| 130 | + sessions.registerInterrupt(worker.id, () => { |
| 131 | + interruptFired = true; |
| 132 | + }); |
| 133 | + sessions.registerFollowup(worker.id, async (message: string) => { |
| 134 | + history.push(message); |
| 135 | + return `Applying fix given ${history.length} prior turns of context.`; |
| 136 | + }); |
| 137 | + |
| 138 | + const interruptAgent = createInterruptAgentTool({ sessions }); |
| 139 | + const followupTask = createFollowupTaskTool({ sessions }); |
| 140 | + |
| 141 | + const interruptResult = await callTool(interruptAgent, { target: worker.id }); |
| 142 | + expect(interruptResult.status).toBe("interrupted"); |
| 143 | + expect(interruptFired).toBe(true); |
| 144 | + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("interrupted"); |
| 145 | + |
| 146 | + const followupResult = await callTool(followupTask, { |
| 147 | + target: worker.id, |
| 148 | + message: "actually fix line 12 directly, not line 20", |
| 149 | + }); |
| 150 | + expect(followupResult.status).toBe("completed"); |
| 151 | + |
| 152 | + // The load-bearing assertion: the worker's own history object still |
| 153 | + // holds the turns that predate the interrupt, plus the new one appended |
| 154 | + // in place — not a fresh array the followup started from empty. |
| 155 | + expect(history).toEqual([ |
| 156 | + "read src/index.ts", |
| 157 | + "found the bug on line 12", |
| 158 | + "actually fix line 12 directly, not line 20", |
| 159 | + ]); |
| 160 | + expect(history.length).toBe(3); |
| 161 | + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("completed"); |
| 162 | + expect(sessions.get(worker.id)?.report).toBe(followupResult.reply as string); |
| 163 | + }); |
| 164 | + |
| 165 | + test("followup_task on a completed retained worker reuses its existing session, not a fresh one", async () => { |
| 166 | + const sessions = createSubAgentSessionStore(); |
| 167 | + const worker = sessions.start({ |
| 168 | + description: "worker", |
| 169 | + agentId: "a", |
| 170 | + brief: "b", |
| 171 | + retained: true, |
| 172 | + }); |
| 173 | + const history: string[] = ["did the first task"]; |
| 174 | + sessions.registerFollowup(worker.id, async (message: string) => { |
| 175 | + history.push(message); |
| 176 | + return `done, history now ${history.length} turns`; |
| 177 | + }); |
| 178 | + sessions.complete(worker.id, "## Summary\nFirst task done."); |
| 179 | + |
| 180 | + const followupTask = createFollowupTaskTool({ sessions }); |
| 181 | + const result = await callTool(followupTask, { target: worker.id, message: "now do task two" }); |
| 182 | + |
| 183 | + expect(result.status).toBe("completed"); |
| 184 | + // Same session id throughout — never re-created — and its underlying |
| 185 | + // history object grew rather than being replaced. |
| 186 | + expect(sessions.get(worker.id)?.id).toBe(worker.id); |
| 187 | + expect(history).toEqual(["did the first task", "now do task two"]); |
| 188 | + |
| 189 | + const nonRetained = sessions.start({ description: "d2", agentId: "a", brief: "b" }); |
| 190 | + sessions.complete(nonRetained.id, "## Summary\nDone."); |
| 191 | + if (followupTask.kind !== "full") throw new Error("expected full tool"); |
| 192 | + const rejected = await followupTask.handler( |
| 193 | + { |
| 194 | + id: "c3", |
| 195 | + name: "followup_task", |
| 196 | + arguments: { target: nonRetained.id, message: "more work" }, |
| 197 | + }, |
| 198 | + new AbortController().signal, |
| 199 | + ); |
| 200 | + expect(rejected.isError).toBe(true); |
| 201 | + }); |
| 202 | + |
| 203 | + test("an interrupted session is resumable via followup_task and interrupt never touches close()", async () => { |
| 204 | + const sessions = createSubAgentSessionStore(); |
| 205 | + const worker = sessions.start({ |
| 206 | + description: "worker", |
| 207 | + agentId: "a", |
| 208 | + brief: "b", |
| 209 | + retained: true, |
| 210 | + }); |
| 211 | + sessions.markRunning(worker.id); |
| 212 | + |
| 213 | + let closeCalls = 0; |
| 214 | + sessions.registerClose(worker.id, async () => { |
| 215 | + closeCalls++; |
| 216 | + }); |
| 217 | + sessions.registerInterrupt(worker.id, () => { |
| 218 | + // Real interrupt handle: fires a dedicated signal, never close(). |
| 219 | + }); |
| 220 | + sessions.registerFollowup(worker.id, async () => "resumed cleanly"); |
| 221 | + |
| 222 | + const interruptAgent = createInterruptAgentTool({ sessions }); |
| 223 | + const followupTask = createFollowupTaskTool({ sessions }); |
| 224 | + |
| 225 | + await callTool(interruptAgent, { target: worker.id }); |
| 226 | + expect(closeCalls).toBe(0); |
| 227 | + |
| 228 | + const followupResult = await callTool(followupTask, { target: worker.id, message: "continue" }); |
| 229 | + expect(followupResult.status).toBe("completed"); |
| 230 | + expect(closeCalls).toBe(0); |
| 231 | + // No lock-strand risk from this path: close() was never invoked, so the |
| 232 | + // workdir lock close_agent's bounded teardown would otherwise release |
| 233 | + // was never at risk of being held by a wedged close in the first place. |
| 234 | + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("completed"); |
| 235 | + }); |
| 236 | + |
| 237 | + test("interrupt_agent and followup_task fail closed on a non-running / non-retained target", async () => { |
| 238 | + const sessions = createSubAgentSessionStore(); |
| 239 | + const notRunning = sessions.start({ description: "d", agentId: "a", brief: "b" }); |
| 240 | + sessions.complete(notRunning.id, "## Summary\nDone."); |
| 241 | + |
| 242 | + const interruptAgent = createInterruptAgentTool({ sessions }); |
| 243 | + const followupTask = createFollowupTaskTool({ sessions }); |
| 244 | + |
| 245 | + if (interruptAgent.kind !== "full") throw new Error("expected full tool"); |
| 246 | + const interruptErr = await interruptAgent.handler( |
| 247 | + { id: "c1", name: "interrupt_agent", arguments: { target: notRunning.id } }, |
| 248 | + new AbortController().signal, |
| 249 | + ); |
| 250 | + expect(interruptErr.isError).toBe(true); |
| 251 | + |
| 252 | + if (followupTask.kind !== "full") throw new Error("expected full tool"); |
| 253 | + const followupErr = await followupTask.handler( |
| 254 | + { id: "c2", name: "followup_task", arguments: { target: notRunning.id, message: "x" } }, |
| 255 | + new AbortController().signal, |
| 256 | + ); |
| 257 | + // Not retained, so followup_task must reject even though it is "completed". |
| 258 | + expect(followupErr.isError).toBe(true); |
| 259 | + }); |
| 260 | +}); |
0 commit comments