mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-14 18:50:44 +00:00
354 lines
12 KiB
TypeScript
354 lines
12 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();
|
|
|
|
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);
|
|
expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith({
|
|
sessionKeys: expect.arrayContaining([
|
|
"discord:group:dev",
|
|
"agent:main:discord:group:dev",
|
|
"sess-active",
|
|
]),
|
|
onWarn: expect.any(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).toHaveBeenCalledWith({
|
|
allowBackendUnavailable: true,
|
|
cfg: expect.any(Object),
|
|
discardPersistentState: true,
|
|
requireAcpSession: false,
|
|
reason: "session-delete",
|
|
sessionKey: "agent:main:discord:group:dev",
|
|
});
|
|
expect(acpManagerMocks.cancelSession).toHaveBeenCalledWith({
|
|
cfg: expect.any(Object),
|
|
reason: "session-delete",
|
|
sessionKey: "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).toMatchObject({
|
|
sessionId: "sess-delete",
|
|
sessionKey: "agent:main:discord:group:delete",
|
|
reason: "deleted",
|
|
transcriptArchived: true,
|
|
});
|
|
expect((event as { sessionFile?: string } | undefined)?.sessionFile).toContain(".jsonl.deleted.");
|
|
expect((event as { nextSessionId?: string } | undefined)?.nextSessionId).toBeUndefined();
|
|
expect(context).toMatchObject({
|
|
sessionId: "sess-delete",
|
|
sessionKey: "agent:main:discord:group:delete",
|
|
agentId: "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).toMatchObject({
|
|
targetSessionKey: "agent:main:subagent:worker",
|
|
targetKind: "subagent",
|
|
reason: "session-delete",
|
|
outcome: "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).not.toContainEqual(
|
|
expect.stringMatching(/^sess-active\.jsonl\.deleted\./),
|
|
);
|
|
|
|
ws.close();
|
|
});
|