import fs from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; import { embeddedRunMock, rpcReq, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, sessionLifecycleHookMocks, subagentLifecycleHookMocks, subagentLifecycleHookState, threadBindingMocks, acpManagerMocks, browserSessionTabMocks, bundleMcpRuntimeMocks, writeSingleLineSession, sessionStoreEntry, expectActiveRunCleanup, directSessionReq, } from "./test/server-sessions.test-helpers.js"; const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness(); function expectObject(value: unknown) { if (!value || typeof value !== "object") { throw new Error("expected object"); } } test("sessions.delete rejects main and aborts active runs", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-main", "hello"); await writeSingleLineSession(dir, "sess-active", "active"); await writeSessionStore({ entries: { main: sessionStoreEntry("sess-main"), "discord:group:dev": sessionStoreEntry("sess-active"), }, }); embeddedRunMock.activeIds.add("sess-active"); embeddedRunMock.waitResults.set("sess-active", true); const mainDelete = await directSessionReq("sessions.delete", { key: "main" }); expect(mainDelete.ok).toBe(false); const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { key: "discord:group:dev", }); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(true); expectActiveRunCleanup( "agent:main:discord:group:dev", ["discord:group:dev", "agent:main:discord:group:dev", "sess-active"], "sess-active", ); expect(bundleMcpRuntimeMocks.disposeSessionMcpRuntime).toHaveBeenCalledWith("sess-active"); expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledTimes(1); const closeTabsCall = ( browserSessionTabMocks.closeTrackedBrowserTabsForSessions.mock.calls as unknown as Array< [{ sessionKeys?: string[]; onWarn?: unknown }] > )[0]?.[0]; expect(closeTabsCall?.sessionKeys).toHaveLength(3); expect(closeTabsCall?.sessionKeys).toContain("discord:group:dev"); expect(closeTabsCall?.sessionKeys).toContain("agent:main:discord:group:dev"); expect(closeTabsCall?.sessionKeys).toContain("sess-active"); expect(typeof closeTabsCall?.onWarn).toBe("function"); expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledTimes(1); expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledWith( { targetSessionKey: "agent:main:discord:group:dev", targetKind: "acp", reason: "session-delete", sendFarewell: true, outcome: "deleted", }, { childSessionKey: "agent:main:discord:group:dev", }, ); expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1); expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({ targetSessionKey: "agent:main:discord:group:dev", reason: "session-delete", }); }); test("sessions.delete limits plugin-runtime cleanup to sessions owned by that plugin", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-owned", "owned"); await writeSingleLineSession(dir, "sess-foreign", "foreign"); await writeSessionStore({ entries: { "agent:main:dreaming-narrative-owned": sessionStoreEntry("sess-owned", { pluginOwnerId: "memory-core", }), "agent:main:dreaming-narrative-foreign": sessionStoreEntry("sess-foreign", { pluginOwnerId: "other-plugin", }), }, }); const pluginClient = { connect: { scopes: ["operator.admin"], }, internal: { pluginRuntimeOwnerId: "memory-core", }, } as never; const denied = await directSessionReq( "sessions.delete", { key: "agent:main:dreaming-narrative-foreign", }, { client: pluginClient, }, ); expect(denied.ok).toBe(false); expect(denied.error?.message).toContain("did not create it"); const deleted = await directSessionReq<{ ok: true; deleted: boolean }>( "sessions.delete", { key: "agent:main:dreaming-narrative-owned", }, { client: pluginClient, }, ); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(true); }); test("sessions.delete closes ACP runtime handles before removing ACP sessions", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-main", "hello"); await writeSingleLineSession(dir, "sess-acp", "acp"); await writeSessionStore({ entries: { main: sessionStoreEntry("sess-main"), "discord:group:dev": sessionStoreEntry("sess-acp", { acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:delete", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }), }, }); const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { key: "discord:group:dev", }); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(true); expect(acpManagerMocks.closeSession).toHaveBeenCalledTimes(1); const closeSessionCall = ( acpManagerMocks.closeSession.mock.calls as unknown as Array< [ { allowBackendUnavailable?: boolean; cfg?: unknown; discardPersistentState?: boolean; requireAcpSession?: boolean; reason?: string; sessionKey?: string; }, ] > )[0]?.[0]; expect(closeSessionCall?.allowBackendUnavailable).toBe(true); expectObject(closeSessionCall?.cfg); expect(closeSessionCall?.discardPersistentState).toBe(true); expect(closeSessionCall?.requireAcpSession).toBe(false); expect(closeSessionCall?.reason).toBe("session-delete"); expect(closeSessionCall?.sessionKey).toBe("agent:main:discord:group:dev"); expect(acpManagerMocks.cancelSession).toHaveBeenCalledTimes(1); const cancelSessionCall = ( acpManagerMocks.cancelSession.mock.calls as unknown as Array< [{ cfg?: unknown; reason?: string; sessionKey?: string }] > )[0]?.[0]; expectObject(cancelSessionCall?.cfg); expect(cancelSessionCall?.reason).toBe("session-delete"); expect(cancelSessionCall?.sessionKey).toBe("agent:main:discord:group:dev"); }); test("sessions.delete emits session_end with deleted reason and no replacement", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-main", "hello"); const transcriptPath = path.join(dir, "sess-delete.jsonl"); await fs.writeFile( transcriptPath, `${JSON.stringify({ type: "message", id: "m-delete", message: { role: "user", content: "delete me" }, })}\n`, "utf-8", ); await writeSessionStore({ entries: { main: sessionStoreEntry("sess-main"), "discord:group:delete": sessionStoreEntry("sess-delete", { sessionFile: transcriptPath, }), }, }); const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { key: "discord:group:delete", }); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(true); expect(sessionLifecycleHookMocks.runSessionEnd).toHaveBeenCalledTimes(1); expect(sessionLifecycleHookMocks.runSessionStart).not.toHaveBeenCalled(); const [event, context] = ( sessionLifecycleHookMocks.runSessionEnd.mock.calls as unknown as Array<[unknown, unknown]> )[0] ?? [undefined, undefined]; expect((event as { sessionId?: string } | undefined)?.sessionId).toBe("sess-delete"); expect((event as { sessionKey?: string } | undefined)?.sessionKey).toBe( "agent:main:discord:group:delete", ); expect((event as { reason?: string } | undefined)?.reason).toBe("deleted"); expect((event as { transcriptArchived?: boolean } | undefined)?.transcriptArchived).toBe(true); expect((event as { sessionFile?: string } | undefined)?.sessionFile).toContain(".jsonl.deleted."); expect((event as { nextSessionId?: string } | undefined)?.nextSessionId).toBeUndefined(); expect((context as { sessionId?: string } | undefined)?.sessionId).toBe("sess-delete"); expect((context as { sessionKey?: string } | undefined)?.sessionKey).toBe( "agent:main:discord:group:delete", ); expect((context as { agentId?: string } | undefined)?.agentId).toBe("main"); }); test("sessions.delete does not emit lifecycle events when nothing was deleted", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-main", "hello"); await writeSessionStore({ entries: { main: sessionStoreEntry("sess-main"), }, }); const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { key: "agent:main:subagent:missing", }); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(false); expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled(); expect(threadBindingMocks.unbindThreadBindingsBySessionKey).not.toHaveBeenCalled(); }); test("sessions.delete emits subagent targetKind for subagent sessions", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-subagent", "hello"); await writeSessionStore({ entries: { "agent:main:subagent:worker": sessionStoreEntry("sess-subagent"), }, }); const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { key: "agent:main:subagent:worker", }); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(true); expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledTimes(1); const event = (subagentLifecycleHookMocks.runSubagentEnded.mock.calls as unknown[][])[0]?.[0] as | { targetKind?: string; targetSessionKey?: string; reason?: string; outcome?: string } | undefined; expect(event?.targetSessionKey).toBe("agent:main:subagent:worker"); expect(event?.targetKind).toBe("subagent"); expect(event?.reason).toBe("session-delete"); expect(event?.outcome).toBe("deleted"); expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1); expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({ targetSessionKey: "agent:main:subagent:worker", reason: "session-delete", }); }); test("sessions.delete can skip lifecycle hooks while still unbinding thread bindings", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-subagent", "hello"); await writeSessionStore({ entries: { "agent:main:subagent:worker": sessionStoreEntry("sess-subagent"), }, }); const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { key: "agent:main:subagent:worker", emitLifecycleHooks: false, }); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(true); expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled(); expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1); expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({ targetSessionKey: "agent:main:subagent:worker", reason: "session-delete", }); }); test("sessions.delete directly unbinds thread bindings when hooks are unavailable", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-subagent", "hello"); await writeSessionStore({ entries: { "agent:main:subagent:worker": sessionStoreEntry("sess-subagent"), }, }); subagentLifecycleHookState.hasSubagentEndedHook = false; const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { key: "agent:main:subagent:worker", }); expect(deleted.ok).toBe(true); expect(subagentLifecycleHookMocks.runSubagentEnded).not.toHaveBeenCalled(); expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1); expect(threadBindingMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({ targetSessionKey: "agent:main:subagent:worker", reason: "session-delete", }); }); test("sessions.delete returns unavailable when active run does not stop", async () => { const { dir, storePath } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-active", "active"); await writeSessionStore({ entries: { "discord:group:dev": sessionStoreEntry("sess-active"), }, }); embeddedRunMock.activeIds.add("sess-active"); embeddedRunMock.waitResults.set("sess-active", false); const { ws } = await openClient(); const deleted = await rpcReq(ws, "sessions.delete", { key: "discord:group:dev", }); expect(deleted.ok).toBe(false); expect(deleted.error?.code).toBe("UNAVAILABLE"); expect(deleted.error?.message ?? "").toMatch(/still active/i); expectActiveRunCleanup( "agent:main:discord:group:dev", ["discord:group:dev", "agent:main:discord:group:dev", "sess-active"], "sess-active", ); expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).not.toHaveBeenCalled(); const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< string, { sessionId?: string } >; expect(store["agent:main:discord:group:dev"]?.sessionId).toBe("sess-active"); const filesAfterDeleteAttempt = await fs.readdir(dir); expect( filesAfterDeleteAttempt.filter((fileName) => fileName.startsWith("sess-active.jsonl.deleted.")), ).toEqual([]); ws.close(); });