mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 05:10:44 +00:00
1260 lines
39 KiB
TypeScript
1260 lines
39 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import type { SessionEntry } from "../../config/sessions.js";
|
|
import { loadSessionStore } from "../../config/sessions.js";
|
|
import type { EmbeddedPiRunResult } from "../pi-embedded.js";
|
|
import { clearCliSessionInStore, updateSessionStoreAfterAgentRun } from "./session-store.js";
|
|
import { resolveSession } from "./session.js";
|
|
|
|
vi.mock("../model-selection.js", () => ({
|
|
isCliProvider: (provider: string, cfg?: OpenClawConfig) =>
|
|
Object.hasOwn(cfg?.agents?.defaults?.cliBackends ?? {}, provider),
|
|
normalizeProviderId: (provider: string) => provider.trim().toLowerCase(),
|
|
}));
|
|
|
|
type MockCost = {
|
|
input?: number;
|
|
output?: number;
|
|
};
|
|
|
|
type MockProviderModel = {
|
|
id: string;
|
|
cost?: MockCost;
|
|
};
|
|
|
|
type MockUsageFormatConfig = {
|
|
models?: {
|
|
providers?: Record<string, { models?: MockProviderModel[] }>;
|
|
};
|
|
};
|
|
|
|
vi.mock("../../utils/usage-format.js", () => ({
|
|
estimateUsageCost: (params: { usage?: { input?: number; output?: number }; cost?: MockCost }) => {
|
|
if (!params.usage || !params.cost) {
|
|
return undefined;
|
|
}
|
|
const input = params.usage.input ?? 0;
|
|
const output = params.usage.output ?? 0;
|
|
const costInput = params.cost.input ?? 0;
|
|
const costOutput = params.cost.output ?? 0;
|
|
const total = input * costInput + output * costOutput;
|
|
if (!Number.isFinite(total)) {
|
|
return undefined;
|
|
}
|
|
return total / 1e6;
|
|
},
|
|
resolveModelCostConfig: (params: { provider?: string; model?: string; config?: unknown }) => {
|
|
const providers = (params.config as MockUsageFormatConfig | undefined)?.models?.providers;
|
|
if (!providers) {
|
|
return undefined;
|
|
}
|
|
const model = providers[params.provider ?? ""]?.models?.find(
|
|
(entry) => entry.id === params.model,
|
|
);
|
|
if (!model) {
|
|
return undefined;
|
|
}
|
|
return model.cost;
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../config/sessions.js", async () => {
|
|
const fsSync = await import("node:fs");
|
|
const fs = await import("node:fs/promises");
|
|
const path = await import("node:path");
|
|
const readStore = async (storePath: string): Promise<Record<string, SessionEntry>> => {
|
|
try {
|
|
return JSON.parse(await fs.readFile(storePath, "utf8")) as Record<string, SessionEntry>;
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
const writeStore = async (storePath: string, store: Record<string, SessionEntry>) => {
|
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
|
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf8");
|
|
};
|
|
return {
|
|
mergeSessionEntry: (existing: SessionEntry | undefined, patch: Partial<SessionEntry>) => ({
|
|
...existing,
|
|
...patch,
|
|
sessionId: patch.sessionId ?? existing?.sessionId ?? "mock-session",
|
|
updatedAt: Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, Date.now()),
|
|
}),
|
|
setSessionRuntimeModel: (entry: SessionEntry, runtime: { provider: string; model: string }) => {
|
|
entry.modelProvider = runtime.provider;
|
|
entry.model = runtime.model;
|
|
return true;
|
|
},
|
|
updateSessionStore: async <T>(
|
|
storePath: string,
|
|
mutator: (store: Record<string, SessionEntry>) => Promise<T> | T,
|
|
) => {
|
|
const store = await readStore(storePath);
|
|
const previousAcpByKey = new Map(
|
|
Object.entries(store)
|
|
.filter(
|
|
(entry): entry is [string, SessionEntry & { acp: NonNullable<SessionEntry["acp"]> }] =>
|
|
Boolean(entry[1]?.acp),
|
|
)
|
|
.map(([key, entry]) => [key, entry.acp]),
|
|
);
|
|
const result = await mutator(store);
|
|
for (const [key, acp] of previousAcpByKey) {
|
|
const next = store[key];
|
|
if (next && !next.acp) {
|
|
next.acp = acp;
|
|
}
|
|
}
|
|
await writeStore(storePath, store);
|
|
return result;
|
|
},
|
|
loadSessionStore: (storePath: string) => {
|
|
try {
|
|
return JSON.parse(fsSync.readFileSync(storePath, "utf8")) as Record<string, SessionEntry>;
|
|
} catch {
|
|
return {};
|
|
}
|
|
},
|
|
};
|
|
});
|
|
|
|
function acpMeta() {
|
|
return {
|
|
backend: "acpx",
|
|
agent: "codex",
|
|
runtimeSessionName: "runtime-1",
|
|
mode: "persistent" as const,
|
|
state: "idle" as const,
|
|
lastActivityAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
async function withTempSessionStore<T>(
|
|
run: (params: { dir: string; storePath: string }) => Promise<T>,
|
|
): Promise<T> {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-"));
|
|
try {
|
|
return await run({ dir, storePath: path.join(dir, "sessions.json") });
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
describe("updateSessionStoreAfterAgentRun", () => {
|
|
it("persists the selected embedded harness id on the session", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-harness-pin";
|
|
const sessionId = "test-harness-pin-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
const result: EmbeddedPiRunResult = {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
agentHarnessId: "codex",
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
result,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.agentHarnessId).toBe("codex");
|
|
expect(loadSessionStore(storePath)[sessionKey]?.agentHarnessId).toBe("codex");
|
|
});
|
|
});
|
|
|
|
it("uses the runtime context budget from agent metadata instead of cold fallback", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-runtime-context";
|
|
const sessionId = "test-runtime-context-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
const result: EmbeddedPiRunResult = {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai-codex",
|
|
model: "gpt-5.5",
|
|
contextTokens: 400_000,
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai-codex",
|
|
defaultModel: "gpt-5.5",
|
|
result,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.contextTokens).toBe(400_000);
|
|
expect(loadSessionStore(storePath)[sessionKey]?.contextTokens).toBe(400_000);
|
|
});
|
|
});
|
|
|
|
it("clears the embedded harness pin after a CLI run", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": {
|
|
command: "claude",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-harness-pin-cli";
|
|
const sessionId = "test-harness-pin-cli-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
agentHarnessId: "codex",
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
const result: EmbeddedPiRunResult = {
|
|
meta: {
|
|
durationMs: 1,
|
|
executionTrace: { runner: "cli" },
|
|
agentMeta: {
|
|
sessionId: "cli-session-123",
|
|
provider: "claude-cli",
|
|
model: "claude-sonnet-4-6",
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "claude-cli",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
result,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.agentHarnessId).toBeUndefined();
|
|
expect(loadSessionStore(storePath)[sessionKey]?.agentHarnessId).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("persists claude-cli session bindings when the backend is configured", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": {
|
|
command: "claude",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-claude-cli";
|
|
const sessionId = "test-openclaw-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
const result: EmbeddedPiRunResult = {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId: "cli-session-123",
|
|
provider: "claude-cli",
|
|
model: "claude-sonnet-4-6",
|
|
cliSessionBinding: {
|
|
sessionId: "cli-session-123",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
contextTokensOverride: 200_000,
|
|
defaultProvider: "claude-cli",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
result,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({
|
|
sessionId: "cli-session-123",
|
|
});
|
|
expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("cli-session-123");
|
|
expect(sessionStore[sessionKey]?.claudeCliSessionId).toBe("cli-session-123");
|
|
|
|
const persisted = loadSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({
|
|
sessionId: "cli-session-123",
|
|
});
|
|
expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("cli-session-123");
|
|
expect(persisted[sessionKey]?.claudeCliSessionId).toBe("cli-session-123");
|
|
});
|
|
});
|
|
|
|
it("preserves ACP metadata when caller has a stale session snapshot", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:codex:acp:test-acp-preserve";
|
|
const sessionId = "test-acp-session";
|
|
|
|
const existing: SessionEntry = {
|
|
sessionId,
|
|
updatedAt: Date.now(),
|
|
acp: acpMeta(),
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: existing }, null, 2), "utf8");
|
|
|
|
const staleInMemory: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: Date.now(),
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg: {} as never,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore: staleInMemory,
|
|
contextTokensOverride: 200_000,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
result: {
|
|
payloads: [],
|
|
meta: {
|
|
aborted: false,
|
|
agentMeta: {
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
},
|
|
},
|
|
} as never,
|
|
});
|
|
|
|
const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey];
|
|
expect(persisted?.acp).toBeDefined();
|
|
expect(staleInMemory[sessionKey]?.acp).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it("preserves terminal lifecycle state when caller has a stale running snapshot", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-lifecycle-preserve";
|
|
const sessionId = "test-lifecycle-preserve-session";
|
|
const terminalEntry: SessionEntry = {
|
|
sessionId,
|
|
updatedAt: 2_000,
|
|
status: "done",
|
|
startedAt: 1_000,
|
|
endedAt: 1_900,
|
|
runtimeMs: 900,
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: terminalEntry }, null, 2));
|
|
|
|
const staleInMemory: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1_100,
|
|
status: "running",
|
|
startedAt: 1_000,
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore: staleInMemory,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
result: {
|
|
payloads: [],
|
|
meta: {
|
|
aborted: false,
|
|
agentMeta: {
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
},
|
|
},
|
|
} as never,
|
|
});
|
|
|
|
const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey];
|
|
expect(persisted).toMatchObject({
|
|
status: "done",
|
|
startedAt: 1_000,
|
|
endedAt: 1_900,
|
|
runtimeMs: 900,
|
|
modelProvider: "openai",
|
|
model: "gpt-5.4",
|
|
});
|
|
expect(staleInMemory[sessionKey]?.status).toBe("done");
|
|
});
|
|
});
|
|
|
|
it("persists latest systemPromptReport for downstream warning dedupe", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:codex:report:test-system-prompt-report";
|
|
const sessionId = "test-system-prompt-report-session";
|
|
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: Date.now(),
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf8");
|
|
|
|
const report = {
|
|
source: "run" as const,
|
|
generatedAt: Date.now(),
|
|
bootstrapTruncation: {
|
|
warningMode: "once" as const,
|
|
warningSignaturesSeen: ["sig-a", "sig-b"],
|
|
},
|
|
systemPrompt: {
|
|
chars: 1,
|
|
projectContextChars: 1,
|
|
nonProjectContextChars: 0,
|
|
},
|
|
injectedWorkspaceFiles: [],
|
|
skills: { promptChars: 0, entries: [] },
|
|
tools: { listChars: 0, schemaChars: 0, entries: [] },
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg: {} as never,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
contextTokensOverride: 200_000,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
result: {
|
|
payloads: [],
|
|
meta: {
|
|
agentMeta: {
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
},
|
|
systemPromptReport: report,
|
|
},
|
|
} as never,
|
|
});
|
|
|
|
const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey];
|
|
expect(persisted?.systemPromptReport?.bootstrapTruncation?.warningSignaturesSeen).toEqual([
|
|
"sig-a",
|
|
"sig-b",
|
|
]);
|
|
expect(sessionStore[sessionKey]?.systemPromptReport?.bootstrapTruncation?.warningMode).toBe(
|
|
"once",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("stores and reloads the runtime model for explicit session-id-only runs", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
session: {
|
|
store: storePath,
|
|
mainKey: "main",
|
|
},
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
},
|
|
},
|
|
} as never;
|
|
|
|
const first = resolveSession({
|
|
cfg,
|
|
sessionId: "explicit-session-123",
|
|
});
|
|
|
|
expect(first.sessionKey).toBe("agent:main:explicit:explicit-session-123");
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId: first.sessionId,
|
|
sessionKey: first.sessionKey!,
|
|
storePath: first.storePath,
|
|
sessionStore: first.sessionStore!,
|
|
contextTokensOverride: 200_000,
|
|
defaultProvider: "claude-cli",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
result: {
|
|
payloads: [],
|
|
meta: {
|
|
agentMeta: {
|
|
provider: "claude-cli",
|
|
model: "claude-sonnet-4-6",
|
|
sessionId: "claude-cli-session-1",
|
|
cliSessionBinding: {
|
|
sessionId: "claude-cli-session-1",
|
|
authEpoch: "auth-epoch-1",
|
|
},
|
|
},
|
|
},
|
|
} as never,
|
|
});
|
|
|
|
const second = resolveSession({
|
|
cfg,
|
|
sessionId: "explicit-session-123",
|
|
});
|
|
|
|
expect(second.sessionKey).toBe(first.sessionKey);
|
|
expect(second.sessionEntry?.cliSessionBindings?.["claude-cli"]).toEqual({
|
|
sessionId: "claude-cli-session-1",
|
|
authEpoch: "auth-epoch-1",
|
|
});
|
|
|
|
const persisted = loadSessionStore(storePath, { skipCache: true })[first.sessionKey!];
|
|
expect(persisted?.cliSessionBindings?.["claude-cli"]).toEqual({
|
|
sessionId: "claude-cli-session-1",
|
|
authEpoch: "auth-epoch-1",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("preserves previous totalTokens when provider returns no usage data (#67667)", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-no-usage";
|
|
const sessionId = "test-session";
|
|
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
totalTokens: 21225,
|
|
totalTokensFresh: true,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
const result: EmbeddedPiRunResult = {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "minimax",
|
|
model: "MiniMax-M2.7",
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "minimax",
|
|
defaultModel: "MiniMax-M2.7",
|
|
result,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(21225);
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(false);
|
|
|
|
const persisted = loadSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.totalTokens).toBe(21225);
|
|
expect(persisted[sessionKey]?.totalTokensFresh).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("does not treat CLI cumulative usage as a fresh context snapshot", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
cliBackends: {
|
|
"claude-cli": { command: "claude" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-cli-cumulative-usage";
|
|
const sessionId = "test-cli-cumulative-usage-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
totalTokens: 95_000,
|
|
totalTokensFresh: true,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
contextTokensOverride: 1_000_000,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "claude-cli",
|
|
defaultModel: "claude-opus-4-7",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
executionTrace: { runner: "cli" },
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "claude-cli",
|
|
model: "claude-opus-4-7",
|
|
usage: {
|
|
input: 3_800_000,
|
|
output: 20_000,
|
|
total: 3_820_000,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.inputTokens).toBe(3_800_000);
|
|
expect(sessionStore[sessionKey]?.outputTokens).toBe(20_000);
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("persists compaction tokensAfter when provider usage is unavailable", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-compaction-tokens-after";
|
|
const sessionId = "test-compaction-tokens-after-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
const result: EmbeddedPiRunResult = {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "minimax",
|
|
model: "MiniMax-M2.7",
|
|
compactionCount: 1,
|
|
compactionTokensAfter: 21_225,
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "minimax",
|
|
defaultModel: "MiniMax-M2.7",
|
|
result,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(21_225);
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(true);
|
|
expect(sessionStore[sessionKey]?.compactionCount).toBe(1);
|
|
|
|
const persisted = loadSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.totalTokens).toBe(21_225);
|
|
expect(persisted[sessionKey]?.totalTokensFresh).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("ignores non-finite compaction tokensAfter values", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-compaction-tokens-after-invalid";
|
|
const sessionId = "test-compaction-tokens-after-invalid-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
totalTokens: 12_000,
|
|
totalTokensFresh: true,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "minimax",
|
|
defaultModel: "MiniMax-M2.7",
|
|
result: {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "minimax",
|
|
model: "MiniMax-M2.7",
|
|
compactionCount: 1,
|
|
compactionTokensAfter: Number.POSITIVE_INFINITY,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(12_000);
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("snapshots cost instead of accumulating (fixes #69347)", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
models: [
|
|
{
|
|
id: "gpt-4",
|
|
cost: {
|
|
input: 10,
|
|
output: 30,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-cost-snapshot";
|
|
const sessionId = "test-cost-snapshot-session";
|
|
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
// Simulate a run with 10k input + 5k output tokens
|
|
// Cost = (10000 * 10 + 5000 * 30) / 1e6 = $0.25
|
|
const result: EmbeddedPiRunResult = {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-4",
|
|
usage: {
|
|
input: 10000,
|
|
output: 5000,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-4",
|
|
result,
|
|
});
|
|
|
|
// First run: cost should be $0.25
|
|
expect(sessionStore[sessionKey]?.estimatedCostUsd).toBeCloseTo(0.25, 4);
|
|
|
|
// Simulate a second persist with the SAME cumulative usage (e.g., from a heartbeat or
|
|
// redundant persist). Before the fix, this would double the cost.
|
|
// After the fix, cost should remain the same because it's snapshotted.
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-4",
|
|
result, // Same usage again
|
|
});
|
|
|
|
// After second persist with same usage, cost should STILL be $0.25 (not $0.50)
|
|
expect(sessionStore[sessionKey]?.estimatedCostUsd).toBeCloseTo(0.25, 4);
|
|
|
|
const persisted = loadSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.estimatedCostUsd).toBeCloseTo(0.25, 4);
|
|
});
|
|
});
|
|
|
|
it("preserves lastInteractionAt for non-interactive system runs", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-system-run";
|
|
const sessionId = "test-system-run-session";
|
|
const lastInteractionAt = Date.now() - 60 * 60_000;
|
|
const sessionStartedAt = Date.now() - 2 * 60 * 60_000;
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: Date.now() - 10_000,
|
|
sessionStartedAt,
|
|
lastInteractionAt,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
},
|
|
},
|
|
},
|
|
touchInteraction: false,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.lastInteractionAt).toBe(lastInteractionAt);
|
|
expect(sessionStore[sessionKey]?.sessionStartedAt).toBe(sessionStartedAt);
|
|
expect(sessionStore[sessionKey]?.updatedAt).toBeGreaterThan(lastInteractionAt);
|
|
});
|
|
});
|
|
|
|
it("advances lastInteractionAt for interactive runs", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-user-run";
|
|
const sessionId = "test-user-run-session";
|
|
const lastInteractionAt = Date.now() - 60 * 60_000;
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: Date.now() - 10_000,
|
|
lastInteractionAt,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.lastInteractionAt).toBeGreaterThan(lastInteractionAt);
|
|
});
|
|
});
|
|
|
|
it("preserves runtime model and contextTokens when preserveRuntimeModel is true (heartbeat bleed fix)", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-heartbeat-bleed";
|
|
const sessionId = "test-heartbeat-bleed-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
modelProvider: "anthropic",
|
|
model: "claude-opus-4-6",
|
|
contextTokens: 1_000_000,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
// Heartbeat turn uses a different model
|
|
const result: EmbeddedPiRunResult = {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "ollama",
|
|
model: "llama3.2:1b",
|
|
contextTokens: 128_000,
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-opus-4-6",
|
|
result,
|
|
preserveRuntimeModel: true,
|
|
});
|
|
|
|
// Runtime model and contextTokens should be preserved from the original entry
|
|
expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6");
|
|
expect(sessionStore[sessionKey]?.modelProvider).toBe("anthropic");
|
|
expect(sessionStore[sessionKey]?.contextTokens).toBe(1_000_000);
|
|
|
|
const persisted = loadSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.model).toBe("claude-opus-4-6");
|
|
expect(persisted[sessionKey]?.modelProvider).toBe("anthropic");
|
|
expect(persisted[sessionKey]?.contextTokens).toBe(1_000_000);
|
|
});
|
|
});
|
|
|
|
it("leaves contextTokens unset when entry has prior model but no contextTokens (heartbeat bleed guard)", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-heartbeat-no-context-tokens";
|
|
const sessionId = "test-heartbeat-no-context-tokens-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
modelProvider: "anthropic",
|
|
model: "claude-opus-4-6",
|
|
// contextTokens intentionally missing — older session without cached context
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
// Heartbeat turn uses a different, smaller model
|
|
const result: EmbeddedPiRunResult = {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "ollama",
|
|
model: "llama3.2:1b",
|
|
contextTokens: 128_000,
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-opus-4-6",
|
|
result,
|
|
preserveRuntimeModel: true,
|
|
});
|
|
|
|
// Runtime model should be preserved
|
|
expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6");
|
|
expect(sessionStore[sessionKey]?.modelProvider).toBe("anthropic");
|
|
// contextTokens should NOT bleed from the heartbeat run's smaller window
|
|
expect(sessionStore[sessionKey]?.contextTokens).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("does not set runtime model when preserveRuntimeModel is true and entry has no prior runtime model", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-heartbeat-new-session";
|
|
const sessionId = "test-heartbeat-new-session-id";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
const result: EmbeddedPiRunResult = {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "ollama",
|
|
model: "llama3.2:1b",
|
|
contextTokens: 128_000,
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "ollama",
|
|
defaultModel: "llama3.2:1b",
|
|
result,
|
|
preserveRuntimeModel: true,
|
|
});
|
|
|
|
// Heartbeat should NOT establish initial model state on an empty session
|
|
expect(sessionStore[sessionKey]?.model).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.modelProvider).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.contextTokens).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("preserves model without borrowing heartbeat provider when entry has model but no modelProvider", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-heartbeat-model-no-provider";
|
|
const sessionId = "test-heartbeat-model-no-provider-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
model: "claude-opus-4-6",
|
|
// modelProvider intentionally missing
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
// Heartbeat turn uses a different provider
|
|
const result: EmbeddedPiRunResult = {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "ollama",
|
|
model: "llama3.2:1b",
|
|
contextTokens: 128_000,
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-opus-4-6",
|
|
result,
|
|
preserveRuntimeModel: true,
|
|
});
|
|
|
|
// Model preserved, provider NOT borrowed from heartbeat
|
|
expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6");
|
|
expect(sessionStore[sessionKey]?.modelProvider).toBeUndefined();
|
|
|
|
const persisted = loadSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.model).toBe("claude-opus-4-6");
|
|
expect(persisted[sessionKey]?.modelProvider).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("overwrites runtime model when preserveRuntimeModel is false (default behavior)", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-normal-overwrite";
|
|
const sessionId = "test-normal-overwrite-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
modelProvider: "anthropic",
|
|
model: "claude-opus-4-6",
|
|
contextTokens: 1_000_000,
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
|
|
|
|
const result: EmbeddedPiRunResult = {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
contextTokens: 400_000,
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
result,
|
|
});
|
|
|
|
// Normal turn: runtime model is updated
|
|
expect(sessionStore[sessionKey]?.model).toBe("gpt-5.4");
|
|
expect(sessionStore[sessionKey]?.modelProvider).toBe("openai");
|
|
expect(sessionStore[sessionKey]?.contextTokens).toBe(400_000);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("clearCliSessionInStore", () => {
|
|
it("persists cleared Claude CLI bindings through session-store merge", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-clear-claude-cli";
|
|
const entry: SessionEntry = {
|
|
sessionId: "openclaw-session-1",
|
|
updatedAt: 1,
|
|
cliSessionBindings: {
|
|
"claude-cli": {
|
|
sessionId: "claude-session-1",
|
|
authEpoch: "epoch-1",
|
|
},
|
|
"codex-cli": {
|
|
sessionId: "codex-session-1",
|
|
},
|
|
},
|
|
cliSessionIds: {
|
|
"claude-cli": "claude-session-1",
|
|
"codex-cli": "codex-session-1",
|
|
},
|
|
claudeCliSessionId: "claude-session-1",
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf8");
|
|
|
|
const cleared = await clearCliSessionInStore({
|
|
provider: "claude-cli",
|
|
sessionKey,
|
|
sessionStore,
|
|
storePath,
|
|
});
|
|
|
|
expect(cleared?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
|
expect(cleared?.cliSessionBindings?.["codex-cli"]).toEqual({
|
|
sessionId: "codex-session-1",
|
|
});
|
|
expect(cleared?.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
|
expect(cleared?.cliSessionIds?.["codex-cli"]).toBe("codex-session-1");
|
|
expect(cleared?.claudeCliSessionId).toBeUndefined();
|
|
expect(sessionStore[sessionKey]).toEqual(cleared);
|
|
|
|
const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey];
|
|
expect(persisted?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
|
expect(persisted?.cliSessionBindings?.["codex-cli"]).toEqual({
|
|
sessionId: "codex-session-1",
|
|
});
|
|
expect(persisted?.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
|
expect(persisted?.cliSessionIds?.["codex-cli"]).toBe("codex-session-1");
|
|
expect(persisted?.claudeCliSessionId).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("leaves the caller snapshot intact when the session entry is missing", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const existingKey = "agent:main:explicit:existing";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[existingKey]: {
|
|
sessionId: "openclaw-session-1",
|
|
updatedAt: 1,
|
|
claudeCliSessionId: "claude-session-1",
|
|
},
|
|
};
|
|
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf8");
|
|
|
|
const cleared = await clearCliSessionInStore({
|
|
provider: "claude-cli",
|
|
sessionKey: "agent:main:explicit:missing",
|
|
sessionStore,
|
|
storePath,
|
|
});
|
|
|
|
expect(cleared).toBeUndefined();
|
|
expect(sessionStore[existingKey]?.claudeCliSessionId).toBe("claude-session-1");
|
|
expect(
|
|
loadSessionStore(storePath, { skipCache: true })[existingKey]?.claudeCliSessionId,
|
|
).toBe("claude-session-1");
|
|
});
|
|
});
|
|
});
|