mirror of https://github.com/openclaw/openclaw.git
fix: preserve cli sessions across model changes
This commit is contained in:
parent
236e041ef9
commit
12100719b8
|
|
@ -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}".`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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[
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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}'.`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue