fix: preserve cli sessions across model changes

This commit is contained in:
Peter Steinberger 2026-03-26 20:23:19 +00:00
parent 236e041ef9
commit 12100719b8
No known key found for this signature in database
22 changed files with 613 additions and 81 deletions

View File

@ -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}".`,
);

View File

@ -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"]);
});
});

View File

@ -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");

View File

@ -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 }
: {}),
},
}
: {}),
},
},
};

View File

@ -51,6 +51,7 @@ describe("prepareCliBundleMcpConfig", () => {
mcpServers?: Record<string, { args?: string[] }>;
};
expect(raw.mcpServers?.bundleProbe?.args).toEqual([await fs.realpath(serverPath)]);
expect(prepared.mcpConfigHash).toMatch(/^[0-9a-f]{64}$/);
await prepared.cleanup?.();
} finally {

View File

@ -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<void>;
mcpConfigHash?: string;
};
async function readExternalMcpConfig(configPath: string): Promise<BundleMcpConfig> {
@ -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 });
},

View File

@ -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) {

View File

@ -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();
});
});

View File

@ -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 };
}

View File

@ -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,
});

View File

@ -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?: {

View File

@ -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[

View File

@ -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".

View File

@ -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",
});
}

View File

@ -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<SessionEntry>,
): Partial<SessionEntry> {
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<void> {
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,

View File

@ -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";

View File

@ -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 };

View File

@ -65,6 +65,13 @@ export type AcpSessionRuntimeOptions = {
backendExtras?: Record<string, string>;
};
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<string, string>;
cliSessionBindings?: Record<string, CliSessionBinding>;
claudeCliSessionId?: string;
label?: string;
displayName?: string;

View File

@ -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<string, string> = {}): 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'.",
});
});
});

View File

@ -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}'.`,
};

View File

@ -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",

View File

@ -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,