import { describe, expect, it } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; import { clearAllCliSessions, clearCliSession, getCliSessionBinding, hashCliSessionText, resolveCliSessionReuse, setCliSessionBinding, } from "./cli-session.js"; describe("cli-session helpers", () => { it("persists binding metadata alongside legacy session ids", () => { const entry: SessionEntry = { sessionId: "openclaw-session", updatedAt: Date.now(), }; setCliSessionBinding(entry, "claude-cli", { sessionId: "cli-session-1", forceReuse: true, authProfileId: "anthropic:work", authEpoch: "auth-epoch", authEpochVersion: 2, extraSystemPromptHash: "prompt-hash", mcpConfigHash: "mcp-hash", mcpResumeHash: "mcp-resume-hash", }); expect(entry.cliSessionIds?.["claude-cli"]).toBe("cli-session-1"); expect(entry.claudeCliSessionId).toBe("cli-session-1"); expect(getCliSessionBinding(entry, "claude-cli")).toEqual({ sessionId: "cli-session-1", forceReuse: true, authProfileId: "anthropic:work", authEpoch: "auth-epoch", authEpochVersion: 2, extraSystemPromptHash: "prompt-hash", mcpConfigHash: "mcp-hash", mcpResumeHash: "mcp-resume-hash", }); }); it("force-reuses explicitly attached CLI sessions despite metadata drift", () => { const binding = { sessionId: "cli-session-1", forceReuse: true, authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-config-a", mcpResumeHash: "mcp-resume-a", }; expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:personal", authEpoch: "auth-epoch-b", authEpochVersion: 2, extraSystemPromptHash: "prompt-b", mcpConfigHash: "mcp-config-b", mcpResumeHash: "mcp-resume-b", }), ).toEqual({ sessionId: "cli-session-1" }); }); it("keeps legacy bindings reusable until richer metadata is persisted", () => { const entry: SessionEntry = { sessionId: "openclaw-session", updatedAt: Date.now(), cliSessionIds: { "claude-cli": "legacy-session" }, claudeCliSessionId: "legacy-session", }; expect( resolveCliSessionReuse({ binding: getCliSessionBinding(entry, "claude-cli"), authEpochVersion: 2, }), ).toEqual({ sessionId: "legacy-session" }); }); it("invalidates legacy bindings when auth, prompt, or MCP state changes", () => { const entry: SessionEntry = { sessionId: "openclaw-session", updatedAt: Date.now(), cliSessionIds: { "claude-cli": "legacy-session" }, claudeCliSessionId: "legacy-session", }; const binding = getCliSessionBinding(entry, "claude-cli"); expect( resolveCliSessionReuse({ binding, authEpochVersion: 2, authProfileId: "anthropic:work", }), ).toEqual({ invalidatedReason: "auth-profile" }); expect( resolveCliSessionReuse({ binding, authEpochVersion: 2, extraSystemPromptHash: "prompt-hash", }), ).toEqual({ invalidatedReason: "system-prompt" }); expect( resolveCliSessionReuse({ binding, authEpochVersion: 2, mcpConfigHash: "mcp-hash", }), ).toEqual({ invalidatedReason: "mcp" }); }); it("invalidates reuse when stored auth profile or prompt shape changes", () => { const binding = { sessionId: "cli-session-1", authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }; expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:personal", authEpoch: "auth-epoch-b", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }), ).toEqual({ invalidatedReason: "auth-profile" }); expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", authEpoch: "auth-epoch-b", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }), ).toEqual({ invalidatedReason: "auth-epoch" }); expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-b", mcpConfigHash: "mcp-a", }), ).toEqual({ invalidatedReason: "system-prompt" }); expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-b", }), ).toEqual({ invalidatedReason: "mcp" }); }); it("reuses when auth profile ids rotate but the versioned auth epoch is stable", () => { const binding = { sessionId: "cli-session-1", authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }; expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:work-alias", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }), ).toEqual({ sessionId: "cli-session-1" }); }); it("accepts unversioned auth epochs for binding upgrades", () => { const binding = { sessionId: "cli-session-1", authProfileId: "anthropic:work", authEpoch: "previous-auth-epoch", extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }; expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }), ).toEqual({ sessionId: "cli-session-1" }); }); it("accepts older auth epoch versions for binding upgrades", () => { const binding = { sessionId: "cli-session-1", authProfileId: "anthropic:work", authEpoch: "refresh-token-auth-epoch", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }; expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", authEpoch: "identity-auth-epoch", authEpochVersion: 3, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }), ).toEqual({ sessionId: "cli-session-1" }); }); it("accepts v3 bindings without authEpoch as binding upgrades to v4", () => { // Pre-v4 google-gemini-cli sessions persisted with authEpochVersion: 3 // and no authEpoch (the local credential fingerprint returned undefined // before id_token identity lifting). The version-gate must skip the // epoch comparison for these so the next request after upgrade reuses // the stored session instead of forcing a one-time invalidation. const binding = { sessionId: "cli-session-1", authProfileId: undefined, // authEpoch deliberately absent authEpochVersion: 3, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }; expect( resolveCliSessionReuse({ binding, authProfileId: undefined, authEpoch: "v4-identity-hash", authEpochVersion: 4, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }), ).toEqual({ sessionId: "cli-session-1" }); }); it("does not treat model changes as a session mismatch", () => { const binding = { sessionId: "cli-session-1", authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }; expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }), ).toEqual({ sessionId: "cli-session-1" }); }); it("prefers the stable MCP resume hash over the raw MCP config hash", () => { const binding = { sessionId: "cli-session-1", authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-config-a", mcpResumeHash: "mcp-resume-a", }; expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-config-b", mcpResumeHash: "mcp-resume-a", }), ).toEqual({ sessionId: "cli-session-1" }); expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-config-a", mcpResumeHash: "mcp-resume-b", }), ).toEqual({ invalidatedReason: "mcp" }); }); it("falls back to legacy MCP config hashes when stored resume hashes are absent", () => { const binding = { sessionId: "cli-session-1", authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-config-a", }; expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-config-a", mcpResumeHash: "mcp-resume-a", }), ).toEqual({ sessionId: "cli-session-1" }); expect( resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", authEpoch: "auth-epoch-a", authEpochVersion: 2, extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-config-b", mcpResumeHash: "mcp-resume-a", }), ).toEqual({ invalidatedReason: "mcp" }); }); it("clears provider-scoped and global CLI session state", () => { const entry: SessionEntry = { sessionId: "openclaw-session", updatedAt: Date.now(), }; setCliSessionBinding(entry, "claude-cli", { sessionId: "claude-session" }); setCliSessionBinding(entry, "codex-cli", { sessionId: "codex-session" }); clearCliSession(entry, "codex-cli"); expect(getCliSessionBinding(entry, "codex-cli")).toBeUndefined(); expect(getCliSessionBinding(entry, "claude-cli")?.sessionId).toBe("claude-session"); clearAllCliSessions(entry); expect(entry.cliSessionBindings).toBeUndefined(); expect(entry.cliSessionIds).toBeUndefined(); expect(entry.claudeCliSessionId).toBeUndefined(); }); it("hashes trimmed extra system prompts consistently", () => { expect(hashCliSessionText(" keep this ")).toBe(hashCliSessionText("keep this")); expect(hashCliSessionText("")).toBeUndefined(); }); });