From 3635b2b8d67177f95db778bbfc6b631abc73ab07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 08:42:50 +0100 Subject: [PATCH] test: split gateway session utils coverage --- src/gateway/session-utils.search.test.ts | 755 +++++++++ src/gateway/session-utils.subagent.test.ts | 864 +++++++++++ src/gateway/session-utils.test.ts | 1618 +------------------- 3 files changed, 1631 insertions(+), 1606 deletions(-) create mode 100644 src/gateway/session-utils.search.test.ts create mode 100644 src/gateway/session-utils.subagent.test.ts diff --git a/src/gateway/session-utils.search.test.ts b/src/gateway/session-utils.search.test.ts new file mode 100644 index 00000000000..8c4661eccf3 --- /dev/null +++ b/src/gateway/session-utils.search.test.ts @@ -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>; +}): OpenClawConfig { + return { + agents: { + defaults: { + model: { primary: params.primary }, + models: params.models, + }, + }, + } as OpenClawConfig; +} + +function createLegacyRuntimeListConfig( + models?: Record>, +): OpenClawConfig { + return createModelDefaultsConfig({ + primary: "google-gemini-cli/gemini-3-pro-preview", + ...(models ? { models } : {}), + }); +} + +function createLegacyRuntimeStore(model: string): Record { + 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 => ({ + "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 = { + "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 = { + "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 }); + } + }); +}); diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts new file mode 100644 index 00000000000..a8a427216a9 --- /dev/null +++ b/src/gateway/session-utils.subagent.test.ts @@ -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 = { + "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 = { + "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 = { + "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 = { + "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 = { + [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 = { + [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 = { + "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 = { + [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 = { + "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 = { + "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 = { + "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 = { + "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(); + }); + }); +}); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index ab88f735cef..1c353121324 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -1,28 +1,20 @@ 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 { resetConfigRuntimeState, writeConfigFile } from "../config/config.js"; +import { afterEach, describe, expect, test } from "vitest"; +import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.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 { capArrayByJsonBytes, classifySessionKey, deriveSessionTitle, listAgentsForGateway, - listSessionsFromStore, - loadCombinedSessionStoreForGateway, loadSessionEntry, migrateAndPruneGatewaySessionStoreKey, parseGroupKey, pruneLegacyStoreKeys, - resolveGatewayModelSupportsImages, resolveGatewaySessionStoreTarget, resolveSessionModelIdentityRef, resolveSessionModelRef, @@ -69,28 +61,9 @@ function createModelDefaultsConfig(params: { } as OpenClawConfig; } -function createLegacyRuntimeListConfig( - models?: Record>, -): OpenClawConfig { - return createModelDefaultsConfig({ - primary: "google-gemini-cli/gemini-3-pro-preview", - ...(models ? { models } : {}), - }); -} - -function createLegacyRuntimeStore(model: string): Record { - return { - "agent:main:main": { - sessionId: "sess-main", - updatedAt: Date.now(), - model, - } as SessionEntry, - }; -} - describe("gateway session utils", () => { afterEach(() => { - resetSubagentRegistryForTests({ persist: false }); + resetConfigRuntimeState(); }); test("capArrayByJsonBytes trims from the front", () => { @@ -129,7 +102,6 @@ describe("gateway session utils", () => { expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe("agent:ops:work"); expect(resolveSessionStoreKey({ cfg, sessionKey: "work" })).toBe("agent:ops:work"); expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:ops:main" })).toBe("agent:ops:work"); - // Mixed-case main alias must also resolve to the configured mainKey (idempotent) expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:ops:MAIN" })).toBe("agent:ops:work"); expect(resolveSessionStoreKey({ cfg, sessionKey: "MAIN" })).toBe("agent:ops:work"); }); @@ -171,12 +143,10 @@ describe("gateway session utils", () => { session: { mainKey: "main" }, agents: { list: [{ id: "ops", default: true }] }, } as OpenClawConfig; - // Bare keys with different casing must resolve to the same canonical key expect(resolveSessionStoreKey({ cfg, sessionKey: "CoP" })).toBe( resolveSessionStoreKey({ cfg, sessionKey: "cop" }), ); expect(resolveSessionStoreKey({ cfg, sessionKey: "MySession" })).toBe("agent:ops:mysession"); - // Prefixed agent keys with mixed-case rest must also normalize expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:ops:CoP" })).toBe("agent:ops:cop"); expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:alpha:MySession" })).toBe( "agent:alpha:mysession", @@ -214,7 +184,6 @@ describe("gateway session utils", () => { test("resolveGatewaySessionStoreTarget includes legacy mixed-case store key", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-case-")); const storePath = path.join(dir, "sessions.json"); - // Simulate a legacy store with a mixed-case key fs.writeFileSync( storePath, JSON.stringify({ "agent:ops:MySession": { sessionId: "s1", updatedAt: 1 } }), @@ -224,14 +193,11 @@ describe("gateway session utils", () => { session: { mainKey: "main", store: storePath }, agents: { list: [{ id: "ops", default: true }] }, } as OpenClawConfig; - // Client passes the lowercased canonical key (as returned by sessions.list) const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:ops:mysession" }); expect(target.canonicalKey).toBe("agent:ops:mysession"); - // storeKeys must include the legacy mixed-case key from the on-disk store expect(target.storeKeys).toEqual( expect.arrayContaining(["agent:ops:mysession", "agent:ops:MySession"]), ); - // The legacy key must resolve to the actual entry in the store const store = JSON.parse(fs.readFileSync(storePath, "utf8")); const found = target.storeKeys.some((k) => Boolean(store[k])); expect(found).toBe(true); @@ -240,7 +206,6 @@ describe("gateway session utils", () => { test("resolveGatewaySessionStoreTarget includes all case-variant duplicate keys", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-dupes-")); const storePath = path.join(dir, "sessions.json"); - // Simulate a store with both canonical and legacy mixed-case entries fs.writeFileSync( storePath, JSON.stringify({ @@ -254,7 +219,6 @@ describe("gateway session utils", () => { agents: { list: [{ id: "ops", default: true }] }, } as OpenClawConfig; const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:ops:mysession" }); - // storeKeys must include BOTH variants so delete/reset/patch can clean up all duplicates expect(target.storeKeys).toEqual( expect.arrayContaining(["agent:ops:mysession", "agent:ops:MySession"]), ); @@ -263,7 +227,6 @@ describe("gateway session utils", () => { test("resolveGatewaySessionStoreTarget finds legacy main alias key when mainKey is customized", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-alias-")); const storePath = path.join(dir, "sessions.json"); - // Legacy store has entry under "agent:ops:MAIN" but mainKey is "work" fs.writeFileSync( storePath, JSON.stringify({ "agent:ops:MAIN": { sessionId: "s1", updatedAt: 1 } }), @@ -275,7 +238,6 @@ describe("gateway session utils", () => { } as OpenClawConfig; const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:ops:main" }); expect(target.canonicalKey).toBe("agent:ops:work"); - // storeKeys must include the legacy mixed-case alias key expect(target.storeKeys).toEqual(expect.arrayContaining(["agent:ops:MAIN"])); }); @@ -320,14 +282,14 @@ describe("gateway session utils", () => { }), "utf8", ); - await writeConfigFile({ + const cfg = { session: { mainKey: "main", store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"), }, agents: { list: [{ id: "main", default: true }] }, - }); - resetConfigRuntimeState(); + } as OpenClawConfig; + setRuntimeConfigSnapshot(cfg, cfg); const loaded = loadSessionEntry("agent:retired-agent:main"); @@ -358,14 +320,14 @@ describe("gateway session utils", () => { ), "utf8", ); - await writeConfigFile({ + const cfg = { session: { mainKey: "main", store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"), }, agents: { list: [{ id: "main", default: true }] }, - }); - resetConfigRuntimeState(); + } as OpenClawConfig; + setRuntimeConfigSnapshot(cfg, cfg); const loaded = loadSessionEntry("agent:main:main"); @@ -409,14 +371,14 @@ describe("gateway session utils", () => { "utf8", ); - await writeConfigFile({ + const cfg = { session: { mainKey: "main", store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"), }, agents: { list: [{ id: "main", default: true }] }, - }); - resetConfigRuntimeState(); + } as OpenClawConfig; + setRuntimeConfigSnapshot(cfg, cfg); const loaded = loadSessionEntry("agent:main:main"); @@ -674,8 +636,6 @@ describe("resolveSessionModelRef", () => { }); test("preserves provider from slash-prefixed model when modelProvider is missing", () => { - // When model string contains a provider prefix (e.g. "anthropic/claude-sonnet-4-6") - // parseModelRef should extract it correctly even without modelProvider set. const cfg = createModelDefaultsConfig({ primary: "google-gemini-cli/gemini-3-pro-preview", }); @@ -918,1557 +878,3 @@ describe("deriveSessionTitle", () => { expect(deriveSessionTitle(entry)).toBe("Actual Subject"); }); }); - -describe("listSessionsFromStore search", () => { - const baseCfg = { - session: { mainKey: "main" }, - agents: { list: [{ id: "main", default: true }] }, - } as OpenClawConfig; - - const makeStore = (): Record => ({ - "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 = { - "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 = { - "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 }); - } - }); -}); - -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 = { - "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 = { - "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 = { - "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 = { - "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 = { - [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 = { - [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 = { - "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 = { - [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 = { - "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 = { - "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 = { - "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 = { - "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(); - }); - }); -});