Files
openclaw/src/gateway/server.sessions.delete-lifecycle.test.ts
Peter Steinberger 694ca50e97 Revert "refactor: move runtime state to SQLite"
This reverts commit f91de52f0d.
2026-05-13 13:33:38 +01:00

378 lines
14 KiB
TypeScript

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();
});