mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 08:20:43 +00:00
1378 lines
44 KiB
TypeScript
1378 lines
44 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
import {
|
|
addSubagentRunForTests,
|
|
resetSubagentRegistryForTests,
|
|
} from "../agents/subagent-registry.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { loadSessionStore, type SessionEntry } from "../config/sessions.js";
|
|
import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js";
|
|
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
|
|
import { withEnv } from "../test-utils/env.js";
|
|
import {
|
|
listSessionsFromStore,
|
|
loadCombinedSessionStoreForGateway,
|
|
resolveGatewayModelSupportsImages,
|
|
} from "./session-utils.js";
|
|
|
|
describe("listSessionsFromStore subagent metadata", () => {
|
|
afterEach(() => {
|
|
resetSubagentRegistryForTests({ persist: false });
|
|
resetAgentRunContextForTest();
|
|
});
|
|
beforeEach(() => {
|
|
resetSubagentRegistryForTests({ persist: false });
|
|
resetAgentRunContextForTest();
|
|
});
|
|
|
|
const cfg = {
|
|
session: { mainKey: "main" },
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
} as OpenClawConfig;
|
|
|
|
test("searches channel-derived display names before row enrichment", () => {
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store: {
|
|
"agent:main:slack:group:general": {
|
|
sessionId: "slack-general-session",
|
|
updatedAt: 2,
|
|
channel: "slack",
|
|
} as SessionEntry,
|
|
"agent:main:discord:group:random": {
|
|
sessionId: "discord-random-session",
|
|
updatedAt: 1,
|
|
channel: "discord",
|
|
} as SessionEntry,
|
|
},
|
|
opts: { search: "slack:g-general" },
|
|
});
|
|
|
|
expect(result.sessions.map((session) => session.key)).toEqual([
|
|
"agent:main:slack:group:general",
|
|
]);
|
|
expect(result.sessions[0]?.displayName).toBe("slack:g-general");
|
|
});
|
|
|
|
test("applies limit before transcript enrichment", () => {
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:newest": {
|
|
sessionId: "newest-session",
|
|
sessionFile: "/tmp/newest-session.jsonl",
|
|
updatedAt: 300,
|
|
} as SessionEntry,
|
|
"agent:main:middle": {
|
|
sessionId: "middle-session",
|
|
sessionFile: "/tmp/middle-session.jsonl",
|
|
updatedAt: 200,
|
|
} as SessionEntry,
|
|
"agent:main:oldest": {
|
|
sessionId: "old-session",
|
|
sessionFile: "/tmp/old-session.jsonl",
|
|
updatedAt: 100,
|
|
} as SessionEntry,
|
|
};
|
|
const existsSpy = vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
|
try {
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: { limit: 2 },
|
|
});
|
|
|
|
expect(result.sessions.map((session) => session.sessionId)).toEqual([
|
|
"newest-session",
|
|
"middle-session",
|
|
]);
|
|
expect(existsSpy.mock.calls.flat().join("\n")).not.toContain("old-session");
|
|
} finally {
|
|
existsSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
test("includes subagent status timing and direct child session keys", () => {
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: now,
|
|
} as SessionEntry,
|
|
"agent:main:subagent:parent": {
|
|
sessionId: "sess-parent",
|
|
updatedAt: now - 2_000,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
"agent:main:subagent:child": {
|
|
sessionId: "sess-child",
|
|
updatedAt: now - 1_000,
|
|
spawnedBy: "agent:main:subagent:parent",
|
|
spawnedWorkspaceDir: "/tmp/child-workspace",
|
|
forkedFromParent: true,
|
|
spawnDepth: 2,
|
|
subagentRole: "orchestrator",
|
|
subagentControlScope: "children",
|
|
} as SessionEntry,
|
|
"agent:main:subagent:failed": {
|
|
sessionId: "sess-failed",
|
|
updatedAt: now - 500,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
addSubagentRunForTests({
|
|
runId: "run-parent",
|
|
childSessionKey: "agent:main:subagent:parent",
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "parent task",
|
|
cleanup: "keep",
|
|
createdAt: now - 10_000,
|
|
startedAt: now - 9_000,
|
|
model: "openai/gpt-5.4",
|
|
});
|
|
registerAgentRunContext("run-parent", {
|
|
sessionKey: "agent:main:subagent:parent",
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-child",
|
|
childSessionKey: "agent:main:subagent:child",
|
|
controllerSessionKey: "agent:main:subagent:parent",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "child task",
|
|
cleanup: "keep",
|
|
createdAt: now - 8_000,
|
|
startedAt: now - 7_500,
|
|
endedAt: now - 2_500,
|
|
outcome: { status: "ok" },
|
|
model: "openai/gpt-5.4",
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-failed",
|
|
childSessionKey: "agent:main:subagent:failed",
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "failed task",
|
|
cleanup: "keep",
|
|
createdAt: now - 6_000,
|
|
startedAt: now - 5_500,
|
|
endedAt: now - 500,
|
|
outcome: { status: "error", error: "boom" },
|
|
model: "openai/gpt-5.4",
|
|
});
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
const main = result.sessions.find((session) => session.key === "agent:main:main");
|
|
expect(main?.childSessions).toEqual([
|
|
"agent:main:subagent:parent",
|
|
"agent:main:subagent:failed",
|
|
]);
|
|
expect(main?.status).toBeUndefined();
|
|
|
|
const parent = result.sessions.find((session) => session.key === "agent:main:subagent:parent");
|
|
expect(parent?.status).toBe("running");
|
|
expect(parent?.startedAt).toBe(now - 9_000);
|
|
expect(parent?.endedAt).toBeUndefined();
|
|
expect(parent?.runtimeMs).toBeGreaterThanOrEqual(9_000);
|
|
expect(parent?.childSessions).toEqual(["agent:main:subagent:child"]);
|
|
|
|
const child = result.sessions.find((session) => session.key === "agent:main:subagent:child");
|
|
expect(child?.status).toBe("done");
|
|
expect(child?.startedAt).toBe(now - 7_500);
|
|
expect(child?.endedAt).toBe(now - 2_500);
|
|
expect(child?.runtimeMs).toBe(5_000);
|
|
expect(child?.spawnedWorkspaceDir).toBe("/tmp/child-workspace");
|
|
expect(child?.forkedFromParent).toBe(true);
|
|
expect(child?.spawnDepth).toBe(2);
|
|
expect(child?.subagentRole).toBe("orchestrator");
|
|
expect(child?.subagentControlScope).toBe("children");
|
|
expect(child?.childSessions).toBeUndefined();
|
|
|
|
const failed = result.sessions.find((session) => session.key === "agent:main:subagent:failed");
|
|
expect(failed?.status).toBe("failed");
|
|
expect(failed?.runtimeMs).toBe(5_000);
|
|
});
|
|
|
|
test("does not show stale registry-only subagent runs as actively running", () => {
|
|
const now = Date.now();
|
|
const childSessionKey = "agent:main:subagent:stale-display";
|
|
const store: Record<string, SessionEntry> = {
|
|
[childSessionKey]: {
|
|
sessionId: "sess-stale-display",
|
|
updatedAt: now - 250,
|
|
spawnedBy: "agent:main:main",
|
|
status: "done",
|
|
startedAt: now - 4_000,
|
|
endedAt: now - 500,
|
|
runtimeMs: 3_500,
|
|
} as SessionEntry,
|
|
};
|
|
|
|
addSubagentRunForTests({
|
|
runId: "run-stale-display",
|
|
childSessionKey,
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "stale display task",
|
|
cleanup: "keep",
|
|
createdAt: now - 5_000,
|
|
startedAt: now - 4_000,
|
|
model: "openai/gpt-5.4",
|
|
});
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
const row = result.sessions.find((session) => session.key === childSessionKey);
|
|
expect(row?.status).toBe("done");
|
|
expect(row?.subagentRunState).toBe("historical");
|
|
expect(row?.hasActiveSubagentRun).toBe(false);
|
|
expect(row?.endedAt).toBe(now - 500);
|
|
expect(row?.runtimeMs).toBe(3_500);
|
|
});
|
|
|
|
test("does not keep childSessions attached to a stale older controller row", () => {
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: now,
|
|
} as SessionEntry,
|
|
"agent:main:subagent:old-parent": {
|
|
sessionId: "sess-old-parent",
|
|
updatedAt: now - 4_000,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
"agent:main:subagent:new-parent": {
|
|
sessionId: "sess-new-parent",
|
|
updatedAt: now - 3_000,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
"agent:main:subagent:shared-child": {
|
|
sessionId: "sess-shared-child",
|
|
updatedAt: now - 1_000,
|
|
spawnedBy: "agent:main:subagent:new-parent",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
addSubagentRunForTests({
|
|
runId: "run-old-parent",
|
|
childSessionKey: "agent:main:subagent:old-parent",
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "old parent task",
|
|
cleanup: "keep",
|
|
createdAt: now - 10_000,
|
|
startedAt: now - 9_000,
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-new-parent",
|
|
childSessionKey: "agent:main:subagent:new-parent",
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "new parent task",
|
|
cleanup: "keep",
|
|
createdAt: now - 8_000,
|
|
startedAt: now - 7_000,
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-child-stale-parent",
|
|
childSessionKey: "agent:main:subagent:shared-child",
|
|
controllerSessionKey: "agent:main:subagent:old-parent",
|
|
requesterSessionKey: "agent:main:subagent:old-parent",
|
|
requesterDisplayKey: "old-parent",
|
|
task: "shared child stale parent",
|
|
cleanup: "keep",
|
|
createdAt: now - 6_000,
|
|
startedAt: now - 5_500,
|
|
endedAt: now - 4_500,
|
|
outcome: { status: "ok" },
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-child-current-parent",
|
|
childSessionKey: "agent:main:subagent:shared-child",
|
|
controllerSessionKey: "agent:main:subagent:new-parent",
|
|
requesterSessionKey: "agent:main:subagent:new-parent",
|
|
requesterDisplayKey: "new-parent",
|
|
task: "shared child current parent",
|
|
cleanup: "keep",
|
|
createdAt: now - 2_000,
|
|
startedAt: now - 1_500,
|
|
});
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
const oldParent = result.sessions.find(
|
|
(session) => session.key === "agent:main:subagent:old-parent",
|
|
);
|
|
const newParent = result.sessions.find(
|
|
(session) => session.key === "agent:main:subagent:new-parent",
|
|
);
|
|
|
|
expect(oldParent?.childSessions).toBeUndefined();
|
|
expect(newParent?.childSessions).toEqual(["agent:main:subagent:shared-child"]);
|
|
});
|
|
|
|
test("does not reattach moved children through stale spawnedBy store metadata", () => {
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: now,
|
|
} as SessionEntry,
|
|
"agent:main:subagent:old-parent-store": {
|
|
sessionId: "sess-old-parent-store",
|
|
updatedAt: now - 4_000,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
"agent:main:subagent:new-parent-store": {
|
|
sessionId: "sess-new-parent-store",
|
|
updatedAt: now - 3_000,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
"agent:main:subagent:shared-child-store": {
|
|
sessionId: "sess-shared-child-store",
|
|
updatedAt: now - 1_000,
|
|
spawnedBy: "agent:main:subagent:old-parent-store",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
addSubagentRunForTests({
|
|
runId: "run-old-parent-store",
|
|
childSessionKey: "agent:main:subagent:old-parent-store",
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "old parent store task",
|
|
cleanup: "keep",
|
|
createdAt: now - 10_000,
|
|
startedAt: now - 9_000,
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-new-parent-store",
|
|
childSessionKey: "agent:main:subagent:new-parent-store",
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "new parent store task",
|
|
cleanup: "keep",
|
|
createdAt: now - 8_000,
|
|
startedAt: now - 7_000,
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-child-store-stale-parent",
|
|
childSessionKey: "agent:main:subagent:shared-child-store",
|
|
controllerSessionKey: "agent:main:subagent:old-parent-store",
|
|
requesterSessionKey: "agent:main:subagent:old-parent-store",
|
|
requesterDisplayKey: "old-parent-store",
|
|
task: "shared child stale store parent",
|
|
cleanup: "keep",
|
|
createdAt: now - 6_000,
|
|
startedAt: now - 5_500,
|
|
endedAt: now - 4_500,
|
|
outcome: { status: "ok" },
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-child-store-current-parent",
|
|
childSessionKey: "agent:main:subagent:shared-child-store",
|
|
controllerSessionKey: "agent:main:subagent:new-parent-store",
|
|
requesterSessionKey: "agent:main:subagent:new-parent-store",
|
|
requesterDisplayKey: "new-parent-store",
|
|
task: "shared child current store parent",
|
|
cleanup: "keep",
|
|
createdAt: now - 2_000,
|
|
startedAt: now - 1_500,
|
|
});
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
const oldParent = result.sessions.find(
|
|
(session) => session.key === "agent:main:subagent:old-parent-store",
|
|
);
|
|
const newParent = result.sessions.find(
|
|
(session) => session.key === "agent:main:subagent:new-parent-store",
|
|
);
|
|
|
|
expect(oldParent?.childSessions).toBeUndefined();
|
|
expect(newParent?.childSessions).toEqual(["agent:main:subagent:shared-child-store"]);
|
|
});
|
|
|
|
test("does not return moved child sessions from stale spawnedBy filters", () => {
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: now,
|
|
} as SessionEntry,
|
|
"agent:main:subagent:old-parent-filter": {
|
|
sessionId: "sess-old-parent-filter",
|
|
updatedAt: now - 4_000,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
"agent:main:subagent:new-parent-filter": {
|
|
sessionId: "sess-new-parent-filter",
|
|
updatedAt: now - 3_000,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
"agent:main:subagent:shared-child-filter": {
|
|
sessionId: "sess-shared-child-filter",
|
|
updatedAt: now - 1_000,
|
|
spawnedBy: "agent:main:subagent:old-parent-filter",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
addSubagentRunForTests({
|
|
runId: "run-old-parent-filter",
|
|
childSessionKey: "agent:main:subagent:old-parent-filter",
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "old parent filter task",
|
|
cleanup: "keep",
|
|
createdAt: now - 10_000,
|
|
startedAt: now - 9_000,
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-new-parent-filter",
|
|
childSessionKey: "agent:main:subagent:new-parent-filter",
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "new parent filter task",
|
|
cleanup: "keep",
|
|
createdAt: now - 8_000,
|
|
startedAt: now - 7_000,
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-child-filter-stale-parent",
|
|
childSessionKey: "agent:main:subagent:shared-child-filter",
|
|
controllerSessionKey: "agent:main:subagent:old-parent-filter",
|
|
requesterSessionKey: "agent:main:subagent:old-parent-filter",
|
|
requesterDisplayKey: "old-parent-filter",
|
|
task: "shared child stale filter parent",
|
|
cleanup: "keep",
|
|
createdAt: now - 6_000,
|
|
startedAt: now - 5_500,
|
|
endedAt: now - 4_500,
|
|
outcome: { status: "ok" },
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-child-filter-current-parent",
|
|
childSessionKey: "agent:main:subagent:shared-child-filter",
|
|
controllerSessionKey: "agent:main:subagent:new-parent-filter",
|
|
requesterSessionKey: "agent:main:subagent:new-parent-filter",
|
|
requesterDisplayKey: "new-parent-filter",
|
|
task: "shared child current filter parent",
|
|
cleanup: "keep",
|
|
createdAt: now - 2_000,
|
|
startedAt: now - 1_500,
|
|
});
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {
|
|
spawnedBy: "agent:main:subagent:old-parent-filter",
|
|
},
|
|
});
|
|
|
|
expect(result.sessions.map((session) => session.key)).toStrictEqual([]);
|
|
});
|
|
|
|
test("reports the newest run owner for moved child session rows", () => {
|
|
const now = Date.now();
|
|
const childSessionKey = "agent:main:subagent:shared-child-owner";
|
|
const store: Record<string, SessionEntry> = {
|
|
[childSessionKey]: {
|
|
sessionId: "sess-shared-child-owner",
|
|
updatedAt: now,
|
|
spawnedBy: "agent:main:subagent:old-parent-owner",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
addSubagentRunForTests({
|
|
runId: "run-child-owner-stale-parent",
|
|
childSessionKey,
|
|
controllerSessionKey: "agent:main:subagent:old-parent-owner",
|
|
requesterSessionKey: "agent:main:subagent:old-parent-owner",
|
|
requesterDisplayKey: "old-parent-owner",
|
|
task: "shared child stale owner parent",
|
|
cleanup: "keep",
|
|
createdAt: now - 6_000,
|
|
startedAt: now - 5_500,
|
|
endedAt: now - 4_500,
|
|
outcome: { status: "ok" },
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-child-owner-current-parent",
|
|
childSessionKey,
|
|
controllerSessionKey: "agent:main:subagent:new-parent-owner",
|
|
requesterSessionKey: "agent:main:subagent:new-parent-owner",
|
|
requesterDisplayKey: "new-parent-owner",
|
|
task: "shared child current owner parent",
|
|
cleanup: "keep",
|
|
createdAt: now - 2_000,
|
|
startedAt: now - 1_500,
|
|
});
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
expect(result.sessions).toHaveLength(1);
|
|
expect(result.sessions[0]).toMatchObject({
|
|
key: childSessionKey,
|
|
spawnedBy: "agent:main:subagent:new-parent-owner",
|
|
});
|
|
});
|
|
|
|
test("reports the newest parentSessionKey for moved child session rows", () => {
|
|
const now = Date.now();
|
|
const childSessionKey = "agent:main:subagent:shared-child-parent";
|
|
const store: Record<string, SessionEntry> = {
|
|
[childSessionKey]: {
|
|
sessionId: "sess-shared-child-parent",
|
|
updatedAt: now,
|
|
parentSessionKey: "agent:main:subagent:old-parent-parent",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
addSubagentRunForTests({
|
|
runId: "run-child-parent-stale-parent",
|
|
childSessionKey,
|
|
controllerSessionKey: "agent:main:subagent:old-parent-parent",
|
|
requesterSessionKey: "agent:main:subagent:old-parent-parent",
|
|
requesterDisplayKey: "old-parent-parent",
|
|
task: "shared child stale parentSessionKey parent",
|
|
cleanup: "keep",
|
|
createdAt: now - 6_000,
|
|
startedAt: now - 5_500,
|
|
endedAt: now - 4_500,
|
|
outcome: { status: "ok" },
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-child-parent-current-parent",
|
|
childSessionKey,
|
|
controllerSessionKey: "agent:main:subagent:new-parent-parent",
|
|
requesterSessionKey: "agent:main:subagent:new-parent-parent",
|
|
requesterDisplayKey: "new-parent-parent",
|
|
task: "shared child current parentSessionKey parent",
|
|
cleanup: "keep",
|
|
createdAt: now - 2_000,
|
|
startedAt: now - 1_500,
|
|
});
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
expect(result.sessions).toHaveLength(1);
|
|
expect(result.sessions[0]).toMatchObject({
|
|
key: childSessionKey,
|
|
parentSessionKey: "agent:main:subagent:new-parent-parent",
|
|
});
|
|
});
|
|
|
|
test("preserves original session timing across follow-up replacement runs", () => {
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:subagent:followup": {
|
|
sessionId: "sess-followup",
|
|
updatedAt: now,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
addSubagentRunForTests({
|
|
runId: "run-followup-new",
|
|
childSessionKey: "agent:main:subagent:followup",
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "follow-up task",
|
|
cleanup: "keep",
|
|
createdAt: now - 10_000,
|
|
startedAt: now - 30_000,
|
|
sessionStartedAt: now - 150_000,
|
|
accumulatedRuntimeMs: 120_000,
|
|
model: "openai/gpt-5.4",
|
|
});
|
|
registerAgentRunContext("run-followup-new", {
|
|
sessionKey: "agent:main:subagent:followup",
|
|
});
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
const followup = result.sessions.find(
|
|
(session) => session.key === "agent:main:subagent:followup",
|
|
);
|
|
expect(followup?.status).toBe("running");
|
|
expect(followup?.startedAt).toBe(now - 150_000);
|
|
expect(followup?.runtimeMs).toBeGreaterThanOrEqual(150_000);
|
|
});
|
|
|
|
test("uses the newest child-session row for stale/current replacement pairs", () => {
|
|
const now = Date.now();
|
|
const childSessionKey = "agent:main:subagent:stale-current";
|
|
const store: Record<string, SessionEntry> = {
|
|
[childSessionKey]: {
|
|
sessionId: "sess-stale-current",
|
|
updatedAt: now,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
addSubagentRunForTests({
|
|
runId: "run-stale-active",
|
|
childSessionKey,
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "stale active row",
|
|
cleanup: "keep",
|
|
createdAt: now - 5_000,
|
|
startedAt: now - 4_500,
|
|
model: "openai/gpt-5.4",
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-current-ended",
|
|
childSessionKey,
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "current ended row",
|
|
cleanup: "keep",
|
|
createdAt: now - 1_000,
|
|
startedAt: now - 900,
|
|
endedAt: now - 200,
|
|
outcome: { status: "ok" },
|
|
model: "openai/gpt-5.4",
|
|
});
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
expect(result.sessions).toHaveLength(1);
|
|
expect(result.sessions[0]).toMatchObject({
|
|
key: childSessionKey,
|
|
status: "done",
|
|
startedAt: now - 900,
|
|
endedAt: now - 200,
|
|
});
|
|
});
|
|
|
|
test("prefers persisted terminal session state when only stale active subagent snapshots remain", () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-subagent-"));
|
|
const stateDir = path.join(tempRoot, "state");
|
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
try {
|
|
const now = Date.now();
|
|
const childSessionKey = "agent:main:subagent:disk-live";
|
|
const registryPath = path.join(stateDir, "subagents", "runs.json");
|
|
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
registryPath,
|
|
JSON.stringify(
|
|
{
|
|
version: 2,
|
|
runs: {
|
|
"run-complete": {
|
|
runId: "run-complete",
|
|
childSessionKey,
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "finished too early",
|
|
cleanup: "keep",
|
|
createdAt: now - 2_000,
|
|
startedAt: now - 1_900,
|
|
endedAt: now - 1_800,
|
|
outcome: { status: "ok" },
|
|
},
|
|
"run-live": {
|
|
runId: "run-live",
|
|
childSessionKey,
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "still running",
|
|
cleanup: "keep",
|
|
createdAt: now - 10_000,
|
|
startedAt: now - 9_000,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
const row = withEnv(
|
|
{
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_DISK: "1",
|
|
},
|
|
() => {
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store: {
|
|
[childSessionKey]: {
|
|
sessionId: "sess-disk-live",
|
|
updatedAt: now,
|
|
spawnedBy: "agent:main:main",
|
|
status: "done",
|
|
endedAt: now - 1_800,
|
|
runtimeMs: 100,
|
|
} as SessionEntry,
|
|
},
|
|
opts: {},
|
|
});
|
|
return result.sessions.find((session) => session.key === childSessionKey);
|
|
},
|
|
);
|
|
|
|
expect(row?.status).toBe("done");
|
|
expect(row?.subagentRunState).toBe("historical");
|
|
expect(row?.hasActiveSubagentRun).toBe(false);
|
|
expect(row?.startedAt).toBe(now - 9_000);
|
|
expect(row?.endedAt).toBe(now - 1_800);
|
|
expect(row?.runtimeMs).toBe(100);
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("reuses one subagent registry disk snapshot across sessions.list filtering and row enrichment", () => {
|
|
const tempRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "openclaw-session-utils-subagent-cache-"),
|
|
);
|
|
const stateDir = path.join(tempRoot, "state");
|
|
const registryPath = path.join(stateDir, "subagents", "runs.json");
|
|
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
|
const now = Date.now();
|
|
const controllerSessionKey = "agent:main:main";
|
|
const childKeys = [
|
|
"agent:main:subagent:cache-child-a",
|
|
"agent:main:subagent:cache-child-b",
|
|
"agent:main:subagent:cache-child-c",
|
|
];
|
|
fs.writeFileSync(
|
|
registryPath,
|
|
JSON.stringify(
|
|
{
|
|
version: 2,
|
|
runs: Object.fromEntries(
|
|
childKeys.map((childSessionKey, index) => [
|
|
`run-cache-child-${index}`,
|
|
{
|
|
runId: `run-cache-child-${index}`,
|
|
childSessionKey,
|
|
controllerSessionKey,
|
|
requesterSessionKey: controllerSessionKey,
|
|
requesterDisplayKey: "main",
|
|
task: "cache test child",
|
|
cleanup: "keep",
|
|
createdAt: now - 5_000 + index,
|
|
startedAt: now - 4_000 + index,
|
|
},
|
|
]),
|
|
),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
const store: Record<string, SessionEntry> = {
|
|
[controllerSessionKey]: {
|
|
updatedAt: now,
|
|
} as SessionEntry,
|
|
[childKeys[0]]: {
|
|
updatedAt: now - 1_000,
|
|
spawnedBy: controllerSessionKey,
|
|
} as SessionEntry,
|
|
[childKeys[1]]: {
|
|
updatedAt: now - 2_000,
|
|
spawnedBy: controllerSessionKey,
|
|
} as SessionEntry,
|
|
[childKeys[2]]: {
|
|
updatedAt: now - 3_000,
|
|
spawnedBy: controllerSessionKey,
|
|
} as SessionEntry,
|
|
};
|
|
|
|
const statSpy = vi.spyOn(fs, "statSync");
|
|
try {
|
|
const result = withEnv(
|
|
{
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_DISK: "1",
|
|
},
|
|
() =>
|
|
listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: { spawnedBy: controllerSessionKey },
|
|
}),
|
|
);
|
|
|
|
expect(result.sessions.map((session) => session.key)).toEqual(childKeys);
|
|
const registryStatCount = statSpy.mock.calls.filter(
|
|
([pathname]) => path.normalize(String(pathname)) === path.normalize(registryPath),
|
|
).length;
|
|
expect(registryStatCount).toBe(1);
|
|
} finally {
|
|
statSpy.mockRestore();
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("does not read the subagent registry when raw filters drop every session", () => {
|
|
const tempRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "openclaw-session-utils-subagent-cache-empty-"),
|
|
);
|
|
const stateDir = path.join(tempRoot, "state");
|
|
const registryPath = path.join(stateDir, "subagents", "runs.json");
|
|
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
|
fs.writeFileSync(registryPath, JSON.stringify({ version: 2, runs: {} }, null, 2), "utf-8");
|
|
|
|
const statSpy = vi.spyOn(fs, "statSync");
|
|
try {
|
|
const result = withEnv(
|
|
{
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_DISK: "1",
|
|
},
|
|
() =>
|
|
listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store: {
|
|
"agent:main:filtered-out": {
|
|
label: "keep-me-out",
|
|
updatedAt: Date.now(),
|
|
} as SessionEntry,
|
|
},
|
|
opts: { label: "wanted-label" },
|
|
}),
|
|
);
|
|
|
|
expect(result.sessions).toStrictEqual([]);
|
|
const registryStatCount = statSpy.mock.calls.filter(
|
|
([pathname]) => path.normalize(String(pathname)) === path.normalize(registryPath),
|
|
).length;
|
|
expect(registryStatCount).toBe(0);
|
|
} finally {
|
|
statSpy.mockRestore();
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("includes explicit parentSessionKey relationships for dashboard child sessions", () => {
|
|
resetSubagentRegistryForTests({ persist: false });
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: now,
|
|
} as SessionEntry,
|
|
"agent:main:dashboard:child": {
|
|
sessionId: "sess-child",
|
|
updatedAt: now - 1_000,
|
|
parentSessionKey: "agent:main:main",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
const main = result.sessions.find((session) => session.key === "agent:main:main");
|
|
const child = result.sessions.find((session) => session.key === "agent:main:dashboard:child");
|
|
expect(main?.childSessions).toEqual(["agent:main:dashboard:child"]);
|
|
expect(child?.parentSessionKey).toBe("agent:main:main");
|
|
});
|
|
|
|
test("returns dashboard child sessions when filtering by parentSessionKey owner", () => {
|
|
resetSubagentRegistryForTests({ persist: false });
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: now,
|
|
} as SessionEntry,
|
|
"agent:main:dashboard:child": {
|
|
sessionId: "sess-dashboard-child",
|
|
updatedAt: now - 1_000,
|
|
parentSessionKey: "agent:main:main",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {
|
|
spawnedBy: "agent:main:main",
|
|
},
|
|
});
|
|
|
|
expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:dashboard:child"]);
|
|
});
|
|
|
|
test("does not reattach stale terminal store-only child links", () => {
|
|
resetSubagentRegistryForTests({ persist: false });
|
|
const now = Date.now();
|
|
const staleAt = now - 2 * 60 * 60_000;
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: now,
|
|
} as SessionEntry,
|
|
"agent:claude:acp:done-child": {
|
|
sessionId: "sess-done-child",
|
|
updatedAt: staleAt,
|
|
spawnedBy: "agent:main:main",
|
|
status: "done",
|
|
endedAt: staleAt,
|
|
} as SessionEntry,
|
|
};
|
|
|
|
const all = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
const main = all.sessions.find((session) => session.key === "agent:main:main");
|
|
expect(main?.childSessions).toBeUndefined();
|
|
|
|
const filtered = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {
|
|
spawnedBy: "agent:main:main",
|
|
},
|
|
});
|
|
expect(filtered.sessions.map((session) => session.key)).toStrictEqual([]);
|
|
});
|
|
|
|
test("does not reattach stale orphan store-only child links without lifecycle fields", () => {
|
|
resetSubagentRegistryForTests({ persist: false });
|
|
const now = Date.now();
|
|
const staleAt = now - 2 * 60 * 60_000;
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: now,
|
|
} as SessionEntry,
|
|
"agent:main:subagent:orphan": {
|
|
sessionId: "sess-orphan",
|
|
updatedAt: staleAt,
|
|
parentSessionKey: "agent:main:main",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
const all = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
const main = all.sessions.find((session) => session.key === "agent:main:main");
|
|
expect(main?.childSessions).toBeUndefined();
|
|
|
|
const filtered = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {
|
|
spawnedBy: "agent:main:main",
|
|
},
|
|
});
|
|
expect(filtered.sessions.map((session) => session.key)).toStrictEqual([]);
|
|
});
|
|
|
|
test("does not keep old ended registry runs attached as child sessions", () => {
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: now,
|
|
} as SessionEntry,
|
|
"agent:main:subagent:old-ended": {
|
|
sessionId: "sess-old-ended",
|
|
updatedAt: now - 60 * 60_000,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
addSubagentRunForTests({
|
|
runId: "run-old-ended",
|
|
childSessionKey: "agent:main:subagent:old-ended",
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "old ended task",
|
|
cleanup: "keep",
|
|
createdAt: now - 60 * 60_000,
|
|
startedAt: now - 59 * 60_000,
|
|
endedAt: now - 31 * 60_000,
|
|
outcome: { status: "ok" },
|
|
});
|
|
|
|
const all = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
const main = all.sessions.find((session) => session.key === "agent:main:main");
|
|
expect(main?.childSessions).toBeUndefined();
|
|
|
|
const filtered = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {
|
|
spawnedBy: "agent:main:main",
|
|
},
|
|
});
|
|
expect(filtered.sessions.map((session) => session.key)).toStrictEqual([]);
|
|
});
|
|
|
|
test("keeps ended parents attached while live descendants are still running", () => {
|
|
const now = Date.now();
|
|
const parentKey = "agent:main:subagent:ended-parent";
|
|
const childKey = "agent:main:subagent:ended-parent:subagent:live-child";
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: now,
|
|
} as SessionEntry,
|
|
[parentKey]: {
|
|
sessionId: "sess-ended-parent",
|
|
updatedAt: now - 31 * 60_000,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
[childKey]: {
|
|
sessionId: "sess-live-child",
|
|
updatedAt: now,
|
|
spawnedBy: parentKey,
|
|
} as SessionEntry,
|
|
};
|
|
|
|
addSubagentRunForTests({
|
|
runId: "run-ended-parent",
|
|
childSessionKey: parentKey,
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "ended parent task",
|
|
cleanup: "keep",
|
|
createdAt: now - 60 * 60_000,
|
|
startedAt: now - 59 * 60_000,
|
|
endedAt: now - 31 * 60_000,
|
|
outcome: { status: "ok" },
|
|
});
|
|
addSubagentRunForTests({
|
|
runId: "run-live-child",
|
|
childSessionKey: childKey,
|
|
controllerSessionKey: parentKey,
|
|
requesterSessionKey: parentKey,
|
|
requesterDisplayKey: "ended-parent",
|
|
task: "live child task",
|
|
cleanup: "keep",
|
|
createdAt: now - 1_000,
|
|
startedAt: now - 900,
|
|
});
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
const main = result.sessions.find((session) => session.key === "agent:main:main");
|
|
expect(main?.childSessions).toEqual([parentKey]);
|
|
});
|
|
|
|
test("falls back to persisted subagent timing after run archival", () => {
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:subagent:archived": {
|
|
sessionId: "sess-archived",
|
|
updatedAt: now,
|
|
spawnedBy: "agent:main:main",
|
|
startedAt: now - 20_000,
|
|
endedAt: now - 5_000,
|
|
runtimeMs: 15_000,
|
|
status: "done",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
const archived = result.sessions.find(
|
|
(session) => session.key === "agent:main:subagent:archived",
|
|
);
|
|
expect(archived?.status).toBe("done");
|
|
expect(archived?.startedAt).toBe(now - 20_000);
|
|
expect(archived?.endedAt).toBe(now - 5_000);
|
|
expect(archived?.runtimeMs).toBe(15_000);
|
|
});
|
|
|
|
test("maps timeout outcomes to timeout status and clamps negative runtime", () => {
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:subagent:timeout": {
|
|
sessionId: "sess-timeout",
|
|
updatedAt: now,
|
|
spawnedBy: "agent:main:main",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
addSubagentRunForTests({
|
|
runId: "run-timeout",
|
|
childSessionKey: "agent:main:subagent:timeout",
|
|
controllerSessionKey: "agent:main:main",
|
|
requesterSessionKey: "agent:main:main",
|
|
requesterDisplayKey: "main",
|
|
task: "timeout task",
|
|
cleanup: "keep",
|
|
createdAt: now - 10_000,
|
|
startedAt: now - 1_000,
|
|
endedAt: now - 2_000,
|
|
outcome: { status: "timeout" },
|
|
model: "openai/gpt-5.4",
|
|
});
|
|
|
|
const result = listSessionsFromStore({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
const timeout = result.sessions.find(
|
|
(session) => session.key === "agent:main:subagent:timeout",
|
|
);
|
|
expect(timeout?.status).toBe("timeout");
|
|
expect(timeout?.runtimeMs).toBe(0);
|
|
});
|
|
|
|
test("fails closed when model lookup misses", async () => {
|
|
await expect(
|
|
resolveGatewayModelSupportsImages({
|
|
model: "gpt-5.4",
|
|
provider: "openai",
|
|
loadGatewayModelCatalog: async () => [
|
|
{ id: "gpt-5.4", name: "GPT-5.4", provider: "other", input: ["text", "image"] },
|
|
],
|
|
}),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
test("fails closed when model catalog load throws", async () => {
|
|
await expect(
|
|
resolveGatewayModelSupportsImages({
|
|
model: "gpt-5.4",
|
|
provider: "openai",
|
|
loadGatewayModelCatalog: async () => {
|
|
throw new Error("catalog unavailable");
|
|
},
|
|
}),
|
|
).resolves.toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)", () => {
|
|
test("ACP agent sessions are visible even when agents.list is configured", async () => {
|
|
await withStateDirEnv("openclaw-acp-vis-", async ({ stateDir }) => {
|
|
const customRoot = path.join(stateDir, "custom-state");
|
|
const agentsDir = path.join(customRoot, "agents");
|
|
const mainDir = path.join(agentsDir, "main", "sessions");
|
|
const codexDir = path.join(agentsDir, "codex", "sessions");
|
|
fs.mkdirSync(mainDir, { recursive: true });
|
|
fs.mkdirSync(codexDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(mainDir, "sessions.json"),
|
|
JSON.stringify({
|
|
"agent:main:main": { sessionId: "s-main", updatedAt: 100 },
|
|
}),
|
|
"utf8",
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(codexDir, "sessions.json"),
|
|
JSON.stringify({
|
|
"agent:codex:acp-task": { sessionId: "s-codex", updatedAt: 200 },
|
|
}),
|
|
"utf8",
|
|
);
|
|
|
|
const cfg = {
|
|
session: {
|
|
mainKey: "main",
|
|
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
|
},
|
|
agents: {
|
|
list: [{ id: "main", default: true }],
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const { store } = loadCombinedSessionStoreForGateway(cfg);
|
|
expect(store["agent:main:main"]).toMatchObject({ sessionId: "s-main" });
|
|
expect(store["agent:codex:acp-task"]).toMatchObject({ sessionId: "s-codex" });
|
|
});
|
|
});
|
|
|
|
test("agent-scoped loads read only matching agent stores", async () => {
|
|
await withStateDirEnv("openclaw-acp-scoped-", async ({ stateDir }) => {
|
|
const customRoot = path.join(stateDir, "custom-state");
|
|
const agentsDir = path.join(customRoot, "agents");
|
|
const mainDir = path.join(agentsDir, "main", "sessions");
|
|
const codexDir = path.join(agentsDir, "codex", "sessions");
|
|
fs.mkdirSync(mainDir, { recursive: true });
|
|
fs.mkdirSync(codexDir, { recursive: true });
|
|
|
|
const mainStorePath = path.join(mainDir, "sessions.json");
|
|
const codexStorePath = path.join(codexDir, "sessions.json");
|
|
fs.writeFileSync(
|
|
mainStorePath,
|
|
JSON.stringify({
|
|
"agent:main:main": { sessionId: "s-main", updatedAt: 100 },
|
|
}),
|
|
"utf8",
|
|
);
|
|
fs.writeFileSync(
|
|
codexStorePath,
|
|
JSON.stringify({
|
|
"agent:codex:acp-task": { sessionId: "s-codex", updatedAt: 200 },
|
|
}),
|
|
"utf8",
|
|
);
|
|
|
|
const cfg = {
|
|
session: {
|
|
mainKey: "main",
|
|
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
|
},
|
|
agents: {
|
|
list: [{ id: "main", default: true }],
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const readSpy = vi.spyOn(fs, "readFileSync");
|
|
|
|
const { store, storePath } = loadCombinedSessionStoreForGateway(cfg, { agentId: "codex" });
|
|
|
|
expect(storePath).toBe(fs.realpathSync.native(codexStorePath));
|
|
expect(store["agent:codex:acp-task"]).toMatchObject({ sessionId: "s-codex" });
|
|
expect(store["agent:main:main"]).toBeUndefined();
|
|
const readPaths = readSpy.mock.calls
|
|
.map((call) => call[0])
|
|
.filter((arg): arg is string => typeof arg === "string");
|
|
expect(readPaths).toContain(fs.realpathSync.native(codexStorePath));
|
|
expect(readPaths).not.toContain(fs.realpathSync.native(mainStorePath));
|
|
|
|
readSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
test("keeps canonical single-target entries by reference", async () => {
|
|
await withStateDirEnv("openclaw-acp-canonical-", async ({ stateDir }) => {
|
|
const customRoot = path.join(stateDir, "custom-state");
|
|
const codexDir = path.join(customRoot, "agents", "codex", "sessions");
|
|
fs.mkdirSync(codexDir, { recursive: true });
|
|
|
|
const codexStorePath = path.join(codexDir, "sessions.json");
|
|
fs.writeFileSync(
|
|
codexStorePath,
|
|
JSON.stringify({
|
|
"agent:codex:acp-task": {
|
|
sessionId: "s-codex",
|
|
updatedAt: 200,
|
|
spawnedBy: "agent:codex:main",
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
|
|
const cfg = {
|
|
session: {
|
|
mainKey: "main",
|
|
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
|
},
|
|
agents: {
|
|
list: [{ id: "codex", default: true }],
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const cachedStore = loadSessionStore(fs.realpathSync.native(codexStorePath), {
|
|
clone: false,
|
|
});
|
|
const { store } = loadCombinedSessionStoreForGateway(cfg, { agentId: "codex" });
|
|
|
|
expect(store["agent:codex:acp-task"]).toBe(cachedStore["agent:codex:acp-task"]);
|
|
});
|
|
});
|
|
});
|