import { describe, expect, it, vi } from "vitest"; import { withPluginRuntimeGatewayRequestScope } from "../../../plugins/runtime/gateway-request-scope.js"; import { cleanupProvisionalSession, terminateAcceptedCollectorRun, } from "./subagent-spawn-cleanup.js"; function sessionChangedError(): Error { return Object.assign(new Error("session changed"), { name: "GatewayClientRequestError", gatewayCode: "INVALID_REQUEST", details: { reason: "session-changed" }, }); } describe("subagent spawn cleanup identity", () => { it("requires both frozen session identities before deletion", async () => { const callGateway = vi.fn(); await expect( cleanupProvisionalSession("agent:main:subagent:child", { expectedSessionId: "session-id", callGateway, }), ).resolves.toBe(false); expect(callGateway).not.toHaveBeenCalled(); }); it("accepts chat.abort only when it confirms the exact run", async () => { const callGateway = vi .fn() .mockResolvedValueOnce({ ok: true, aborted: false, runIds: [] }) .mockResolvedValueOnce({ deleted: true }); await terminateAcceptedCollectorRun({ childSessionKey: "agent:main:subagent:child", gatewayRunId: "gateway-run", expectedSessionId: "session-id", expectedLifecycleRevision: "session-revision", callGateway, }); expect(callGateway).toHaveBeenNthCalledWith(2, { method: "sessions.delete", params: { key: "agent:main:subagent:child", emitLifecycleHooks: false, deleteTranscript: true, expectedSessionId: "session-id", expectedLifecycleRevision: "session-revision", }, timeoutMs: 60_000, }); }); it("does not delete after chat.abort confirms the matching run", async () => { const callGateway = vi.fn(async () => ({ ok: true, aborted: true, runIds: ["gateway-run"], })); await terminateAcceptedCollectorRun({ childSessionKey: "agent:main:subagent:child", gatewayRunId: "gateway-run", expectedSessionId: "session-id", expectedLifecycleRevision: "session-revision", callGateway, }); expect(callGateway).toHaveBeenCalledOnce(); }); it("stops without deleting a durable session when the accepted run already ended", async () => { const callGateway = vi.fn(async () => ({ ok: true, aborted: false, runIds: [], })); await terminateAcceptedCollectorRun({ childSessionKey: "agent:main:subagent:child", gatewayRunId: "gateway-run", sessionCleanup: "preserve", callGateway, }); expect(callGateway).toHaveBeenCalledOnce(); }); it("retries abort without deleting a durable session after a gateway error", async () => { const callGateway = vi .fn() .mockRejectedValueOnce(new Error("gateway unavailable")) .mockResolvedValueOnce({ ok: true, aborted: false, runIds: [] }); await terminateAcceptedCollectorRun({ childSessionKey: "agent:main:subagent:child", gatewayRunId: "gateway-run", sessionCleanup: "preserve", callGateway, }); expect(callGateway).toHaveBeenCalledTimes(2); expect(callGateway).toHaveBeenNthCalledWith(2, { method: "chat.abort", params: { sessionKey: "agent:main:subagent:child", runId: "gateway-run", }, timeoutMs: 60_000, }); }); it("stops accepted-run cleanup when its Gateway request owner is retired", async () => { const callGateway = vi .fn() .mockRejectedValueOnce(new Error("Gateway request owner is retired")) .mockResolvedValue({ aborted: false, runIds: [] }); await withPluginRuntimeGatewayRequestScope( { resolveGatewayContext: () => undefined, isWebchatConnect: () => false }, () => terminateAcceptedCollectorRun({ childSessionKey: "agent:main:subagent:child", gatewayRunId: "gateway-run", sessionCleanup: "preserve", callGateway, }), ); expect(callGateway).toHaveBeenCalledOnce(); }); it("stops cleanup when guarded deletion observes a successor lifecycle", async () => { const callGateway = vi .fn() .mockResolvedValueOnce({ ok: true, aborted: true, runIds: ["different-run"] }) .mockRejectedValueOnce(sessionChangedError()); await expect( terminateAcceptedCollectorRun({ childSessionKey: "agent:main:subagent:child", gatewayRunId: "gateway-run", expectedSessionId: "session-id", expectedLifecycleRevision: "session-revision", callGateway, }), ).resolves.toBeUndefined(); expect(callGateway).toHaveBeenCalledTimes(2); }); });