import fs from "node:fs" ;
import os from "node:os" ;
import path from "node:path" ;
import { afterEach, beforeEach, describe, expect, test } from "vitest" ;
import {
addSubagentRunForTests,
resetSubagentRegistryForTests,
} from "../agents/subagent-registry.js" ;
import type { OpenClawConfig } from "../config/config.js" ;
import 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("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)).toEqual([]);
});
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" , async () => {
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("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)).toEqual([]);
});
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)).toEqual([]);
});
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)).toEqual([]);
});
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" ]).toBeDefined();
expect(store["agent:codex:acp-task" ]).toBeDefined();
});
});
});
Messung V0.5 in Prozent C=92 H=93 G=92
¤ Dauer der Verarbeitung: 0.14 Sekunden
(vorverarbeitet am 2026-06-05)
¤
*© Formatika GbR, Deutschland