Files
Moltbot/src/gateway/session-utils.test.ts
Josh Lehman 9cb0fa58c2 fix: restore protocol outputs and stabilize Windows path CI (#44266)
* fix(ci): restore protocol outputs and stabilize Windows path test

Regenerate the Swift protocol models so protocol:check stops failing on main.
Align the session target test helper with the sync production realpath behavior so Windows does not compare runneradmin and RUNNER~1 spellings for the same file.

Regeneration-Prompt: |
  Investigate the failing checks from merged PR #34485 and confirm whether they still affect current main before changing code. Keep the fix tight: do not alter runtime behavior beyond what is required to clear the reproduced CI regressions. Commit the generated Swift protocol outputs for the PushTestResult transport field because protocol:check was failing from stale generated files on main. Also fix the Windows-only session target test by making its helper use the same synchronous realpath behavior as production discovery, so path spelling differences like runneradmin versus RUNNER~1 do not cause a false assertion failure.

* fix(ci): align session target realpath behavior on Windows

Use native realpath for sync session target discovery so it matches the async path on Windows, and update the session target test helper to assert against the same canonical path form.

Regeneration-Prompt: |
  After opening the follow-up PR for the CI regressions from merged PR #34485, inspect the new failing Windows shard instead of assuming the first fix covered every case. Keep scope limited to the session target path mismatch exposed by CI. Fix the inconsistency at the source by making sync session target discovery use the same native realpath canonicalization as the async discovery path on Windows, then update the test helper to match that shared behavior and verify the touched file with targeted tests and file-scoped lint/format checks.

* test: make merge config fixtures satisfy provider type

After rebasing the PR onto current origin/main, the merge helper test fixtures no longer satisfied ProviderConfig because the anthropic provider examples were missing required provider and model fields. Add a shared fully-typed model fixture and explicit anthropic baseUrl values so the test keeps full type coverage under tsgo.

Regeneration-Prompt: |
  Rebase the PR branch for #44266 onto the current origin/main because the failing CI error only reproduced on the merge ref. Re-run the type-check path and inspect src/agents/models-config.merge.test.ts at the exact compiler lines instead of weakening types globally. Keep the fix test-only: make the anthropic ProviderConfig fixtures structurally valid by supplying the required baseUrl and full model definition fields, and keep the shared fixture typed so tsgo accepts it without unknown casts.

* fix: align Windows session store test expectations
2026-03-12 10:55:29 -07:00

875 lines
30 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { clearConfigCache, writeConfigFile } 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 {
capArrayByJsonBytes,
classifySessionKey,
deriveSessionTitle,
listAgentsForGateway,
listSessionsFromStore,
loadCombinedSessionStoreForGateway,
loadSessionEntry,
parseGroupKey,
pruneLegacyStoreKeys,
resolveGatewaySessionStoreTarget,
resolveSessionModelIdentityRef,
resolveSessionModelRef,
resolveSessionStoreKey,
} from "./session-utils.js";
function resolveSyncRealpath(filePath: string): string {
return fs.realpathSync.native(filePath);
}
function createSymlinkOrSkip(targetPath: string, linkPath: string): boolean {
try {
fs.symlinkSync(targetPath, linkPath);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (process.platform === "win32" && (code === "EPERM" || code === "EACCES")) {
return false;
}
throw error;
}
}
function createSingleAgentAvatarConfig(workspace: string): OpenClawConfig {
return {
session: { mainKey: "main" },
agents: {
list: [{ id: "main", default: true, workspace, identity: { avatar: "avatar-link.png" } }],
},
} as OpenClawConfig;
}
function createModelDefaultsConfig(params: {
primary: string;
models?: Record<string, Record<string, never>>;
}): OpenClawConfig {
return {
agents: {
defaults: {
model: { primary: params.primary },
models: params.models,
},
},
} as OpenClawConfig;
}
function createLegacyRuntimeListConfig(
models?: Record<string, Record<string, never>>,
): OpenClawConfig {
return createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
...(models ? { models } : {}),
});
}
function createLegacyRuntimeStore(model: string): Record<string, SessionEntry> {
return {
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
model,
} as SessionEntry,
};
}
describe("gateway session utils", () => {
test("capArrayByJsonBytes trims from the front", () => {
const res = capArrayByJsonBytes(["a", "b", "c"], 10);
expect(res.items).toEqual(["b", "c"]);
});
test("parseGroupKey handles group keys", () => {
expect(parseGroupKey("discord:group:dev")).toEqual({
channel: "discord",
kind: "group",
id: "dev",
});
expect(parseGroupKey("agent:ops:discord:group:dev")).toEqual({
channel: "discord",
kind: "group",
id: "dev",
});
expect(parseGroupKey("foo:bar")).toBeNull();
});
test("classifySessionKey respects chat type + prefixes", () => {
expect(classifySessionKey("global")).toBe("global");
expect(classifySessionKey("unknown")).toBe("unknown");
expect(classifySessionKey("discord:group:dev")).toBe("group");
expect(classifySessionKey("main")).toBe("direct");
const entry = { chatType: "group" } as SessionEntry;
expect(classifySessionKey("main", entry)).toBe("group");
});
test("resolveSessionStoreKey maps main aliases to default agent main", () => {
const cfg = {
session: { mainKey: "work" },
agents: { list: [{ id: "ops", default: true }] },
} as OpenClawConfig;
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");
});
test("resolveSessionStoreKey canonicalizes bare keys to default agent", () => {
const cfg = {
session: { mainKey: "main" },
agents: { list: [{ id: "ops", default: true }] },
} as OpenClawConfig;
expect(resolveSessionStoreKey({ cfg, sessionKey: "discord:group:123" })).toBe(
"agent:ops:discord:group:123",
);
expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:alpha:main" })).toBe(
"agent:alpha:main",
);
});
test("resolveSessionStoreKey falls back to first list entry when no agent is marked default", () => {
const cfg = {
session: { mainKey: "main" },
agents: { list: [{ id: "ops" }, { id: "review" }] },
} as OpenClawConfig;
expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe("agent:ops:main");
expect(resolveSessionStoreKey({ cfg, sessionKey: "discord:group:123" })).toBe(
"agent:ops:discord:group:123",
);
});
test("resolveSessionStoreKey falls back to main when agents.list is missing", () => {
const cfg = {
session: { mainKey: "work" },
} as OpenClawConfig;
expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe("agent:main:work");
expect(resolveSessionStoreKey({ cfg, sessionKey: "thread-1" })).toBe("agent:main:thread-1");
});
test("resolveSessionStoreKey normalizes session key casing", () => {
const cfg = {
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",
);
});
test("resolveSessionStoreKey honors global scope", () => {
const cfg = {
session: { scope: "global", mainKey: "work" },
agents: { list: [{ id: "ops", default: true }] },
} as OpenClawConfig;
expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe("global");
const target = resolveGatewaySessionStoreTarget({ cfg, key: "main" });
expect(target.canonicalKey).toBe("global");
expect(target.agentId).toBe("ops");
});
test("resolveGatewaySessionStoreTarget uses canonical key for main alias", () => {
const storeTemplate = path.join(
os.tmpdir(),
"openclaw-session-utils",
"{agentId}",
"sessions.json",
);
const cfg = {
session: { mainKey: "main", store: storeTemplate },
agents: { list: [{ id: "ops", default: true }] },
} as OpenClawConfig;
const target = resolveGatewaySessionStoreTarget({ cfg, key: "main" });
expect(target.canonicalKey).toBe("agent:ops:main");
expect(target.storeKeys).toEqual(expect.arrayContaining(["agent:ops:main", "main"]));
expect(target.storePath).toBe(path.resolve(storeTemplate.replace("{agentId}", "ops")));
});
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 } }),
"utf8",
);
const cfg = {
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);
});
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({
"agent:ops:mysession": { sessionId: "s-lower", updatedAt: 2 },
"agent:ops:MySession": { sessionId: "s-mixed", updatedAt: 1 },
}),
"utf8",
);
const cfg = {
session: { mainKey: "main", store: storePath },
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"]),
);
});
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 } }),
"utf8",
);
const cfg = {
session: { mainKey: "work", store: storePath },
agents: { list: [{ id: "ops", default: true }] },
} 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"]));
});
test("resolveGatewaySessionStoreTarget preserves discovered store paths for non-round-tripping agent dirs", async () => {
await withStateDirEnv("session-utils-discovered-store-", async ({ stateDir }) => {
const retiredSessionsDir = path.join(stateDir, "agents", "Retired Agent", "sessions");
fs.mkdirSync(retiredSessionsDir, { recursive: true });
const retiredStorePath = path.join(retiredSessionsDir, "sessions.json");
fs.writeFileSync(
retiredStorePath,
JSON.stringify({
"agent:retired-agent:main": { sessionId: "sess-retired", updatedAt: 1 },
}),
"utf8",
);
const cfg = {
session: {
mainKey: "main",
store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"),
},
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:retired-agent:main" });
expect(target.storePath).toBe(resolveSyncRealpath(retiredStorePath));
});
});
test("loadSessionEntry reads discovered stores from non-round-tripping agent dirs", async () => {
clearConfigCache();
try {
await withStateDirEnv("session-utils-load-entry-", async ({ stateDir }) => {
const retiredSessionsDir = path.join(stateDir, "agents", "Retired Agent", "sessions");
fs.mkdirSync(retiredSessionsDir, { recursive: true });
const retiredStorePath = path.join(retiredSessionsDir, "sessions.json");
fs.writeFileSync(
retiredStorePath,
JSON.stringify({
"agent:retired-agent:main": { sessionId: "sess-retired", updatedAt: 7 },
}),
"utf8",
);
await writeConfigFile({
session: {
mainKey: "main",
store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"),
},
agents: { list: [{ id: "main", default: true }] },
});
clearConfigCache();
const loaded = loadSessionEntry("agent:retired-agent:main");
expect(loaded.storePath).toBe(resolveSyncRealpath(retiredStorePath));
expect(loaded.entry?.sessionId).toBe("sess-retired");
});
} finally {
clearConfigCache();
}
});
test("pruneLegacyStoreKeys removes alias and case-variant ghost keys", () => {
const store: Record<string, unknown> = {
"agent:ops:work": { sessionId: "canonical", updatedAt: 3 },
"agent:ops:MAIN": { sessionId: "legacy-upper", updatedAt: 1 },
"agent:ops:Main": { sessionId: "legacy-mixed", updatedAt: 2 },
"agent:ops:main": { sessionId: "legacy-lower", updatedAt: 4 },
};
pruneLegacyStoreKeys({
store,
canonicalKey: "agent:ops:work",
candidates: ["agent:ops:work", "agent:ops:main"],
});
expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
});
test("listAgentsForGateway rejects avatar symlink escapes outside workspace", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-avatar-outside-"));
const workspace = path.join(root, "workspace");
fs.mkdirSync(workspace, { recursive: true });
const outsideFile = path.join(root, "outside.txt");
fs.writeFileSync(outsideFile, "top-secret", "utf8");
const linkPath = path.join(workspace, "avatar-link.png");
if (!createSymlinkOrSkip(outsideFile, linkPath)) {
return;
}
const cfg = createSingleAgentAvatarConfig(workspace);
const result = listAgentsForGateway(cfg);
expect(result.agents[0]?.identity?.avatarUrl).toBeUndefined();
});
test("listAgentsForGateway allows avatar symlinks that stay inside workspace", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-avatar-inside-"));
const workspace = path.join(root, "workspace");
fs.mkdirSync(path.join(workspace, "avatars"), { recursive: true });
const targetPath = path.join(workspace, "avatars", "actual.png");
fs.writeFileSync(targetPath, "avatar", "utf8");
const linkPath = path.join(workspace, "avatar-link.png");
if (!createSymlinkOrSkip(targetPath, linkPath)) {
return;
}
const cfg = createSingleAgentAvatarConfig(workspace);
const result = listAgentsForGateway(cfg);
expect(result.agents[0]?.identity?.avatarUrl).toBe(
`data:image/png;base64,${Buffer.from("avatar").toString("base64")}`,
);
});
test("listAgentsForGateway keeps explicit agents.list scope over disk-only agents (scope boundary)", async () => {
await withStateDirEnv("openclaw-agent-list-scope-", async ({ stateDir }) => {
fs.mkdirSync(path.join(stateDir, "agents", "main"), { recursive: true });
fs.mkdirSync(path.join(stateDir, "agents", "codex"), { recursive: true });
const cfg = {
session: { mainKey: "main" },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const { agents } = listAgentsForGateway(cfg);
expect(agents.map((agent) => agent.id)).toEqual(["main"]);
});
});
});
describe("resolveSessionModelRef", () => {
test("prefers runtime model/provider from session entry", () => {
const cfg = createModelDefaultsConfig({
primary: "anthropic/claude-opus-4-6",
});
const resolved = resolveSessionModelRef(cfg, {
sessionId: "s1",
updatedAt: Date.now(),
modelProvider: "openai-codex",
model: "gpt-5.3-codex",
modelOverride: "claude-opus-4-6",
providerOverride: "anthropic",
});
expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" });
});
test("preserves openrouter provider when model contains vendor prefix", () => {
const cfg = createModelDefaultsConfig({
primary: "openrouter/minimax/minimax-m2.5",
});
const resolved = resolveSessionModelRef(cfg, {
sessionId: "s-or",
updatedAt: Date.now(),
modelProvider: "openrouter",
model: "anthropic/claude-haiku-4.5",
});
expect(resolved).toEqual({
provider: "openrouter",
model: "anthropic/claude-haiku-4.5",
});
});
test("falls back to override when runtime model is not recorded yet", () => {
const cfg = createModelDefaultsConfig({
primary: "anthropic/claude-opus-4-6",
});
const resolved = resolveSessionModelRef(cfg, {
sessionId: "s2",
updatedAt: Date.now(),
modelOverride: "openai-codex/gpt-5.3-codex",
});
expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" });
});
test("falls back to resolved provider for unprefixed legacy runtime model", () => {
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
});
const resolved = resolveSessionModelRef(cfg, {
sessionId: "legacy-session",
updatedAt: Date.now(),
model: "claude-sonnet-4-6",
modelProvider: undefined,
});
expect(resolved).toEqual({
provider: "google-gemini-cli",
model: "claude-sonnet-4-6",
});
});
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",
});
const resolved = resolveSessionModelRef(cfg, {
sessionId: "slash-model",
updatedAt: Date.now(),
model: "anthropic/claude-sonnet-4-6",
modelProvider: undefined,
});
expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
});
});
describe("resolveSessionModelIdentityRef", () => {
const resolveLegacyIdentityRef = (
cfg: OpenClawConfig,
modelProvider: string | undefined = undefined,
) =>
resolveSessionModelIdentityRef(cfg, {
sessionId: "legacy-session",
updatedAt: Date.now(),
model: "claude-sonnet-4-6",
modelProvider,
});
test("does not inherit default provider for unprefixed legacy runtime model", () => {
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
});
const resolved = resolveLegacyIdentityRef(cfg);
expect(resolved).toEqual({ model: "claude-sonnet-4-6" });
});
test("infers provider from configured model allowlist when unambiguous", () => {
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
models: {
"anthropic/claude-sonnet-4-6": {},
},
});
const resolved = resolveLegacyIdentityRef(cfg);
expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
});
test("keeps provider unknown when configured models are ambiguous", () => {
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
models: {
"anthropic/claude-sonnet-4-6": {},
"minimax/claude-sonnet-4-6": {},
},
});
const resolved = resolveLegacyIdentityRef(cfg);
expect(resolved).toEqual({ model: "claude-sonnet-4-6" });
});
test("preserves provider from slash-prefixed runtime model", () => {
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
});
const resolved = resolveSessionModelIdentityRef(cfg, {
sessionId: "slash-model",
updatedAt: Date.now(),
model: "anthropic/claude-sonnet-4-6",
modelProvider: undefined,
});
expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
});
test("infers wrapper provider for slash-prefixed runtime model when allowlist match is unique", () => {
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
models: {
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
},
});
const resolved = resolveSessionModelIdentityRef(cfg, {
sessionId: "slash-model",
updatedAt: Date.now(),
model: "anthropic/claude-sonnet-4-6",
modelProvider: undefined,
});
expect(resolved).toEqual({
provider: "vercel-ai-gateway",
model: "anthropic/claude-sonnet-4-6",
});
});
});
describe("deriveSessionTitle", () => {
test("returns undefined for undefined entry", () => {
expect(deriveSessionTitle(undefined)).toBeUndefined();
});
test("prefers displayName when set", () => {
const entry = {
sessionId: "abc123",
updatedAt: Date.now(),
displayName: "My Custom Session",
subject: "Group Chat",
} as SessionEntry;
expect(deriveSessionTitle(entry)).toBe("My Custom Session");
});
test("falls back to subject when displayName is missing", () => {
const entry = {
sessionId: "abc123",
updatedAt: Date.now(),
subject: "Dev Team Chat",
} as SessionEntry;
expect(deriveSessionTitle(entry)).toBe("Dev Team Chat");
});
test("uses first user message when displayName and subject missing", () => {
const entry = {
sessionId: "abc123",
updatedAt: Date.now(),
} as SessionEntry;
expect(deriveSessionTitle(entry, "Hello, how are you?")).toBe("Hello, how are you?");
});
test("truncates long first user message to 60 chars with ellipsis", () => {
const entry = {
sessionId: "abc123",
updatedAt: Date.now(),
} as SessionEntry;
const longMsg =
"This is a very long message that exceeds sixty characters and should be truncated appropriately";
const result = deriveSessionTitle(entry, longMsg);
expect(result).toBeDefined();
expect(result!.length).toBeLessThanOrEqual(60);
expect(result!.endsWith("…")).toBe(true);
});
test("truncates at word boundary when possible", () => {
const entry = {
sessionId: "abc123",
updatedAt: Date.now(),
} as SessionEntry;
const longMsg = "This message has many words and should be truncated at a word boundary nicely";
const result = deriveSessionTitle(entry, longMsg);
expect(result).toBeDefined();
expect(result!.endsWith("…")).toBe(true);
expect(result!.includes(" ")).toBe(false);
});
test("falls back to sessionId prefix with date", () => {
const entry = {
sessionId: "abcd1234-5678-90ef-ghij-klmnopqrstuv",
updatedAt: new Date("2024-03-15T10:30:00Z").getTime(),
} as SessionEntry;
const result = deriveSessionTitle(entry);
expect(result).toBe("abcd1234 (2024-03-15)");
});
test("falls back to sessionId prefix without date when updatedAt missing", () => {
const entry = {
sessionId: "abcd1234-5678-90ef-ghij-klmnopqrstuv",
updatedAt: 0,
} as SessionEntry;
const result = deriveSessionTitle(entry);
expect(result).toBe("abcd1234");
});
test("trims whitespace from displayName", () => {
const entry = {
sessionId: "abc123",
updatedAt: Date.now(),
displayName: " Padded Name ",
} as SessionEntry;
expect(deriveSessionTitle(entry)).toBe("Padded Name");
});
test("ignores empty displayName and falls through", () => {
const entry = {
sessionId: "abc123",
updatedAt: Date.now(),
displayName: " ",
subject: "Actual Subject",
} as SessionEntry;
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<string, SessionEntry> => ({
"agent:main:work-project": {
sessionId: "sess-work-1",
updatedAt: Date.now(),
displayName: "Work Project Alpha",
label: "work",
} as SessionEntry,
"agent:main:personal-chat": {
sessionId: "sess-personal-1",
updatedAt: Date.now() - 1000,
displayName: "Personal Chat",
subject: "Family Reunion Planning",
} as SessionEntry,
"agent:main:discord:group:dev-team": {
sessionId: "sess-discord-1",
updatedAt: Date.now() - 2000,
label: "discord",
subject: "Dev Team Discussion",
} as SessionEntry,
});
test("returns all sessions when search is empty or missing", () => {
const cases = [{ opts: { search: "" } }, { opts: {} }] as const;
for (const testCase of cases) {
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store: makeStore(),
opts: testCase.opts,
});
expect(result.sessions).toHaveLength(3);
}
});
test("filters sessions across display metadata and key fields", () => {
const cases = [
{ search: "WORK PROJECT", expectedKey: "agent:main:work-project" },
{ search: "reunion", expectedKey: "agent:main:personal-chat" },
{ search: "discord", expectedKey: "agent:main:discord:group:dev-team" },
{ search: "sess-personal", expectedKey: "agent:main:personal-chat" },
{ search: "dev-team", expectedKey: "agent:main:discord:group:dev-team" },
{ search: "alpha", expectedKey: "agent:main:work-project" },
{ search: " personal ", expectedKey: "agent:main:personal-chat" },
{ search: "nonexistent-term", expectedKey: undefined },
] as const;
for (const testCase of cases) {
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store: makeStore(),
opts: { search: testCase.search },
});
if (!testCase.expectedKey) {
expect(result.sessions).toHaveLength(0);
continue;
}
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].key).toBe(testCase.expectedKey);
}
});
test("hides cron run alias session keys from sessions list", () => {
const now = Date.now();
const store: Record<string, SessionEntry> = {
"agent:main:cron:job-1": {
sessionId: "run-abc",
updatedAt: now,
label: "Cron: job-1",
} as SessionEntry,
"agent:main:cron:job-1:run:run-abc": {
sessionId: "run-abc",
updatedAt: now,
label: "Cron: job-1",
} as SessionEntry,
};
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: {},
});
expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]);
});
test.each([
{
name: "does not guess provider for legacy runtime model without modelProvider",
cfg: createLegacyRuntimeListConfig(),
runtimeModel: "claude-sonnet-4-6",
expectedProvider: undefined,
},
{
name: "infers provider for legacy runtime model when allowlist match is unique",
cfg: createLegacyRuntimeListConfig({ "anthropic/claude-sonnet-4-6": {} }),
runtimeModel: "claude-sonnet-4-6",
expectedProvider: "anthropic",
},
{
name: "infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique",
cfg: createLegacyRuntimeListConfig({
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
}),
runtimeModel: "anthropic/claude-sonnet-4-6",
expectedProvider: "vercel-ai-gateway",
},
])("$name", ({ cfg, runtimeModel, expectedProvider }) => {
const result = listSessionsFromStore({
cfg,
storePath: "/tmp/sessions.json",
store: createLegacyRuntimeStore(runtimeModel),
opts: {},
});
expect(result.sessions[0]?.modelProvider).toBe(expectedProvider);
expect(result.sessions[0]?.model).toBe(runtimeModel);
});
test("exposes unknown totals when freshness is stale or missing", () => {
const now = Date.now();
const store: Record<string, SessionEntry> = {
"agent:main:fresh": {
sessionId: "sess-fresh",
updatedAt: now,
totalTokens: 1200,
totalTokensFresh: true,
} as SessionEntry,
"agent:main:stale": {
sessionId: "sess-stale",
updatedAt: now - 1000,
totalTokens: 2200,
totalTokensFresh: false,
} as SessionEntry,
"agent:main:missing": {
sessionId: "sess-missing",
updatedAt: now - 2000,
inputTokens: 100,
outputTokens: 200,
} as SessionEntry,
};
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: {},
});
const fresh = result.sessions.find((row) => row.key === "agent:main:fresh");
const stale = result.sessions.find((row) => row.key === "agent:main:stale");
const missing = result.sessions.find((row) => row.key === "agent:main:missing");
expect(fresh?.totalTokens).toBe(1200);
expect(fresh?.totalTokensFresh).toBe(true);
expect(stale?.totalTokens).toBeUndefined();
expect(stale?.totalTokensFresh).toBe(false);
expect(missing?.totalTokens).toBeUndefined();
expect(missing?.totalTokensFresh).toBe(false);
});
});
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();
});
});
});