Skip to content

Commit 6946fc9

Browse files
committed
Keep send-input followup from undoing wait overlays
1 parent 7c0ea9f commit 6946fc9

4 files changed

Lines changed: 203 additions & 12 deletions

File tree

src/subagent/agent-fleet.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,181 @@ describe("interrupt_agent unblocks wait_agents", () => {
11021102
expect(doneResults[0]!.report).toBe("followup report");
11031103
});
11041104

1105+
test("close_agent overlay survives a send_input followup completing in the close window", async () => {
1106+
const gate = deferred<RunSubAgentResult>();
1107+
const followupGate = deferred<string>();
1108+
const closeHold = deferred<void>();
1109+
const deps = makeDeps(async (params) => {
1110+
params.onAgentReady?.({
1111+
close: async () => closeHold.promise,
1112+
interrupt: () => {},
1113+
followup: async () => followupGate.promise,
1114+
deliver: () => {},
1115+
});
1116+
return gate.promise;
1117+
});
1118+
const spawn = createSpawnAgentTool(deps);
1119+
const wait = createWaitAgentsTool({
1120+
sessions: deps.sessions,
1121+
fleetRecords: deps.fleetRecords,
1122+
});
1123+
const sendInput = createSendInputTool({
1124+
sessions: deps.sessions,
1125+
fleetRecords: deps.fleetRecords,
1126+
});
1127+
const close = createCloseAgentTool({
1128+
sessions: deps.sessions,
1129+
fleetRecords: deps.fleetRecords,
1130+
});
1131+
const spawned = await callTool(spawn, {
1132+
description: "looping",
1133+
prompt: "do it",
1134+
intent: "explore",
1135+
});
1136+
const id = spawned.agent_id as string;
1137+
1138+
const waiting = callTool(wait, { targets: [id], timeout_ms: 5000 });
1139+
await callTool(sendInput, { target: id, message: "stop that", interrupt: true });
1140+
if (close.kind !== "full") throw new Error("expected full tool");
1141+
const closing = close.handler(
1142+
{ id: "close-during-followup", name: "close_agent", arguments: { target: id } },
1143+
new AbortController().signal,
1144+
);
1145+
followupGate.resolve("followup during close");
1146+
gate.resolve({ report: "original interrupted", interrupted: true } as RunSubAgentResult);
1147+
1148+
const waited = await waiting;
1149+
expect(waited.timed_out).toBe(false);
1150+
const results = waited.results as { status: string }[];
1151+
expect(results[0]!.status).toBe("interrupted");
1152+
expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted");
1153+
1154+
closeHold.resolve();
1155+
await closing;
1156+
});
1157+
1158+
test("completeAfterInterrupt does not clear a close overlay", () => {
1159+
const sessions = createSubAgentSessionStore();
1160+
const fleetRecords = createFleetMailbox(sessions);
1161+
const worker = sessions.start({
1162+
description: "looping",
1163+
agentId: "explorer",
1164+
brief: "b",
1165+
retained: true,
1166+
});
1167+
sessions.markRunning(worker.id);
1168+
fleetRecords.register(worker.id);
1169+
fleetRecords.noteFollowup(worker.id);
1170+
fleetRecords.interrupt(worker.id);
1171+
expect(fleetRecords.peek(worker.id)?.status).toBe("interrupted");
1172+
fleetRecords.completeAfterInterrupt(worker.id, "followup reply");
1173+
expect(fleetRecords.peek(worker.id)?.status).toBe("interrupted");
1174+
});
1175+
1176+
test("rejected send_input followup clears the lane so wait collects interrupted salvage", async () => {
1177+
const gate = deferred<RunSubAgentResult>();
1178+
const followupGate = deferred<string>();
1179+
const deps = makeDeps(async (params) => {
1180+
params.onAgentReady?.({
1181+
close: async () => {},
1182+
interrupt: () => {},
1183+
followup: async () => followupGate.promise,
1184+
deliver: () => {},
1185+
});
1186+
return gate.promise;
1187+
});
1188+
const spawn = createSpawnAgentTool(deps);
1189+
const wait = createWaitAgentsTool({
1190+
sessions: deps.sessions,
1191+
fleetRecords: deps.fleetRecords,
1192+
});
1193+
const sendInput = createSendInputTool({
1194+
sessions: deps.sessions,
1195+
fleetRecords: deps.fleetRecords,
1196+
});
1197+
const spawned = await callTool(spawn, {
1198+
description: "looping",
1199+
prompt: "do it",
1200+
intent: "explore",
1201+
});
1202+
const id = spawned.agent_id as string;
1203+
await callTool(sendInput, { target: id, message: "stop that", interrupt: true });
1204+
followupGate.reject(new Error("followup failed"));
1205+
await new Promise((resolve) => setTimeout(resolve, 20));
1206+
gate.resolve({
1207+
report: "## Summary\nStopped.\n## Findings\nsalvage\n## Blockers\ninterrupted\n## Paths\n",
1208+
interrupted: true,
1209+
} as RunSubAgentResult);
1210+
await new Promise((resolve) => setTimeout(resolve, 20));
1211+
1212+
const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 });
1213+
expect(waited.timed_out).toBe(false);
1214+
const results = waited.results as { status: string; report?: string }[];
1215+
expect(results[0]!.status).toBe("interrupted");
1216+
expect(results[0]!.report).toContain("salvage");
1217+
});
1218+
1219+
test("send_input interrupt queued overlay clears when the followup is admitted", async () => {
1220+
const admission = createAdmissionQueue({ capacity: 1 });
1221+
const sessions = createSubAgentSessionStore({ admission });
1222+
const fleetRecords = createFleetMailbox(sessions);
1223+
admission.enqueue({
1224+
id: "holder",
1225+
provider: "p",
1226+
start: () => {},
1227+
});
1228+
const worker = sessions.start({
1229+
description: "looping",
1230+
agentId: "explorer",
1231+
brief: "b",
1232+
retained: true,
1233+
provider: "p",
1234+
});
1235+
sessions.markRunning(worker.id);
1236+
fleetRecords.register(worker.id);
1237+
const followupGate = deferred<string>();
1238+
sessions.registerInterrupt(worker.id, () => {});
1239+
sessions.registerFollowup(worker.id, async () => followupGate.promise);
1240+
1241+
const sendInput = createSendInputTool({ sessions, fleetRecords });
1242+
const wait = createWaitAgentsTool({ sessions, fleetRecords });
1243+
const list = createListAgentsTool({ sessions, fleetRecords });
1244+
1245+
const sent = await callTool(sendInput, {
1246+
target: worker.id,
1247+
message: "stop that",
1248+
interrupt: true,
1249+
});
1250+
expect(sent.status).toBe("interrupted");
1251+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("pending_init");
1252+
1253+
const queuedWait = await callTool(wait, { targets: [worker.id], timeout_ms: 50 });
1254+
expect(queuedWait.timed_out).toBe(true);
1255+
expect((queuedWait.results as { status: string }[])[0]!.status).toBe("queued");
1256+
const queuedList = await callTool(list, {});
1257+
const queuedEntry = (
1258+
queuedList.agents as { agent_id: string; status: string; lifecycle: string }[]
1259+
).find((a) => a.agent_id === worker.id);
1260+
expect(queuedEntry?.status).toBe("queued");
1261+
expect(queuedEntry?.lifecycle).toBe("pending_init");
1262+
1263+
admission.release("holder");
1264+
await new Promise((resolve) => setTimeout(resolve, 20));
1265+
1266+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running");
1267+
const runningWait = await callTool(wait, { targets: [worker.id], timeout_ms: 50 });
1268+
expect(runningWait.timed_out).toBe(true);
1269+
expect((runningWait.results as { status: string }[])[0]!.status).toBe("running");
1270+
const runningList = await callTool(list, {});
1271+
const runningEntry = (
1272+
runningList.agents as { agent_id: string; status: string; lifecycle: string }[]
1273+
).find((a) => a.agent_id === worker.id);
1274+
expect(runningEntry?.status).toBe("running");
1275+
expect(runningEntry?.lifecycle).toBe("running");
1276+
1277+
followupGate.resolve("later");
1278+
});
1279+
11051280
test("soft-interrupt wait path collects so omitted re-wait does not re-deliver", async () => {
11061281
const gate = deferred<RunSubAgentResult>();
11071282
const deps = makeDeps(async (params) => {

src/subagent/agent-fleet.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,9 @@ class FleetMailbox {
246246

247247
/**
248248
* send_input interrupt:true followup finished. Clear the followup lane flag
249-
* (and any uncollected interrupted overlay) so wait projects session
250-
* completed → done. No-op if wait_agents already collected the interrupt.
249+
* (and any admission queued overlay) so wait can project the settled session.
250+
* Leaves a close/interrupt overlay in place — a followup reply must not undo
251+
* interrupt_agent or close_agent. No-op if wait_agents already collected.
251252
*/
252253
completeAfterInterrupt(id: string, _report?: string): void {
253254
const existing = this.records.get(id);
@@ -257,8 +258,8 @@ class FleetMailbox {
257258
delete existing.followupLive;
258259
changed = true;
259260
}
260-
if (existing.forceInterrupted === true) {
261-
delete existing.forceInterrupted;
261+
if (existing.forceQueued === true) {
262+
delete existing.forceQueued;
262263
changed = true;
263264
}
264265
if (changed) this.sessions?.wake();

src/subagent/lifecycle-tools.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ export function createInterruptAgentTool(deps: InterruptAgentToolDeps): AgentToo
308308
}
309309
// Soft interrupt leaves the run in flight; projectWaitStatus treats
310310
// interrupted+inFlight as running so resume cannot collect a stale stamp.
311-
// Flip the wait mailbox overlay here (same as send_input interrupt:true).
311+
// Flip the wait mailbox overlay so in-flight wait_agents unblocks as interrupted.
312312
deps.fleetRecords.interrupt(target);
313313
return lifecycleResult(
314314
call.id,
@@ -382,9 +382,15 @@ export function createSendInputTool(deps: LifecycleToolDeps): AgentTool {
382382
...(interrupt ? { interrupt: true } : {}),
383383
...(interrupt && deps.fleetRecords !== undefined
384384
? {
385+
onStart: () => {
386+
deps.fleetRecords?.clearQueued(target);
387+
},
385388
onFollowupReply: (reply: string) => {
386389
deps.fleetRecords?.completeAfterInterrupt(target, reply);
387390
},
391+
onFail: () => {
392+
deps.fleetRecords?.completeAfterInterrupt(target);
393+
},
388394
}
389395
: {}),
390396
});

src/subagent/session-store.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,12 @@ export interface SubAgentSessionStore {
245245
sendInputOne(
246246
id: string,
247247
message: string,
248-
opts?: { interrupt?: boolean; onFollowupReply?: (reply: string) => void },
248+
opts?: {
249+
interrupt?: boolean;
250+
onFollowupReply?: (reply: string) => void;
251+
onStart?: () => void;
252+
onFail?: (error: unknown) => void;
253+
},
249254
): { ok: true; status: AgentLifecycleStatus } | { ok: false; status: AgentLifecycleStatus };
250255
/**
251256
* One pending ask_director per session. `sendInputOne` (soft) resolves it;
@@ -821,11 +826,8 @@ export function createSubAgentSessionStore(
821826
})
822827
.catch((err: unknown) => {
823828
runInFlight.delete(id);
824-
if (opts?.onFail !== undefined) {
825-
opts.onFail(err);
826-
} else {
827-
endFollowupTurn(id, failLifecycle);
828-
}
829+
opts?.onFail?.(err);
830+
endFollowupTurn(id, failLifecycle);
829831
log.error("followup turn failed for {id}: {error}", {
830832
id,
831833
error: err instanceof Error ? err.message : String(err),
@@ -1279,7 +1281,12 @@ export function createSubAgentSessionStore(
12791281
sendInputOne(
12801282
id: string,
12811283
message: string,
1282-
opts?: { interrupt?: boolean; onFollowupReply?: (reply: string) => void },
1284+
opts?: {
1285+
interrupt?: boolean;
1286+
onFollowupReply?: (reply: string) => void;
1287+
onStart?: () => void;
1288+
onFail?: (error: unknown) => void;
1289+
},
12831290
): { ok: true; status: AgentLifecycleStatus } | { ok: false; status: AgentLifecycleStatus } {
12841291
const session = sessions.get(id);
12851292
if (session === undefined) return { ok: false, status: "not_found" };
@@ -1296,7 +1303,9 @@ export function createSubAgentSessionStore(
12961303
settleCancelsAsks(id, "cancelled by send_input interrupt");
12971304
interrupt();
12981305
queueFollowupTurn(id, message, "interrupted", {
1306+
...(opts.onStart !== undefined ? { onStart: opts.onStart } : {}),
12991307
...(opts.onFollowupReply !== undefined ? { onReply: opts.onFollowupReply } : {}),
1308+
...(opts.onFail !== undefined ? { onFail: opts.onFail } : {}),
13001309
});
13011310
pruneRetained();
13021311
return { ok: true, status: "interrupted" };

0 commit comments

Comments
 (0)