mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 09:11:13 +00:00
test: split gateway session utils coverage
This commit is contained in:
755
src/gateway/session-utils.search.test.ts
Normal file
755
src/gateway/session-utils.search.test.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, 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 { listSessionsFromStore } from "./session-utils.js";
|
||||
|
||||
function createModelDefaultsConfig(params: {
|
||||
primary: string;
|
||||
models?: Record<string, Record<string, never>>;
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: params.primary },
|
||||
models: params.models,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createLegacyRuntimeListConfig(
|
||||
models?: Record<string, Record<string, never>>,
|
||||
): OpenClawConfig {
|
||||
return createModelDefaultsConfig({
|
||||
primary: "google-gemini-cli/gemini-3-pro-preview",
|
||||
...(models ? { models } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function createLegacyRuntimeStore(model: string): Record<string, SessionEntry> {
|
||||
return {
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
model,
|
||||
} as SessionEntry,
|
||||
};
|
||||
}
|
||||
|
||||
describe("listSessionsFromStore search", () => {
|
||||
afterEach(() => {
|
||||
resetSubagentRegistryForTests({ persist: false });
|
||||
});
|
||||
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const makeStore = (): Record<string, SessionEntry> => ({
|
||||
"agent:main:work-project": {
|
||||
sessionId: "sess-work-1",
|
||||
updatedAt: Date.now(),
|
||||
displayName: "Work Project Alpha",
|
||||
label: "work",
|
||||
} as SessionEntry,
|
||||
"agent:main:personal-chat": {
|
||||
sessionId: "sess-personal-1",
|
||||
updatedAt: Date.now() - 1000,
|
||||
displayName: "Personal Chat",
|
||||
subject: "Family Reunion Planning",
|
||||
} as SessionEntry,
|
||||
"agent:main:discord:group:dev-team": {
|
||||
sessionId: "sess-discord-1",
|
||||
updatedAt: Date.now() - 2000,
|
||||
label: "discord",
|
||||
subject: "Dev Team Discussion",
|
||||
} as SessionEntry,
|
||||
});
|
||||
|
||||
test("returns all sessions when search is empty or missing", () => {
|
||||
const cases = [{ opts: { search: "" } }, { opts: {} }] as const;
|
||||
for (const testCase of cases) {
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store: makeStore(),
|
||||
opts: testCase.opts,
|
||||
});
|
||||
expect(result.sessions).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
|
||||
test("filters sessions across display metadata and key fields", () => {
|
||||
const cases = [
|
||||
{ search: "WORK PROJECT", expectedKey: "agent:main:work-project" },
|
||||
{ search: "reunion", expectedKey: "agent:main:personal-chat" },
|
||||
{ search: "discord", expectedKey: "agent:main:discord:group:dev-team" },
|
||||
{ search: "sess-personal", expectedKey: "agent:main:personal-chat" },
|
||||
{ search: "dev-team", expectedKey: "agent:main:discord:group:dev-team" },
|
||||
{ search: "alpha", expectedKey: "agent:main:work-project" },
|
||||
{ search: " personal ", expectedKey: "agent:main:personal-chat" },
|
||||
{ search: "nonexistent-term", expectedKey: undefined },
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store: makeStore(),
|
||||
opts: { search: testCase.search },
|
||||
});
|
||||
if (!testCase.expectedKey) {
|
||||
expect(result.sessions).toHaveLength(0);
|
||||
continue;
|
||||
}
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].key).toBe(testCase.expectedKey);
|
||||
}
|
||||
});
|
||||
|
||||
test("hides cron run alias session keys from sessions list", () => {
|
||||
const now = Date.now();
|
||||
const store: Record<string, SessionEntry> = {
|
||||
"agent:main:cron:job-1": {
|
||||
sessionId: "run-abc",
|
||||
updatedAt: now,
|
||||
label: "Cron: job-1",
|
||||
} as SessionEntry,
|
||||
"agent:main:cron:job-1:run:run-abc": {
|
||||
sessionId: "run-abc",
|
||||
updatedAt: now,
|
||||
label: "Cron: job-1",
|
||||
} as SessionEntry,
|
||||
};
|
||||
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "does not guess provider for legacy runtime model without modelProvider",
|
||||
cfg: createLegacyRuntimeListConfig(),
|
||||
runtimeModel: "claude-sonnet-4-6",
|
||||
expectedProvider: undefined,
|
||||
},
|
||||
{
|
||||
name: "infers provider for legacy runtime model when allowlist match is unique",
|
||||
cfg: createLegacyRuntimeListConfig({ "anthropic/claude-sonnet-4-6": {} }),
|
||||
runtimeModel: "claude-sonnet-4-6",
|
||||
expectedProvider: "anthropic",
|
||||
},
|
||||
{
|
||||
name: "infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique",
|
||||
cfg: createLegacyRuntimeListConfig({
|
||||
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
|
||||
}),
|
||||
runtimeModel: "anthropic/claude-sonnet-4-6",
|
||||
expectedProvider: "vercel-ai-gateway",
|
||||
},
|
||||
])("$name", ({ cfg, runtimeModel, expectedProvider }) => {
|
||||
const result = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store: createLegacyRuntimeStore(runtimeModel),
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions[0]?.modelProvider).toBe(expectedProvider);
|
||||
expect(result.sessions[0]?.model).toBe(runtimeModel);
|
||||
});
|
||||
|
||||
test("exposes unknown totals when freshness is stale or missing", () => {
|
||||
const now = Date.now();
|
||||
const store: Record<string, SessionEntry> = {
|
||||
"agent:main:fresh": {
|
||||
sessionId: "sess-fresh",
|
||||
updatedAt: now,
|
||||
totalTokens: 1200,
|
||||
totalTokensFresh: true,
|
||||
} as SessionEntry,
|
||||
"agent:main:stale": {
|
||||
sessionId: "sess-stale",
|
||||
updatedAt: now - 1000,
|
||||
totalTokens: 2200,
|
||||
totalTokensFresh: false,
|
||||
} as SessionEntry,
|
||||
"agent:main:missing": {
|
||||
sessionId: "sess-missing",
|
||||
updatedAt: now - 2000,
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
} as SessionEntry,
|
||||
};
|
||||
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: {},
|
||||
});
|
||||
|
||||
const fresh = result.sessions.find((row) => row.key === "agent:main:fresh");
|
||||
const stale = result.sessions.find((row) => row.key === "agent:main:stale");
|
||||
const missing = result.sessions.find((row) => row.key === "agent:main:missing");
|
||||
expect(fresh?.totalTokens).toBe(1200);
|
||||
expect(fresh?.totalTokensFresh).toBe(true);
|
||||
expect(stale?.totalTokens).toBeUndefined();
|
||||
expect(stale?.totalTokensFresh).toBe(false);
|
||||
expect(missing?.totalTokens).toBeUndefined();
|
||||
expect(missing?.totalTokensFresh).toBe(false);
|
||||
});
|
||||
|
||||
test("includes estimated session cost when model pricing is configured", () => {
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
label: "GPT 5.4",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0.5 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
const result = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store: {
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
inputTokens: 2_000,
|
||||
outputTokens: 500,
|
||||
cacheRead: 1_000,
|
||||
cacheWrite: 200,
|
||||
} as SessionEntry,
|
||||
},
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions[0]?.estimatedCostUsd).toBeCloseTo(0.007725, 8);
|
||||
});
|
||||
|
||||
test("prefers persisted estimated session cost from the store", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-store-cost-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, "sess-main.jsonl"),
|
||||
[
|
||||
JSON.stringify({ type: "session", version: 1, id: "sess-main" }),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
usage: {
|
||||
input: 2_000,
|
||||
output: 500,
|
||||
cacheRead: 1_200,
|
||||
cost: { total: 0.007725 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
try {
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath,
|
||||
store: {
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
modelProvider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
estimatedCostUsd: 0.1234,
|
||||
totalTokens: 0,
|
||||
totalTokensFresh: false,
|
||||
} as SessionEntry,
|
||||
},
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions[0]?.estimatedCostUsd).toBe(0.1234);
|
||||
expect(result.sessions[0]?.totalTokens).toBe(3_200);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps zero estimated session cost when configured model pricing resolves to free", () => {
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.3-codex-spark",
|
||||
label: "GPT 5.3 Codex Spark",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
const result = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store: {
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
modelProvider: "openai-codex",
|
||||
model: "gpt-5.3-codex-spark",
|
||||
inputTokens: 5_107,
|
||||
outputTokens: 1_827,
|
||||
cacheRead: 1_536,
|
||||
cacheWrite: 0,
|
||||
} as SessionEntry,
|
||||
},
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions[0]?.estimatedCostUsd).toBe(0);
|
||||
});
|
||||
|
||||
test("falls back to transcript usage for totalTokens and zero estimatedCostUsd", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-zero-cost-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, "sess-main.jsonl"),
|
||||
[
|
||||
JSON.stringify({ type: "session", version: 1, id: "sess-main" }),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.3-codex-spark",
|
||||
usage: {
|
||||
input: 5_107,
|
||||
output: 1_827,
|
||||
cacheRead: 1_536,
|
||||
cost: { total: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
try {
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath,
|
||||
store: {
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
modelProvider: "openai-codex",
|
||||
model: "gpt-5.3-codex-spark",
|
||||
totalTokens: 0,
|
||||
totalTokensFresh: false,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
} as SessionEntry,
|
||||
},
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions[0]?.totalTokens).toBe(6_643);
|
||||
expect(result.sessions[0]?.totalTokensFresh).toBe(true);
|
||||
expect(result.sessions[0]?.estimatedCostUsd).toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to transcript usage for totalTokens and estimatedCostUsd, and derives contextTokens from the resolved model", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { params: { context1m: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, "sess-main.jsonl"),
|
||||
[
|
||||
JSON.stringify({ type: "session", version: 1, id: "sess-main" }),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
usage: {
|
||||
input: 2_000,
|
||||
output: 500,
|
||||
cacheRead: 1_200,
|
||||
cost: { total: 0.007725 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
try {
|
||||
const result = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath,
|
||||
store: {
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
modelProvider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
totalTokens: 0,
|
||||
totalTokensFresh: false,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
} as SessionEntry,
|
||||
},
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions[0]?.totalTokens).toBe(3_200);
|
||||
expect(result.sessions[0]?.totalTokensFresh).toBe(true);
|
||||
expect(result.sessions[0]?.contextTokens).toBe(1_048_576);
|
||||
expect(result.sessions[0]?.estimatedCostUsd).toBeCloseTo(0.007725, 8);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uses subagent run model immediately for child sessions while transcript usage fills live totals", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-subagent-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const now = Date.now();
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { params: { context1m: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, "sess-child.jsonl"),
|
||||
[
|
||||
JSON.stringify({ type: "session", version: 1, id: "sess-child" }),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
usage: {
|
||||
input: 2_000,
|
||||
output: 500,
|
||||
cacheRead: 1_200,
|
||||
cost: { total: 0.007725 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
addSubagentRunForTests({
|
||||
runId: "run-child-live",
|
||||
childSessionKey: "agent:main:subagent:child-live",
|
||||
controllerSessionKey: "agent:main:main",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "child task",
|
||||
cleanup: "keep",
|
||||
createdAt: now - 5_000,
|
||||
startedAt: now - 4_000,
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath,
|
||||
store: {
|
||||
"agent:main:subagent:child-live": {
|
||||
sessionId: "sess-child",
|
||||
updatedAt: now,
|
||||
spawnedBy: "agent:main:main",
|
||||
totalTokens: 0,
|
||||
totalTokensFresh: false,
|
||||
} as SessionEntry,
|
||||
},
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
key: "agent:main:subagent:child-live",
|
||||
status: "running",
|
||||
modelProvider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
totalTokens: 3_200,
|
||||
totalTokensFresh: true,
|
||||
contextTokens: 1_048_576,
|
||||
});
|
||||
expect(result.sessions[0]?.estimatedCostUsd).toBeCloseTo(0.007725, 8);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a running subagent model when transcript fallback still reflects an older run", () => {
|
||||
const tmpDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "openclaw-session-utils-subagent-stale-model-"),
|
||||
);
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const now = Date.now();
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { params: { context1m: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, "sess-child-stale.jsonl"),
|
||||
[
|
||||
JSON.stringify({ type: "session", version: 1, id: "sess-child-stale" }),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
usage: {
|
||||
input: 2_000,
|
||||
output: 500,
|
||||
cacheRead: 1_200,
|
||||
cost: { total: 0.007725 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
addSubagentRunForTests({
|
||||
runId: "run-child-live-new-model",
|
||||
childSessionKey: "agent:main:subagent:child-live-stale-transcript",
|
||||
controllerSessionKey: "agent:main:main",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "child task",
|
||||
cleanup: "keep",
|
||||
createdAt: now - 5_000,
|
||||
startedAt: now - 4_000,
|
||||
model: "openai/gpt-5.4",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath,
|
||||
store: {
|
||||
"agent:main:subagent:child-live-stale-transcript": {
|
||||
sessionId: "sess-child-stale",
|
||||
updatedAt: now,
|
||||
spawnedBy: "agent:main:main",
|
||||
totalTokens: 0,
|
||||
totalTokensFresh: false,
|
||||
} as SessionEntry,
|
||||
},
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
key: "agent:main:subagent:child-live-stale-transcript",
|
||||
status: "running",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
totalTokens: 3_200,
|
||||
totalTokensFresh: true,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the selected override model when runtime identity was intentionally cleared", () => {
|
||||
const tmpDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "openclaw-session-utils-cleared-runtime-model-"),
|
||||
);
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const now = Date.now();
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { params: { context1m: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, "sess-override.jsonl"),
|
||||
[
|
||||
JSON.stringify({ type: "session", version: 1, id: "sess-override" }),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
usage: {
|
||||
input: 2_000,
|
||||
output: 500,
|
||||
cacheRead: 1_200,
|
||||
cost: { total: 0.007725 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
try {
|
||||
const result = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath,
|
||||
store: {
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-override",
|
||||
updatedAt: now,
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-5.4",
|
||||
totalTokens: 0,
|
||||
totalTokensFresh: false,
|
||||
} as SessionEntry,
|
||||
},
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
key: "agent:main:main",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
totalTokens: 3_200,
|
||||
totalTokensFresh: true,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("does not replace the current runtime model when transcript fallback is only for missing pricing", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-pricing-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const now = Date.now();
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, "sess-pricing.jsonl"),
|
||||
[
|
||||
JSON.stringify({ type: "session", version: 1, id: "sess-pricing" }),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
usage: {
|
||||
input: 2_000,
|
||||
output: 500,
|
||||
cacheRead: 1_200,
|
||||
cost: { total: 0.007725 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
try {
|
||||
const result = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath,
|
||||
store: {
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-pricing",
|
||||
updatedAt: now,
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
contextTokens: 200_000,
|
||||
totalTokens: 3_200,
|
||||
totalTokensFresh: true,
|
||||
inputTokens: 2_000,
|
||||
outputTokens: 500,
|
||||
cacheRead: 1_200,
|
||||
} as SessionEntry,
|
||||
},
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
key: "agent:main:main",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
totalTokens: 3_200,
|
||||
totalTokensFresh: true,
|
||||
contextTokens: 200_000,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
864
src/gateway/session-utils.subagent.test.ts
Normal file
864
src/gateway/session-utils.subagent.test.ts
Normal file
@@ -0,0 +1,864 @@
|
||||
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 { 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 });
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetSubagentRegistryForTests({ persist: false });
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
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 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",
|
||||
});
|
||||
|
||||
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("uses persisted active subagent runs when the local worker only has terminal snapshots", 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("running");
|
||||
expect(row?.startedAt).toBe(now - 9_000);
|
||||
expect(row?.endedAt).toBeUndefined();
|
||||
expect(row?.runtimeMs).toBeGreaterThanOrEqual(9_000);
|
||||
} 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("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();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user