mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-19 14:54:47 +00:00
378 lines
14 KiB
TypeScript
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();
|
|
});
|