Preserve active subagents across reload

This commit is contained in:
jb
2026-08-27 01:05:46 -04:00
parent 4105b03981
commit 9a8169523b
6 changed files with 739 additions and 1518 deletions
+116 -1
View File
@@ -88,16 +88,22 @@ function createMockExtensionApi() {
const registeredTools: Array<any> = [];
const registeredCommands: Array<any> = [];
const registeredMessageRenderers: Array<any> = [];
const eventHandlers = new Map<string, Array<(event: any, ctx: any) => any>>();
const sentUserMessages: string[] = [];
const sentMessages: Array<any> = [];
return {
registeredTools,
registeredCommands,
registeredMessageRenderers,
eventHandlers,
sentUserMessages,
sentMessages,
api: {
on() {},
on(event: string, handler: (event: any, ctx: any) => any) {
const handlers = eventHandlers.get(event) ?? [];
handlers.push(handler);
eventHandlers.set(event, handlers);
},
registerTool(tool: any) {
registeredTools.push(tool);
},
@@ -117,10 +123,24 @@ function createMockExtensionApi() {
getAllTools() {
return [];
},
getActiveTools() {
return [];
},
} as any,
};
}
async function emitMockEvent(
mock: ReturnType<typeof createMockExtensionApi>,
event: string,
payload: Record<string, unknown>,
ctx: Record<string, unknown> = {},
) {
for (const handler of mock.eventHandlers.get(event) ?? []) {
await handler({ type: event, ...payload }, ctx);
}
}
function restoreEnvVar(name: string, value: string | undefined) {
if (value === undefined) {
delete process.env[name];
@@ -1838,6 +1858,101 @@ describe("commands", () => {
});
});
describe("reload lifecycle", () => {
const testApi = (subagentsModule as any).__test__;
const shared = testApi.sharedRuntime as {
runningSubagents: Map<string, any>;
latestCtx: any;
latestPi: any;
widgetInterval: ReturnType<typeof setInterval> | null;
statusInterval: ReturnType<typeof setInterval> | null;
pendingDeliveries: Array<(pi: any) => void>;
deliveryFlushScheduled: boolean;
activeRuntimeToken: symbol | null;
};
const ctx = { hasUI: false, ui: { setWidget() {} } };
function resetSharedRuntime() {
if (shared.widgetInterval) clearInterval(shared.widgetInterval);
if (shared.statusInterval) clearInterval(shared.statusInterval);
shared.widgetInterval = null;
shared.statusInterval = null;
shared.runningSubagents.clear();
shared.latestCtx = null;
shared.latestPi = null;
shared.pendingDeliveries.length = 0;
shared.deliveryFlushScheduled = false;
shared.activeRuntimeToken = null;
}
beforeEach(resetSharedRuntime);
after(resetSharedRuntime);
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
const oldRuntime = createMockExtensionApi();
(subagentsModule as any).default(oldRuntime.api);
await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, ctx);
const abortController = new AbortController();
const running: {
id: string;
name: string;
task: string;
surface: string;
startTime: number;
sessionFile: string;
statusState: ReturnType<typeof createStatusState>;
interactive: boolean;
abortController: AbortController;
suppressDelivery?: boolean;
} = {
id: "reload-child",
name: "reload-child",
task: "keep running",
surface: "surface-1",
startTime: Date.now(),
sessionFile: "/tmp/reload-child.jsonl",
statusState: createStatusState({ source: "pi", startTimeMs: Date.now() }),
interactive: false,
abortController,
};
shared.runningSubagents.set(running.id, running);
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "reload" }, ctx);
assert.equal(abortController.signal.aborted, false, "reload must not cancel the child");
assert.equal(shared.runningSubagents.get(running.id), running);
testApi.sendWhenRuntimeReady((pi: any) => {
pi.sendMessage({ customType: "subagent_result", content: "done", display: true });
});
assert.equal(oldRuntime.sentMessages.length, 0, "the stale runtime must not receive completion");
assert.equal(shared.pendingDeliveries.length, 1);
const freshRuntime = createMockExtensionApi();
(subagentsModule as any).default(freshRuntime.api);
await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, ctx);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(freshRuntime.sentMessages.length, 1);
assert.equal(freshRuntime.sentMessages[0].message.customType, "subagent_result");
assert.equal(oldRuntime.sentMessages.length, 0);
assert.equal(shared.pendingDeliveries.length, 0);
assert.equal(shared.runningSubagents.get(running.id), running);
testApi.flushPendingDeliveries();
assert.equal(freshRuntime.sentMessages.length, 1, "a queued completion must flush exactly once");
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "quit" }, ctx);
assert.equal(abortController.signal.aborted, false, "a stale runtime cannot cancel rebound state");
assert.equal(shared.runningSubagents.get(running.id), running);
await emitMockEvent(freshRuntime, "session_shutdown", { reason: "quit" }, ctx);
assert.equal(abortController.signal.aborted, true, "real shutdown still cancels the child");
assert.equal(running.suppressDelivery, true);
assert.equal(shared.runningSubagents.size, 0);
});
});
describe("tool registration", () => {
it("always resumes subagents as autonomous (auto-exit, non-interactive tracking)", () => {
const testApi = (subagentsModule as any).__test__;