import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { capArrayByJsonBytes, classifySessionKey, deriveSessionTitle, listSessionsFromStore, parseGroupKey, pruneLegacyStoreKeys, resolveGatewaySessionStoreTarget, resolveSessionStoreKey, } from "./session-utils.js"; 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("pruneLegacyStoreKeys removes alias and case-variant ghost keys", () => { const store: Record = { "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"]); }); }); 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 => ({ "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", () => { const store = makeStore(); const result = listSessionsFromStore({ cfg: baseCfg, storePath: "/tmp/sessions.json", store, opts: { search: "" }, }); expect(result.sessions.length).toBe(3); }); test("returns all sessions when search is undefined", () => { const store = makeStore(); const result = listSessionsFromStore({ cfg: baseCfg, storePath: "/tmp/sessions.json", store, opts: {}, }); expect(result.sessions.length).toBe(3); }); test("filters by displayName case-insensitively", () => { const store = makeStore(); const result = listSessionsFromStore({ cfg: baseCfg, storePath: "/tmp/sessions.json", store, opts: { search: "WORK PROJECT" }, }); expect(result.sessions.length).toBe(1); expect(result.sessions[0].displayName).toBe("Work Project Alpha"); }); test("filters by subject", () => { const store = makeStore(); const result = listSessionsFromStore({ cfg: baseCfg, storePath: "/tmp/sessions.json", store, opts: { search: "reunion" }, }); expect(result.sessions.length).toBe(1); expect(result.sessions[0].subject).toBe("Family Reunion Planning"); }); test("filters by label", () => { const store = makeStore(); const result = listSessionsFromStore({ cfg: baseCfg, storePath: "/tmp/sessions.json", store, opts: { search: "discord" }, }); expect(result.sessions.length).toBe(1); expect(result.sessions[0].label).toBe("discord"); }); test("filters by sessionId", () => { const store = makeStore(); const result = listSessionsFromStore({ cfg: baseCfg, storePath: "/tmp/sessions.json", store, opts: { search: "sess-personal" }, }); expect(result.sessions.length).toBe(1); expect(result.sessions[0].sessionId).toBe("sess-personal-1"); }); test("filters by key", () => { const store = makeStore(); const result = listSessionsFromStore({ cfg: baseCfg, storePath: "/tmp/sessions.json", store, opts: { search: "dev-team" }, }); expect(result.sessions.length).toBe(1); expect(result.sessions[0].key).toBe("agent:main:discord:group:dev-team"); }); test("returns empty array when no matches", () => { const store = makeStore(); const result = listSessionsFromStore({ cfg: baseCfg, storePath: "/tmp/sessions.json", store, opts: { search: "nonexistent-term" }, }); expect(result.sessions.length).toBe(0); }); test("matches partial strings", () => { const store = makeStore(); const result = listSessionsFromStore({ cfg: baseCfg, storePath: "/tmp/sessions.json", store, opts: { search: "alpha" }, }); expect(result.sessions.length).toBe(1); expect(result.sessions[0].displayName).toBe("Work Project Alpha"); }); test("trims whitespace from search query", () => { const store = makeStore(); const result = listSessionsFromStore({ cfg: baseCfg, storePath: "/tmp/sessions.json", store, opts: { search: " personal " }, }); expect(result.sessions.length).toBe(1); }); 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("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); }); });