diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 5db40b13a27..6c6b62f8aec 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -65,7 +65,7 @@ import { ensureAuthProfileStore } from "./auth-profiles.js"; import { clearSessionAuthProfileOverride } from "./auth-profiles/session-override.js"; import { resolveBootstrapWarningSignaturesSeen } from "./bootstrap-budget.js"; import { runCliAgent } from "./cli-runner.js"; -import { getCliSessionId, setCliSessionId } from "./cli-session.js"; +import { clearCliSession, getCliSessionBinding, setCliSessionBinding } from "./cli-session.js"; import { deliverAgentCommandResult } from "./command/delivery.js"; import { resolveAgentRunContext } from "./command/run-context.js"; import { updateSessionStoreAfterAgentRun } from "./command/session-store.js"; @@ -82,7 +82,6 @@ import { isCliProvider, modelKey, normalizeModelRef, - normalizeProviderId, parseModelRef, resolveConfiguredModelRef, resolveDefaultModelForAgent, @@ -386,8 +385,12 @@ function runAgentAttempt(params: { ); const bootstrapPromptWarningSignature = bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1]; + const authProfileId = + params.providerOverride === params.authProfileProvider + ? params.sessionEntry?.authProfileOverride + : undefined; if (isCliProvider(params.providerOverride, params.cfg)) { - const cliSessionId = getCliSessionId(params.sessionEntry, params.providerOverride); + const cliSessionBinding = getCliSessionBinding(params.sessionEntry, params.providerOverride); const runCliWithSession = (nextCliSessionId: string | undefined) => runCliAgent({ sessionId: params.sessionId, @@ -404,17 +407,20 @@ function runAgentAttempt(params: { runId: params.runId, extraSystemPrompt: params.opts.extraSystemPrompt, cliSessionId: nextCliSessionId, + cliSessionBinding: + nextCliSessionId === cliSessionBinding?.sessionId ? cliSessionBinding : undefined, + authProfileId, bootstrapPromptWarningSignaturesSeen, bootstrapPromptWarningSignature, images: params.isFallbackRetry ? undefined : params.opts.images, streamParams: params.opts.streamParams, }); - return runCliWithSession(cliSessionId).catch(async (err) => { + return runCliWithSession(cliSessionBinding?.sessionId).catch(async (err) => { // Handle CLI session expired error if ( err instanceof FailoverError && err.reason === "session_expired" && - cliSessionId && + cliSessionBinding?.sessionId && params.sessionKey && params.sessionStore && params.storePath @@ -427,15 +433,7 @@ function runAgentAttempt(params: { const entry = params.sessionStore[params.sessionKey]; if (entry) { const updatedEntry = { ...entry }; - if (params.providerOverride === "claude-cli") { - delete updatedEntry.claudeCliSessionId; - } - if (updatedEntry.cliSessionIds) { - const normalizedProvider = normalizeProviderId(params.providerOverride); - const newCliSessionIds = { ...updatedEntry.cliSessionIds }; - delete newCliSessionIds[normalizedProvider]; - updatedEntry.cliSessionIds = newCliSessionIds; - } + clearCliSession(updatedEntry, params.providerOverride); updatedEntry.updatedAt = Date.now(); await persistSessionEntry({ @@ -453,7 +451,7 @@ function runAgentAttempt(params: { return runCliWithSession(undefined).then(async (result) => { // Update session store with new CLI session ID if available if ( - result.meta.agentMeta?.sessionId && + result.meta.agentMeta?.cliSessionBinding?.sessionId && params.sessionKey && params.sessionStore && params.storePath @@ -461,10 +459,10 @@ function runAgentAttempt(params: { const entry = params.sessionStore[params.sessionKey]; if (entry) { const updatedEntry = { ...entry }; - setCliSessionId( + setCliSessionBinding( updatedEntry, params.providerOverride, - result.meta.agentMeta.sessionId, + result.meta.agentMeta.cliSessionBinding, ); updatedEntry.updatedAt = Date.now(); @@ -483,10 +481,6 @@ function runAgentAttempt(params: { }); } - const authProfileId = - params.providerOverride === params.authProfileProvider - ? params.sessionEntry?.authProfileOverride - : undefined; return runEmbeddedPiAgent({ sessionId: params.sessionId, sessionKey: params.sessionKey, @@ -1008,11 +1002,7 @@ async function agentCommandInternal( if (overrideModel) { const normalizedOverride = normalizeModelRef(overrideProvider, overrideModel); const key = modelKey(normalizedOverride.provider, normalizedOverride.model); - if ( - !isCliProvider(normalizedOverride.provider, cfg) && - !allowAnyModel && - !allowedModelKeys.has(key) - ) { + if (!allowAnyModel && !allowedModelKeys.has(key)) { const { updated } = applyModelOverrideToSessionEntry({ entry, selection: { provider: defaultProvider, model: defaultModel, isDefault: true }, @@ -1035,11 +1025,7 @@ async function agentCommandInternal( const candidateProvider = storedProviderOverride || defaultProvider; const normalizedStored = normalizeModelRef(candidateProvider, storedModelOverride); const key = modelKey(normalizedStored.provider, normalizedStored.model); - if ( - isCliProvider(normalizedStored.provider, cfg) || - allowAnyModel || - allowedModelKeys.has(key) - ) { + if (allowAnyModel || allowedModelKeys.has(key)) { provider = normalizedStored.provider; model = normalizedStored.model; } @@ -1057,11 +1043,7 @@ async function agentCommandInternal( throw new Error("Invalid model override."); } const explicitKey = modelKey(explicitRef.provider, explicitRef.model); - if ( - !isCliProvider(explicitRef.provider, cfg) && - !allowAnyModel && - !allowedModelKeys.has(explicitKey) - ) { + if (!allowAnyModel && !allowedModelKeys.has(explicitKey)) { throw new Error( `Model override "${sanitizeForLog(explicitRef.provider)}/${sanitizeForLog(explicitRef.model)}" is not allowed for agent "${sessionAgentId}".`, ); diff --git a/src/agents/cli-runner.helpers.test.ts b/src/agents/cli-runner.helpers.test.ts index a446fc02668..95659c178bf 100644 --- a/src/agents/cli-runner.helpers.test.ts +++ b/src/agents/cli-runner.helpers.test.ts @@ -1,7 +1,7 @@ import type { ImageContent } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MAX_IMAGE_BYTES } from "../media/constants.js"; -import { loadPromptRefImages } from "./cli-runner/helpers.js"; +import { buildCliArgs, loadPromptRefImages } from "./cli-runner/helpers.js"; import * as promptImageUtils from "./pi-embedded-runner/run/images.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; import * as toolImages from "./tool-images.js"; @@ -102,3 +102,19 @@ describe("loadPromptRefImages", () => { expect(result).toEqual([loadedImage]); }); }); + +describe("buildCliArgs", () => { + it("keeps passing model overrides on resumed CLI sessions", () => { + expect( + buildCliArgs({ + backend: { + command: "codex", + modelArg: "--model", + }, + baseArgs: ["exec", "resume", "thread-123"], + modelId: "gpt-5.4", + useResume: true, + }), + ).toEqual(["exec", "resume", "thread-123", "--model", "gpt-5.4"]); + }); +}); diff --git a/src/agents/cli-runner.test.ts b/src/agents/cli-runner.test.ts index 24663d007c8..80f82acbd62 100644 --- a/src/agents/cli-runner.test.ts +++ b/src/agents/cli-runner.test.ts @@ -2,8 +2,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildAnthropicCliBackend } from "../../extensions/anthropic/cli-backend.js"; +import { buildGoogleGeminiCliBackend } from "../../extensions/google/cli-backend.js"; +import { buildOpenAICodexCliBackend } from "../../extensions/openai/cli-backend.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import { resolveCliNoOutputTimeoutMs } from "./cli-runner/helpers.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; @@ -157,6 +162,25 @@ describe("runCliAgent with process supervisor", () => { }); beforeEach(async () => { + const registry = createEmptyPluginRegistry(); + registry.cliBackends = [ + { + pluginId: "anthropic", + backend: buildAnthropicCliBackend(), + source: "test", + }, + { + pluginId: "openai", + backend: buildOpenAICodexCliBackend(), + source: "test", + }, + { + pluginId: "google", + backend: buildGoogleGeminiCliBackend(), + source: "test", + }, + ]; + setActivePluginRegistry(registry); supervisorSpawnMock.mockClear(); enqueueSystemEventMock.mockClear(); requestHeartbeatNowMock.mockClear(); @@ -211,6 +235,94 @@ describe("runCliAgent with process supervisor", () => { expect(input.scopeKey).toContain("thread-123"); }); + it("keeps resuming the CLI across model changes and passes the new model flag", async () => { + mockSuccessfulCliRun(); + + await runCliAgent({ + sessionId: "s1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: { + agents: { + defaults: { + cliBackends: { + "codex-cli": { + command: "codex", + args: ["exec", "--json"], + resumeArgs: ["exec", "resume", "{sessionId}", "--json"], + output: "text", + modelArg: "--model", + sessionMode: "existing", + }, + }, + }, + }, + } satisfies OpenClawConfig, + prompt: "hi", + provider: "codex-cli", + model: "gpt-5.4", + timeoutMs: 1_000, + runId: "run-model-switch", + cliSessionBinding: { + sessionId: "thread-123", + authProfileId: "openai:default", + }, + authProfileId: "openai:default", + }); + + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[] }; + expect(input.argv).toEqual([ + "codex", + "exec", + "resume", + "thread-123", + "--json", + "--model", + "gpt-5.4", + "hi", + ]); + }); + + it("starts a fresh CLI session when the auth profile changes", async () => { + mockSuccessfulCliRun(); + + await runCliAgent({ + sessionId: "s1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: { + agents: { + defaults: { + cliBackends: { + "codex-cli": { + command: "codex", + args: ["exec", "--json"], + resumeArgs: ["exec", "resume", "{sessionId}", "--json"], + output: "text", + modelArg: "--model", + sessionMode: "existing", + }, + }, + }, + }, + } satisfies OpenClawConfig, + prompt: "hi", + provider: "codex-cli", + model: "gpt-5.4", + timeoutMs: 1_000, + runId: "run-auth-change", + cliSessionBinding: { + sessionId: "thread-123", + authProfileId: "openai:work", + }, + authProfileId: "openai:personal", + }); + + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[]; scopeKey?: string }; + expect(input.argv).toEqual(["codex", "exec", "--json", "--model", "gpt-5.4", "hi"]); + expect(input.scopeKey).toBeUndefined(); + }); + it("sanitizes dangerous backend env overrides before spawn", async () => { vi.stubEnv("PATH", "/usr/bin:/bin"); vi.stubEnv("HOME", "/tmp/trusted-home"); diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 5b7d30352f2..fad13367b07 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -2,6 +2,7 @@ import type { ImageContent } from "@mariozechner/pi-ai"; import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js"; import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { OpenClawConfig } from "../config/config.js"; +import type { CliSessionBinding } from "../config/sessions.js"; import { shouldLogVerbose } from "../globals.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; @@ -37,6 +38,7 @@ import { resolveSystemPromptUsage, writeCliImages, } from "./cli-runner/helpers.js"; +import { hashCliSessionText, resolveCliSessionReuse } from "./cli-session.js"; import { resolveOpenClawDocsPath } from "./docs-path.js"; import { FailoverError, resolveFailoverStatus } from "./failover-error.js"; import { @@ -71,6 +73,8 @@ export async function runCliAgent(params: { streamParams?: import("./command/types.js").AgentStreamParams; ownerNumbers?: string[]; cliSessionId?: string; + cliSessionBinding?: CliSessionBinding; + authProfileId?: string; bootstrapPromptWarningSignaturesSeen?: string[]; /** Backward-compat fallback when only the previous signature is available. */ bootstrapPromptWarningSignature?: string; @@ -106,6 +110,20 @@ export async function runCliAgent(params: { warn: (message) => log.warn(message), }); const backend = preparedBackend.backend; + const extraSystemPromptHash = hashCliSessionText(params.extraSystemPrompt); + const reusableCliSession = resolveCliSessionReuse({ + binding: + params.cliSessionBinding ?? + (params.cliSessionId ? { sessionId: params.cliSessionId } : undefined), + authProfileId: params.authProfileId, + extraSystemPromptHash, + mcpConfigHash: preparedBackend.mcpConfigHash, + }); + if (reusableCliSession.invalidatedReason) { + log.info( + `cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`, + ); + } const modelId = (params.model ?? "default").trim() || "default"; const normalizedModel = normalizeCliModel(modelId, backend); const modelDisplay = `${params.provider}/${modelId}`; @@ -431,9 +449,10 @@ export async function runCliAgent(params: { // Try with the provided CLI session ID first try { try { - const output = await executeCliWithSession(params.cliSessionId); + const output = await executeCliWithSession(reusableCliSession.sessionId); const text = output.text?.trim(); const payloads = text ? [{ text }] : undefined; + const effectiveCliSessionId = output.sessionId ?? reusableCliSession.sessionId; return { payloads, @@ -441,19 +460,31 @@ export async function runCliAgent(params: { durationMs: Date.now() - started, systemPromptReport, agentMeta: { - sessionId: output.sessionId ?? params.cliSessionId ?? params.sessionId ?? "", + sessionId: effectiveCliSessionId ?? params.sessionId ?? "", provider: params.provider, model: modelId, usage: output.usage, + ...(effectiveCliSessionId + ? { + cliSessionBinding: { + sessionId: effectiveCliSessionId, + ...(params.authProfileId ? { authProfileId: params.authProfileId } : {}), + ...(extraSystemPromptHash ? { extraSystemPromptHash } : {}), + ...(preparedBackend.mcpConfigHash + ? { mcpConfigHash: preparedBackend.mcpConfigHash } + : {}), + }, + } + : {}), }, }, }; } catch (err) { if (err instanceof FailoverError) { // Check if this is a session expired error and we have a session to clear - if (err.reason === "session_expired" && params.cliSessionId && params.sessionKey) { + if (err.reason === "session_expired" && reusableCliSession.sessionId && params.sessionKey) { log.warn( - `CLI session expired, clearing session ID and retrying: provider=${params.provider} session=${redactRunIdentifier(params.cliSessionId)}`, + `CLI session expired, clearing session ID and retrying: provider=${params.provider} session=${redactRunIdentifier(reusableCliSession.sessionId)}`, ); // Clear the expired session ID from the session entry @@ -464,6 +495,7 @@ export async function runCliAgent(params: { const output = await executeCliWithSession(undefined); const text = output.text?.trim(); const payloads = text ? [{ text }] : undefined; + const effectiveCliSessionId = output.sessionId; return { payloads, @@ -475,6 +507,18 @@ export async function runCliAgent(params: { provider: params.provider, model: modelId, usage: output.usage, + ...(effectiveCliSessionId + ? { + cliSessionBinding: { + sessionId: effectiveCliSessionId, + ...(params.authProfileId ? { authProfileId: params.authProfileId } : {}), + ...(extraSystemPromptHash ? { extraSystemPromptHash } : {}), + ...(preparedBackend.mcpConfigHash + ? { mcpConfigHash: preparedBackend.mcpConfigHash } + : {}), + }, + } + : {}), }, }, }; diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index 5446b884c85..bc98319abc2 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -51,6 +51,7 @@ describe("prepareCliBundleMcpConfig", () => { mcpServers?: Record; }; expect(raw.mcpServers?.bundleProbe?.args).toEqual([await fs.realpath(serverPath)]); + expect(prepared.mcpConfigHash).toMatch(/^[0-9a-f]{64}$/); await prepared.cleanup?.(); } finally { diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index f6eae9ae059..e5dcd545766 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -13,6 +14,7 @@ import { type PreparedCliBundleMcpConfig = { backend: CliBackendConfig; cleanup?: () => Promise; + mcpConfigHash?: string; }; async function readExternalMcpConfig(configPath: string): Promise { @@ -102,7 +104,8 @@ export async function prepareCliBundleMcpConfig(params: { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-mcp-")); const mcpConfigPath = path.join(tempDir, "mcp.json"); - await fs.writeFile(mcpConfigPath, `${JSON.stringify(mergedConfig, null, 2)}\n`, "utf-8"); + const serializedConfig = `${JSON.stringify(mergedConfig, null, 2)}\n`; + await fs.writeFile(mcpConfigPath, serializedConfig, "utf-8"); return { backend: { @@ -113,6 +116,7 @@ export async function prepareCliBundleMcpConfig(params: { mcpConfigPath, ), }, + mcpConfigHash: crypto.createHash("sha256").update(serializedConfig).digest("hex"), cleanup: async () => { await fs.rm(tempDir, { recursive: true, force: true }); }, diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 3003e1e9f8b..79596490075 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -395,7 +395,7 @@ export function buildCliArgs(params: { useResume: boolean; }): string[] { const args: string[] = [...params.baseArgs]; - if (!params.useResume && params.backend.modelArg && params.modelId) { + if (params.backend.modelArg && params.modelId) { args.push(params.backend.modelArg, params.modelId); } if (!params.useResume && params.systemPrompt && params.backend.systemPromptArg) { diff --git a/src/agents/cli-session.test.ts b/src/agents/cli-session.test.ts new file mode 100644 index 00000000000..c49df2b5730 --- /dev/null +++ b/src/agents/cli-session.test.ts @@ -0,0 +1,123 @@ +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", + authProfileId: "anthropic:work", + extraSystemPromptHash: "prompt-hash", + mcpConfigHash: "mcp-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", + authProfileId: "anthropic:work", + extraSystemPromptHash: "prompt-hash", + mcpConfigHash: "mcp-hash", + }); + }); + + 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") })).toEqual({ + sessionId: "legacy-session", + }); + }); + + it("invalidates reuse when stored auth profile or prompt shape changes", () => { + const binding = { + sessionId: "cli-session-1", + authProfileId: "anthropic:work", + extraSystemPromptHash: "prompt-a", + mcpConfigHash: "mcp-a", + }; + + expect( + resolveCliSessionReuse({ + binding, + authProfileId: "anthropic:personal", + extraSystemPromptHash: "prompt-a", + mcpConfigHash: "mcp-a", + }), + ).toEqual({ invalidatedReason: "auth-profile" }); + expect( + resolveCliSessionReuse({ + binding, + authProfileId: "anthropic:work", + extraSystemPromptHash: "prompt-b", + mcpConfigHash: "mcp-a", + }), + ).toEqual({ invalidatedReason: "system-prompt" }); + expect( + resolveCliSessionReuse({ + binding, + authProfileId: "anthropic:work", + extraSystemPromptHash: "prompt-a", + mcpConfigHash: "mcp-b", + }), + ).toEqual({ invalidatedReason: "mcp" }); + }); + + it("does not treat model changes as a session mismatch", () => { + const binding = { + sessionId: "cli-session-1", + authProfileId: "anthropic:work", + extraSystemPromptHash: "prompt-a", + mcpConfigHash: "mcp-a", + }; + + expect( + resolveCliSessionReuse({ + binding, + authProfileId: "anthropic:work", + extraSystemPromptHash: "prompt-a", + mcpConfigHash: "mcp-a", + }), + ).toEqual({ sessionId: "cli-session-1" }); + }); + + 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(); + }); +}); diff --git a/src/agents/cli-session.ts b/src/agents/cli-session.ts index 1c9df998ce9..48ad5ff789d 100644 --- a/src/agents/cli-session.ts +++ b/src/agents/cli-session.ts @@ -1,37 +1,141 @@ -import type { SessionEntry } from "../config/sessions.js"; +import crypto from "node:crypto"; +import type { CliSessionBinding, SessionEntry } from "../config/sessions.js"; import { normalizeProviderId } from "./model-selection.js"; -export function getCliSessionId( +function trimOptional(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function hashCliSessionText(value: string | undefined): string | undefined { + const trimmed = trimOptional(value); + if (!trimmed) { + return undefined; + } + return crypto.createHash("sha256").update(trimmed).digest("hex"); +} + +export function getCliSessionBinding( entry: SessionEntry | undefined, provider: string, -): string | undefined { +): CliSessionBinding | undefined { if (!entry) { return undefined; } const normalized = normalizeProviderId(provider); + const fromBindings = entry.cliSessionBindings?.[normalized]; + const bindingSessionId = trimOptional(fromBindings?.sessionId); + if (bindingSessionId) { + return { + sessionId: bindingSessionId, + authProfileId: trimOptional(fromBindings?.authProfileId), + extraSystemPromptHash: trimOptional(fromBindings?.extraSystemPromptHash), + mcpConfigHash: trimOptional(fromBindings?.mcpConfigHash), + }; + } const fromMap = entry.cliSessionIds?.[normalized]; if (fromMap?.trim()) { - return fromMap.trim(); + return { sessionId: fromMap.trim() }; } if (normalized === "claude-cli") { const legacy = entry.claudeCliSessionId?.trim(); if (legacy) { - return legacy; + return { sessionId: legacy }; } } return undefined; } +export function getCliSessionId( + entry: SessionEntry | undefined, + provider: string, +): string | undefined { + return getCliSessionBinding(entry, provider)?.sessionId; +} + export function setCliSessionId(entry: SessionEntry, provider: string, sessionId: string): void { + setCliSessionBinding(entry, provider, { sessionId }); +} + +export function setCliSessionBinding( + entry: SessionEntry, + provider: string, + binding: CliSessionBinding, +): void { const normalized = normalizeProviderId(provider); - const trimmed = sessionId.trim(); + const trimmed = binding.sessionId.trim(); if (!trimmed) { return; } - const existing = entry.cliSessionIds ?? {}; - entry.cliSessionIds = { ...existing }; - entry.cliSessionIds[normalized] = trimmed; + entry.cliSessionBindings = { + ...entry.cliSessionBindings, + [normalized]: { + sessionId: trimmed, + ...(trimOptional(binding.authProfileId) + ? { authProfileId: trimOptional(binding.authProfileId) } + : {}), + ...(trimOptional(binding.extraSystemPromptHash) + ? { extraSystemPromptHash: trimOptional(binding.extraSystemPromptHash) } + : {}), + ...(trimOptional(binding.mcpConfigHash) + ? { mcpConfigHash: trimOptional(binding.mcpConfigHash) } + : {}), + }, + }; + entry.cliSessionIds = { ...entry.cliSessionIds, [normalized]: trimmed }; if (normalized === "claude-cli") { entry.claudeCliSessionId = trimmed; } } + +export function clearCliSession(entry: SessionEntry, provider: string): void { + const normalized = normalizeProviderId(provider); + if (entry.cliSessionBindings?.[normalized] !== undefined) { + const next = { ...entry.cliSessionBindings }; + delete next[normalized]; + entry.cliSessionBindings = Object.keys(next).length > 0 ? next : undefined; + } + if (entry.cliSessionIds?.[normalized] !== undefined) { + const next = { ...entry.cliSessionIds }; + delete next[normalized]; + entry.cliSessionIds = Object.keys(next).length > 0 ? next : undefined; + } + if (normalized === "claude-cli") { + delete entry.claudeCliSessionId; + } +} + +export function clearAllCliSessions(entry: SessionEntry): void { + delete entry.cliSessionBindings; + delete entry.cliSessionIds; + delete entry.claudeCliSessionId; +} + +export function resolveCliSessionReuse(params: { + binding?: CliSessionBinding; + authProfileId?: string; + extraSystemPromptHash?: string; + mcpConfigHash?: string; +}): { sessionId?: string; invalidatedReason?: "auth-profile" | "system-prompt" | "mcp" } { + const binding = params.binding; + const sessionId = trimOptional(binding?.sessionId); + if (!sessionId) { + return {}; + } + const currentAuthProfileId = trimOptional(params.authProfileId); + const currentExtraSystemPromptHash = trimOptional(params.extraSystemPromptHash); + const currentMcpConfigHash = trimOptional(params.mcpConfigHash); + if (binding?.authProfileId && trimOptional(binding.authProfileId) !== currentAuthProfileId) { + return { invalidatedReason: "auth-profile" }; + } + if ( + binding?.extraSystemPromptHash && + trimOptional(binding.extraSystemPromptHash) !== currentExtraSystemPromptHash + ) { + return { invalidatedReason: "system-prompt" }; + } + if (binding?.mcpConfigHash && trimOptional(binding.mcpConfigHash) !== currentMcpConfigHash) { + return { invalidatedReason: "mcp" }; + } + return { sessionId }; +} diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 12b85623279..23bae15ada1 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -6,7 +6,7 @@ import { updateSessionStore, } from "../../config/sessions.js"; import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js"; -import { setCliSessionId } from "../cli-session.js"; +import { setCliSessionBinding, setCliSessionId } from "../cli-session.js"; import { resolveContextTokensForModel } from "../context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { isCliProvider } from "../model-selection.js"; @@ -74,9 +74,14 @@ export async function updateSessionStoreAfterAgentRun(params: { model: modelUsed, }); if (isCliProvider(providerUsed, cfg)) { - const cliSessionId = result.meta.agentMeta?.sessionId?.trim(); - if (cliSessionId) { - setCliSessionId(next, providerUsed, cliSessionId); + const cliSessionBinding = result.meta.agentMeta?.cliSessionBinding; + if (cliSessionBinding?.sessionId?.trim()) { + setCliSessionBinding(next, providerUsed, cliSessionBinding); + } else { + const cliSessionId = result.meta.agentMeta?.sessionId?.trim(); + if (cliSessionId) { + setCliSessionId(next, providerUsed, cliSessionId); + } } } next.abortedLastRun = result.meta.aborted ?? false; @@ -87,7 +92,7 @@ export async function updateSessionStoreAfterAgentRun(params: { const input = usage.input ?? 0; const output = usage.output ?? 0; const totalTokens = deriveSessionTotalTokens({ - usage, + usage: promptTokens ? undefined : usage, contextTokens, promptTokens, }); diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 5ef74aa70e3..61b3a7af930 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -1,10 +1,11 @@ -import type { SessionSystemPromptReport } from "../../config/sessions/types.js"; +import type { CliSessionBinding, SessionSystemPromptReport } from "../../config/sessions/types.js"; import type { MessagingToolSend } from "../pi-embedded-messaging.js"; export type EmbeddedPiAgentMeta = { sessionId: string; provider: string; model: string; + cliSessionBinding?: CliSessionBinding; compactionCount?: number; promptTokens?: number; usage?: { diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 75377bc67b5..e6f46425d82 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -6,7 +6,7 @@ import { } from "openclaw/plugin-sdk/reply-payload"; import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { runCliAgent } from "../../agents/cli-runner.js"; -import { getCliSessionId } from "../../agents/cli-session.js"; +import { getCliSessionBinding } from "../../agents/cli-session.js"; import { runWithModelFallback, isFallbackSummaryError } from "../../agents/model-fallback.js"; import { isCliProvider } from "../../agents/model-selection.js"; import { @@ -277,7 +277,14 @@ export async function runAgentTurnWithFallback(params: { startedAt, }, }); - const cliSessionId = getCliSessionId(params.getActiveSessionEntry(), provider); + const cliSessionBinding = getCliSessionBinding( + params.getActiveSessionEntry(), + provider, + ); + const authProfileId = + provider === params.followupRun.run.provider + ? params.followupRun.run.authProfileId + : undefined; return (async () => { let lifecycleTerminalEmitted = false; try { @@ -296,7 +303,9 @@ export async function runAgentTurnWithFallback(params: { runId, extraSystemPrompt: params.followupRun.run.extraSystemPrompt, ownerNumbers: params.followupRun.run.ownerNumbers, - cliSessionId, + cliSessionId: cliSessionBinding?.sessionId, + cliSessionBinding, + authProfileId, bootstrapPromptWarningSignaturesSeen, bootstrapPromptWarningSignature: bootstrapPromptWarningSignaturesSeen[ diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 62e1562c292..ebf97ee8794 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -516,6 +516,9 @@ export async function runReplyAgent(params: { const cliSessionId = isCliProvider(providerUsed, cfg) ? runResult.meta?.agentMeta?.sessionId?.trim() : undefined; + const cliSessionBinding = isCliProvider(providerUsed, cfg) + ? runResult.meta?.agentMeta?.cliSessionBinding + : undefined; const contextTokensUsed = agentCfgContextTokens ?? lookupContextTokens(modelUsed) ?? @@ -534,6 +537,8 @@ export async function runReplyAgent(params: { contextTokensUsed, systemPromptReport: runResult.meta?.systemPromptReport, cliSessionId, + cliSessionBinding, + usageIsContextSnapshot: isCliProvider(providerUsed, cfg), }); // Drain any late tool/block deliveries before deciding there's "nothing to send". diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index f11ceae5e50..7d1ce1a4f4c 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -8,6 +8,7 @@ import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-bu import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; +import { isCliProvider } from "../../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; @@ -292,6 +293,11 @@ export function createFollowupRunner(params: { providerUsed: fallbackProvider, contextTokensUsed, systemPromptReport: runResult.meta?.systemPromptReport, + cliSessionBinding: runResult.meta?.agentMeta?.cliSessionBinding, + usageIsContextSnapshot: isCliProvider( + fallbackProvider ?? queued.run.provider, + queued.run.config, + ), logLabel: "followup", }); } diff --git a/src/auto-reply/reply/session-usage.ts b/src/auto-reply/reply/session-usage.ts index d3594fcdf42..ddd768b7eb1 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -1,4 +1,4 @@ -import { setCliSessionId } from "../../agents/cli-session.js"; +import { setCliSessionBinding, setCliSessionId } from "../../agents/cli-session.js"; import { deriveSessionTotalTokens, hasNonzeroUsage, @@ -18,17 +18,29 @@ function applyCliSessionIdToSessionPatch( params: { providerUsed?: string; cliSessionId?: string; + cliSessionBinding?: import("../../config/sessions.js").CliSessionBinding; }, entry: SessionEntry, patch: Partial, ): Partial { const cliProvider = params.providerUsed ?? entry.modelProvider; + if (params.cliSessionBinding && cliProvider) { + const nextEntry = { ...entry, ...patch }; + setCliSessionBinding(nextEntry, cliProvider, params.cliSessionBinding); + return { + ...patch, + cliSessionIds: nextEntry.cliSessionIds, + cliSessionBindings: nextEntry.cliSessionBindings, + claudeCliSessionId: nextEntry.claudeCliSessionId, + }; + } if (params.cliSessionId && cliProvider) { const nextEntry = { ...entry, ...patch }; setCliSessionId(nextEntry, cliProvider, params.cliSessionId); return { ...patch, cliSessionIds: nextEntry.cliSessionIds, + cliSessionBindings: nextEntry.cliSessionBindings, claudeCliSessionId: nextEntry.claudeCliSessionId, }; } @@ -72,8 +84,10 @@ export async function persistSessionUsageUpdate(params: { providerUsed?: string; contextTokensUsed?: number; promptTokens?: number; + usageIsContextSnapshot?: boolean; systemPromptReport?: SessionSystemPromptReport; cliSessionId?: string; + cliSessionBinding?: import("../../config/sessions.js").CliSessionBinding; logLabel?: string; }): Promise { const { storePath, sessionKey } = params; @@ -88,7 +102,8 @@ export async function persistSessionUsageUpdate(params: { typeof params.promptTokens === "number" && Number.isFinite(params.promptTokens) && params.promptTokens > 0; - const hasFreshContextSnapshot = Boolean(params.lastCallUsage) || hasPromptTokens; + const hasFreshContextSnapshot = + Boolean(params.lastCallUsage) || hasPromptTokens || params.usageIsContextSnapshot === true; if (hasUsage || hasFreshContextSnapshot) { try { @@ -101,7 +116,9 @@ export async function persistSessionUsageUpdate(params: { // `usage.input` sums input tokens from every API call in the run // (tool-use loops, compaction retries), overstating actual context. // `lastCallUsage` reflects only the final API call — the true context. - const usageForContext = params.lastCallUsage ?? (hasUsage ? params.usage : undefined); + const usageForContext = + params.lastCallUsage ?? + (params.usageIsContextSnapshot === true ? params.usage : undefined); const totalTokens = hasFreshContextSnapshot ? deriveSessionTotalTokens({ usage: usageForContext, diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index cd8f4184ade..941aae5e4ab 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1341,6 +1341,14 @@ describe("initSessionState preserves behavior overrides across /new and /reset", authProfileOverride: "20251001", authProfileOverrideSource: "user", authProfileOverrideCompactionCount: 2, + cliSessionIds: { "claude-cli": "cli-session-123" }, + cliSessionBindings: { + "claude-cli": { + sessionId: "cli-session-123", + authProfileId: "anthropic:default", + }, + }, + claudeCliSessionId: "cli-session-123", } as const; const cases = [ { @@ -1384,7 +1392,16 @@ describe("initSessionState preserves behavior overrides across /new and /reset", expect(result.isNewSession, testCase.name).toBe(true); expect(result.resetTriggered, testCase.name).toBe(true); expect(result.sessionId, testCase.name).not.toBe(existingSessionId); - expect(result.sessionEntry, testCase.name).toMatchObject(overrides); + expect(result.sessionEntry, testCase.name).toMatchObject({ + providerOverride: overrides.providerOverride, + modelOverride: overrides.modelOverride, + authProfileOverride: overrides.authProfileOverride, + authProfileOverrideSource: overrides.authProfileOverrideSource, + authProfileOverrideCompactionCount: overrides.authProfileOverrideCompactionCount, + }); + expect(result.sessionEntry.cliSessionIds).toBeUndefined(); + expect(result.sessionEntry.cliSessionBindings).toBeUndefined(); + expect(result.sessionEntry.claudeCliSessionId).toBeUndefined(); } }); @@ -1656,6 +1673,42 @@ describe("persistSessionUsageUpdate", () => { expect(stored[sessionKey].totalTokensFresh).toBe(true); }); + it("treats CLI usage as a fresh context snapshot when requested", async () => { + const storePath = await createStorePath("openclaw-usage-cli-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now() }, + }); + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: { input: 24_000, output: 2_000, cacheRead: 8_000 }, + usageIsContextSnapshot: true, + providerUsed: "claude-cli", + cliSessionBinding: { + sessionId: "cli-session-1", + authProfileId: "anthropic:default", + extraSystemPromptHash: "prompt-hash", + mcpConfigHash: "mcp-hash", + }, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBe(32_000); + expect(stored[sessionKey].totalTokensFresh).toBe(true); + expect(stored[sessionKey].cliSessionIds?.["claude-cli"]).toBe("cli-session-1"); + expect(stored[sessionKey].cliSessionBindings?.["claude-cli"]).toEqual({ + sessionId: "cli-session-1", + authProfileId: "anthropic:default", + extraSystemPromptHash: "prompt-hash", + mcpConfigHash: "mcp-hash", + }); + }); + it("persists totalTokens from promptTokens when usage is unavailable", async () => { const storePath = await createStorePath("openclaw-usage-"); const sessionKey = "main"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 70ee1cc60c5..ee001262a28 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -544,6 +544,9 @@ export async function initSessionState(params: { sessionEntry.outputTokens = undefined; sessionEntry.estimatedCostUsd = undefined; sessionEntry.contextTokens = undefined; + delete sessionEntry.cliSessionIds; + delete sessionEntry.cliSessionBindings; + delete sessionEntry.claudeCliSessionId; } // Preserve per-session overrides while resetting compaction state on /new. sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry }; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 27acdf2aca7..1e552c6e3e9 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -65,6 +65,13 @@ export type AcpSessionRuntimeOptions = { backendExtras?: Record; }; +export type CliSessionBinding = { + sessionId: string; + authProfileId?: string; + extraSystemPromptHash?: string; + mcpConfigHash?: string; +}; + export type SessionEntry = { /** * Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications). @@ -166,6 +173,7 @@ export type SessionEntry = { memoryFlushCompactionCount?: number; memoryFlushContextHash?: string; cliSessionIds?: Record; + cliSessionBindings?: Record; claudeCliSessionId?: string; label?: string; displayName?: string; diff --git a/src/gateway/http-utils.model-override.test.ts b/src/gateway/http-utils.model-override.test.ts new file mode 100644 index 00000000000..48fe2a08820 --- /dev/null +++ b/src/gateway/http-utils.model-override.test.ts @@ -0,0 +1,50 @@ +import type { IncomingMessage } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const loadConfigMock = vi.fn(); +const loadGatewayModelCatalogMock = vi.fn(); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => loadConfigMock(), +})); + +vi.mock("./server-model-catalog.js", () => ({ + loadGatewayModelCatalog: () => loadGatewayModelCatalogMock(), +})); + +import { resolveOpenAiCompatModelOverride } from "./http-utils.js"; + +function createReq(headers: Record = {}): IncomingMessage { + return { headers } as IncomingMessage; +} + +describe("resolveOpenAiCompatModelOverride", () => { + beforeEach(() => { + loadConfigMock.mockReset().mockReturnValue({ + agents: { + defaults: { + model: { primary: "openai/gpt-5.4" }, + models: { + "openai/gpt-5.4": {}, + }, + }, + }, + } satisfies OpenClawConfig); + loadGatewayModelCatalogMock + .mockReset() + .mockResolvedValue([{ id: "gpt-5.4", name: "GPT 5.4", provider: "openai" }]); + }); + + it("rejects CLI model overrides outside the configured allowlist", async () => { + await expect( + resolveOpenAiCompatModelOverride({ + req: createReq({ "x-openclaw-model": "claude-cli/opus" }), + agentId: "main", + model: "openclaw", + }), + ).resolves.toEqual({ + errorMessage: "Model 'claude-cli/opus' is not allowed for agent 'main'.", + }); + }); +}); diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index ae4568b5736..e5cfe9020a2 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -3,7 +3,6 @@ import type { IncomingMessage } from "node:http"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { buildAllowedModelSet, - isCliProvider, modelKey, parseModelRef, resolveDefaultModelForAgent, @@ -103,11 +102,7 @@ export async function resolveOpenAiCompatModelOverride(params: { agentId: params.agentId, }); const normalized = modelKey(parsed.provider, parsed.model); - if ( - !isCliProvider(parsed.provider, cfg) && - !allowed.allowAny && - !allowed.allowedKeys.has(normalized) - ) { + if (!allowed.allowAny && !allowed.allowedKeys.has(normalized)) { return { errorMessage: `Model '${normalized}' is not allowed for agent '${params.agentId}'.`, }; diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index d2b77e5e027..b0986a493fe 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -1140,10 +1140,8 @@ describe("gateway server sessions", () => { expect(reset.payload?.entry.execAsk).toBe("on-miss"); expect(reset.payload?.entry.execNode).toBe("mac-mini"); expect(reset.payload?.entry.displayName).toBe("Ops Child"); - expect(reset.payload?.entry.cliSessionIds).toEqual({ - "claude-cli": "cli-session-123", - }); - expect(reset.payload?.entry.claudeCliSessionId).toBe("cli-session-123"); + expect(reset.payload?.entry.cliSessionIds).toBeUndefined(); + expect(reset.payload?.entry.claudeCliSessionId).toBeUndefined(); expect(reset.payload?.entry.deliveryContext).toEqual({ channel: "discord", to: "discord:child", @@ -1230,10 +1228,8 @@ describe("gateway server sessions", () => { expect(store["agent:main:subagent:child"]?.execAsk).toBe("on-miss"); expect(store["agent:main:subagent:child"]?.execNode).toBe("mac-mini"); expect(store["agent:main:subagent:child"]?.displayName).toBe("Ops Child"); - expect(store["agent:main:subagent:child"]?.cliSessionIds).toEqual({ - "claude-cli": "cli-session-123", - }); - expect(store["agent:main:subagent:child"]?.claudeCliSessionId).toBe("cli-session-123"); + expect(store["agent:main:subagent:child"]?.cliSessionIds).toBeUndefined(); + expect(store["agent:main:subagent:child"]?.claudeCliSessionId).toBeUndefined(); expect(store["agent:main:subagent:child"]?.deliveryContext).toEqual({ channel: "discord", to: "discord:child", diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index dcc00e372c0..eb4aa1b7519 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -354,8 +354,6 @@ export async function performGatewaySessionReset(params: { space: currentEntry?.space, origin: snapshotSessionOrigin(currentEntry), deliveryContext: currentEntry?.deliveryContext, - cliSessionIds: currentEntry?.cliSessionIds, - claudeCliSessionId: currentEntry?.claudeCliSessionId, lastChannel: currentEntry?.lastChannel, lastTo: currentEntry?.lastTo, lastAccountId: currentEntry?.lastAccountId,