mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
3264 lines
105 KiB
TypeScript
3264 lines
105 KiB
TypeScript
// Covers command-session store updates after agent runs, CLI compaction, and
|
|
// runtime metadata persistence.
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import type { InternalSessionEntry as SessionEntry } from "../../config/sessions.js";
|
|
import {
|
|
listSessionEntriesCore,
|
|
loadSessionEntry,
|
|
replaceSessionEntry,
|
|
} from "../../config/sessions/session-accessor.js";
|
|
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
|
|
import type { EmbeddedAgentRunResult } from "../embedded-agent.js";
|
|
import {
|
|
clearCliSessionInStore,
|
|
consumeCliSessionForkInStore,
|
|
persistCliSessionForkSuccessorInStore,
|
|
restoreCliSessionForkInStore,
|
|
recordCliCompactionInStore,
|
|
updateSessionStoreAfterAgentRun as updateSessionStoreAfterAgentRunBase,
|
|
} from "./session-store.js";
|
|
import { resolveSession } from "./session.js";
|
|
|
|
vi.mock("../model-selection.js", () => ({
|
|
isCliProvider: (provider: string, _cfg?: OpenClawConfig) =>
|
|
["claude-cli", "codex-cli", "google-gemini-cli"].includes(provider.trim().toLowerCase()),
|
|
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;
|
|
agentDir?: string;
|
|
}) => {
|
|
const agents = (params.config as OpenClawConfig | undefined)?.agents?.list ?? [];
|
|
if (agents.length > 1 && !params.agentDir) {
|
|
throw new Error("multi-agent cost resolution requires an explicit agent directory");
|
|
}
|
|
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;
|
|
},
|
|
}));
|
|
|
|
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 {
|
|
closeOpenClawAgentDatabasesForTest();
|
|
// SQLite teardown can race fixture removal on loaded CI hosts. Keep the
|
|
// retries bounded so persistent cleanup failures still surface.
|
|
await fs.rm(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 25 });
|
|
}
|
|
}
|
|
|
|
async function seedSessionStore(
|
|
storePath: string,
|
|
entries: Record<string, SessionEntry>,
|
|
): Promise<void> {
|
|
for (const [sessionKey, entry] of Object.entries(entries)) {
|
|
await replaceSessionEntry({ storePath, sessionKey }, entry);
|
|
}
|
|
}
|
|
|
|
function loadPersistedSessionStore(storePath: string): Record<string, SessionEntry> {
|
|
return Object.fromEntries(
|
|
listSessionEntriesCore({ storePath }).map(({ sessionKey, entry }) => [sessionKey, entry]),
|
|
);
|
|
}
|
|
|
|
function loadPersistedSessionEntry(
|
|
storePath: string,
|
|
sessionKey: string,
|
|
): SessionEntry | undefined {
|
|
return loadSessionEntry({ storePath, sessionKey }) ?? undefined;
|
|
}
|
|
|
|
afterEach(() => {
|
|
closeOpenClawAgentDatabasesForTest();
|
|
});
|
|
|
|
type SessionStoreUpdateParams = Parameters<typeof updateSessionStoreAfterAgentRunBase>[0];
|
|
|
|
async function updateSessionStoreAfterAgentRun(
|
|
params: Omit<SessionStoreUpdateParams, "agentDir"> & { agentDir?: string },
|
|
) {
|
|
await updateSessionStoreAfterAgentRunBase({
|
|
...params,
|
|
agentDir: params.agentDir ?? "/tmp/openclaw-session-store-test-agent",
|
|
});
|
|
}
|
|
|
|
describe("updateSessionStoreAfterAgentRun", () => {
|
|
it("uses the prepared agent directory for multi-agent cost accounting", async () => {
|
|
await withTempSessionStore(async ({ dir, storePath }) => {
|
|
const sessionKey = "agent:marie:dashboard:cost-accounting";
|
|
const sessionId = "cost-accounting-session";
|
|
const sessionStore: Record<string, SessionEntry> = {};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg: {
|
|
agents: { list: [{ id: "main" }, { id: "marie" }] },
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
baseUrl: "https://api.openai.com/v1",
|
|
models: [
|
|
{
|
|
id: "gpt-5.5",
|
|
name: "GPT-5.5",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 2, output: 4, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128_000,
|
|
maxTokens: 8_192,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig,
|
|
agentDir: path.join(dir, "agents", "marie", "agent"),
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-5.5",
|
|
usage: { input: 1_000_000, output: 1_000_000 },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.estimatedCostUsd).toBe(6);
|
|
});
|
|
});
|
|
|
|
it("clears the durable replay-safe recovery guard after the recovery run terminates", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:restart-recovery";
|
|
const sessionId = "restart-recovery-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
restartRecoveryForceSafeTools: true,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg: {} as OpenClawConfig,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
clearRestartRecoveryForceSafeTools: true,
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: { sessionId, provider: "openai", model: "gpt-5.5" },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.restartRecoveryForceSafeTools).toBeUndefined();
|
|
expect(
|
|
loadPersistedSessionEntry(storePath, sessionKey)?.restartRecoveryForceSafeTools,
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("keeps the durable replay-safe recovery guard when the recovery run is aborted", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:aborted-restart-recovery";
|
|
const sessionId = "aborted-restart-recovery-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
restartRecoveryForceSafeTools: true,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg: {} as OpenClawConfig,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
clearRestartRecoveryForceSafeTools: true,
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
aborted: true,
|
|
agentMeta: { sessionId, provider: "openai", model: "gpt-5.5" },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.restartRecoveryForceSafeTools).toBe(true);
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)?.restartRecoveryForceSafeTools).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("preserves a concurrent rename and unpin during final accounting", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-management-race";
|
|
const sessionId = "test-management-race-session";
|
|
const staleEntry: SessionEntry = {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
label: "Old label",
|
|
pinnedAt: 100,
|
|
chatType: "direct",
|
|
elevatedLevel: "full",
|
|
inheritedToolAllow: ["exec"],
|
|
sendPolicy: "allow",
|
|
};
|
|
const sessionStore = { [sessionKey]: staleEntry };
|
|
const concurrentEntry: SessionEntry = {
|
|
...staleEntry,
|
|
chatType: "group",
|
|
label: "Renamed while running",
|
|
sendPolicy: "deny",
|
|
updatedAt: 2,
|
|
};
|
|
delete concurrentEntry.elevatedLevel;
|
|
delete concurrentEntry.inheritedToolAllow;
|
|
delete concurrentEntry.pinnedAt;
|
|
await seedSessionStore(storePath, { [sessionKey]: concurrentEntry });
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg: {} as OpenClawConfig,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: { sessionId, provider: "openai", model: "gpt-5.5" },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]).toMatchObject({
|
|
chatType: "group",
|
|
label: "Renamed while running",
|
|
model: "gpt-5.5",
|
|
sendPolicy: "deny",
|
|
});
|
|
expect(sessionStore[sessionKey]?.elevatedLevel).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.inheritedToolAllow).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.pinnedAt).toBeUndefined();
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)).toEqual(sessionStore[sessionKey]);
|
|
});
|
|
});
|
|
|
|
it("passes resolved maintenance config to the gateway turn store write", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
session: {
|
|
maintenance: {
|
|
mode: "enforce",
|
|
maxEntries: 42,
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-maintenance-config";
|
|
const sessionId = "test-maintenance-config-session";
|
|
const now = Date.now();
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: now,
|
|
},
|
|
...Object.fromEntries(
|
|
Array.from({ length: 45 }, (_, index) => [
|
|
`agent:main:stale:${index}`,
|
|
{
|
|
sessionId: `stale-${index}`,
|
|
updatedAt: now - index - 1,
|
|
} satisfies SessionEntry,
|
|
]),
|
|
),
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
const result: EmbeddedAgentRunResult = {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-5.5",
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
result,
|
|
});
|
|
|
|
const persisted = loadPersistedSessionStore(storePath);
|
|
expect(Object.keys(persisted)).toHaveLength(42);
|
|
expect(persisted[sessionKey]?.sessionId).toBe(sessionId);
|
|
expect(persisted["agent:main:stale:44"]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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,
|
|
sessionFile: path.join(path.dirname(storePath), "legacy-predecessor.jsonl"),
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
const result: EmbeddedAgentRunResult = {
|
|
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(loadPersistedSessionEntry(storePath, sessionKey)?.agentHarnessId).toBe("codex");
|
|
});
|
|
});
|
|
|
|
it("persists rotated compaction session identity and transcript file", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-rotated-session";
|
|
const sessionId = "test-rotated-session-old";
|
|
const rotatedSessionId = "test-rotated-session-new";
|
|
const rotatedSessionFile = path.join(path.dirname(storePath), "rotated-session.jsonl");
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
sessionFile: path.join(path.dirname(storePath), "old-session.jsonl"),
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId: rotatedSessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId: rotatedSessionId,
|
|
sessionFile: rotatedSessionFile,
|
|
provider: "openai",
|
|
model: "gpt-5.5",
|
|
compactionCount: 1,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]).toMatchObject({
|
|
sessionId: rotatedSessionId,
|
|
usageFamilyKey: sessionKey,
|
|
usageFamilySessionIds: [sessionId, rotatedSessionId],
|
|
compactionCount: 1,
|
|
});
|
|
expect(sessionStore[sessionKey]).not.toHaveProperty("sessionFile");
|
|
expect(sessionStore[sessionKey]?.sessionStartedAt).toBeGreaterThan(1);
|
|
});
|
|
});
|
|
|
|
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 seedSessionStore(storePath, sessionStore);
|
|
|
|
const result: EmbeddedAgentRunResult = {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-5.5",
|
|
contextTokens: 400_000,
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
result,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.contextTokens).toBe(400_000);
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)?.contextTokens).toBe(400_000);
|
|
});
|
|
});
|
|
|
|
it("caps configured context override by the resolved runtime model window", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
models: [{ id: "gpt-5.5", contextWindow: 272_000 }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-capped-context-override";
|
|
const sessionId = "test-capped-context-override-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
const result: EmbeddedAgentRunResult = {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-5.5",
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
contextTokensOverride: 1_000_000,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
result,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.contextTokens).toBe(272_000);
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)?.contextTokens).toBe(272_000);
|
|
});
|
|
});
|
|
|
|
it("persists the prepared claude-cli context budget", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-claude-cli-configured-context";
|
|
const sessionId = "test-claude-cli-configured-context-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
contextTokens: 1_048_576,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
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",
|
|
contextTokens: 100_000,
|
|
},
|
|
},
|
|
} as EmbeddedAgentRunResult,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.contextTokens).toBe(100_000);
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)?.contextTokens).toBe(100_000);
|
|
});
|
|
});
|
|
|
|
it("clears the embedded harness pin after a CLI run", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {},
|
|
},
|
|
} 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 seedSessionStore(storePath, sessionStore);
|
|
|
|
const result: EmbeddedAgentRunResult = {
|
|
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(loadPersistedSessionEntry(storePath, sessionKey)?.agentHarnessId).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("persists claude-cli session bindings when the backend is configured", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {},
|
|
},
|
|
} 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 seedSessionStore(storePath, sessionStore);
|
|
|
|
const result: EmbeddedAgentRunResult = {
|
|
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]?.sessionId).toBe(sessionId);
|
|
expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("cli-session-123");
|
|
expect(sessionStore[sessionKey]?.claudeCliSessionId).toBe("cli-session-123");
|
|
|
|
const persisted = loadPersistedSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({
|
|
sessionId: "cli-session-123",
|
|
});
|
|
expect(persisted[sessionKey]?.sessionId).toBe(sessionId);
|
|
expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("cli-session-123");
|
|
expect(persisted[sessionKey]?.claudeCliSessionId).toBe("cli-session-123");
|
|
});
|
|
});
|
|
|
|
it("clears stale CLI bindings when a successful run reports an unflushed replacement", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {},
|
|
},
|
|
} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-clear-unflushed-cli";
|
|
const sessionId = "test-openclaw-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
cliSessionBindings: {
|
|
"claude-cli": {
|
|
sessionId: "stale-cli-session",
|
|
authEpoch: "old-epoch",
|
|
},
|
|
"codex-cli": {
|
|
sessionId: "codex-session",
|
|
},
|
|
},
|
|
cliSessionIds: {
|
|
"claude-cli": "stale-cli-session",
|
|
"codex-cli": "codex-session",
|
|
},
|
|
claudeCliSessionId: "stale-cli-session",
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
const result: EmbeddedAgentRunResult = {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId: "",
|
|
provider: "claude-cli",
|
|
model: "claude-sonnet-4-6",
|
|
clearCliSessionBinding: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
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"]).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cliSessionBindings?.["codex-cli"]).toEqual({
|
|
sessionId: "codex-session",
|
|
});
|
|
expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cliSessionIds?.["codex-cli"]).toBe("codex-session");
|
|
expect(sessionStore[sessionKey]?.claudeCliSessionId).toBeUndefined();
|
|
|
|
const persisted = loadPersistedSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
|
expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
|
|
expect(persisted[sessionKey]?.claudeCliSessionId).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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 seedSessionStore(storePath, { [sessionKey]: existing });
|
|
|
|
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 = loadPersistedSessionEntry(storePath, sessionKey);
|
|
expect(persisted?.acp?.backend).toBe("acpx");
|
|
expect(persisted?.acp?.agent).toBe("codex");
|
|
expect(persisted?.acp?.runtimeSessionName).toBe("runtime-1");
|
|
expect(persisted?.acp?.mode).toBe("persistent");
|
|
expect(persisted?.acp?.state).toBe("idle");
|
|
expect(staleInMemory[sessionKey]?.acp).toEqual(persisted?.acp);
|
|
});
|
|
});
|
|
|
|
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 seedSessionStore(storePath, { [sessionKey]: terminalEntry });
|
|
|
|
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 = loadPersistedSessionEntry(storePath, sessionKey);
|
|
expect(persisted?.status).toBe("done");
|
|
expect(persisted?.startedAt).toBe(1_000);
|
|
expect(persisted?.endedAt).toBe(1_900);
|
|
expect(persisted?.runtimeMs).toBe(900);
|
|
expect(persisted?.modelProvider).toBe("openai");
|
|
expect(persisted?.model).toBe("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 seedSessionStore(storePath, sessionStore);
|
|
|
|
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 = loadPersistedSessionEntry(storePath, 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: {},
|
|
},
|
|
} 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 = loadPersistedSessionEntry(storePath, first.sessionKey!);
|
|
expect(persisted?.cliSessionBindings?.["claude-cli"]).toEqual({
|
|
sessionId: "claude-cli-session-1",
|
|
authEpoch: "auth-epoch-1",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("reuses a completed run entry while the session is still fresh", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:terminal-cli-session";
|
|
const existingSessionId = "terminal-cli-session-old";
|
|
const now = Date.now();
|
|
await seedSessionStore(storePath, {
|
|
[sessionKey]: {
|
|
sessionId: existingSessionId,
|
|
updatedAt: now,
|
|
status: "done",
|
|
startedAt: now - 1_000,
|
|
endedAt: now - 100,
|
|
runtimeMs: 900,
|
|
},
|
|
});
|
|
|
|
const result = resolveSession({
|
|
cfg: {
|
|
session: {
|
|
store: storePath,
|
|
mainKey: "main",
|
|
},
|
|
} as OpenClawConfig,
|
|
sessionKey,
|
|
});
|
|
|
|
expect(result.isNewSession).toBe(false);
|
|
expect(result.sessionId).toBe(existingSessionId);
|
|
expect(result.sessionEntry?.sessionId).toBe(existingSessionId);
|
|
expect(result.sessionEntry?.status).toBe("done");
|
|
expect(result.sessionEntry?.endedAt).toBe(now - 100);
|
|
});
|
|
});
|
|
|
|
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 seedSessionStore(storePath, sessionStore);
|
|
|
|
const result: EmbeddedAgentRunResult = {
|
|
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 = loadPersistedSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.totalTokens).toBe(21225);
|
|
expect(persisted[sessionKey]?.totalTokensFresh).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("persists estimated context budget status without marking stale usage fresh", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-context-budget-status";
|
|
const sessionId = "test-context-budget-status-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
totalTokens: 21225,
|
|
totalTokensFresh: true,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
const result: EmbeddedAgentRunResult = {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "minimax",
|
|
model: "MiniMax-M2.7",
|
|
contextBudgetStatus: {
|
|
schemaVersion: 1,
|
|
source: "pre-prompt-estimate",
|
|
updatedAt: 123,
|
|
provider: "minimax",
|
|
model: "MiniMax-M2.7",
|
|
route: "fits",
|
|
shouldCompact: false,
|
|
estimatedPromptTokens: 18_000,
|
|
contextTokenBudget: 32_000,
|
|
promptBudgetBeforeReserve: 28_000,
|
|
reserveTokens: 4_000,
|
|
effectiveReserveTokens: 4_000,
|
|
remainingPromptBudgetTokens: 10_000,
|
|
overflowTokens: 0,
|
|
toolResultReducibleChars: 0,
|
|
messageCount: 4,
|
|
unwindowedMessageCount: 4,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
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);
|
|
expect(sessionStore[sessionKey]?.contextBudgetStatus).toMatchObject({
|
|
source: "pre-prompt-estimate",
|
|
estimatedPromptTokens: 18_000,
|
|
contextTokenBudget: 32_000,
|
|
});
|
|
|
|
const persisted = loadPersistedSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.contextBudgetStatus?.estimatedPromptTokens).toBe(18_000);
|
|
});
|
|
});
|
|
|
|
it("clears stale estimated context budget status when a runtime refresh has no current estimate", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-clear-context-budget-status";
|
|
const sessionId = "test-clear-context-budget-status-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
totalTokens: 21225,
|
|
totalTokensFresh: false,
|
|
contextBudgetStatus: {
|
|
schemaVersion: 1,
|
|
source: "pre-prompt-estimate",
|
|
updatedAt: 123,
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4.6",
|
|
route: "fits",
|
|
shouldCompact: false,
|
|
estimatedPromptTokens: 18_000,
|
|
contextTokenBudget: 32_000,
|
|
promptBudgetBeforeReserve: 28_000,
|
|
reserveTokens: 4_000,
|
|
effectiveReserveTokens: 4_000,
|
|
remainingPromptBudgetTokens: 10_000,
|
|
overflowTokens: 0,
|
|
toolResultReducibleChars: 0,
|
|
messageCount: 4,
|
|
unwindowedMessageCount: 4,
|
|
},
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
const result: EmbeddedAgentRunResult = {
|
|
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]?.modelProvider).toBe("minimax");
|
|
expect(sessionStore[sessionKey]?.model).toBe("MiniMax-M2.7");
|
|
expect(sessionStore[sessionKey]?.contextBudgetStatus).toBeUndefined();
|
|
|
|
const persisted = loadPersistedSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.contextBudgetStatus).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("does not treat CLI cumulative usage as a fresh context snapshot", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {},
|
|
},
|
|
} 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 seedSessionStore(storePath, sessionStore);
|
|
|
|
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("uses non-CLI last-call usage when promptTokens is unavailable", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-responses-cumulative-usage";
|
|
const sessionId = "test-responses-cumulative-usage-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg: {} as OpenClawConfig,
|
|
contextTokensOverride: 1_000_000,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "custom-openai",
|
|
defaultModel: "responses-model",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "custom-openai",
|
|
model: "responses-model",
|
|
usage: {
|
|
input: 497_720,
|
|
output: 7_485,
|
|
cacheRead: 1_323_520,
|
|
cacheWrite: 0,
|
|
total: 1_828_725,
|
|
},
|
|
lastCallUsage: {
|
|
input: 38_333,
|
|
output: 66,
|
|
cacheRead: 120_320,
|
|
cacheWrite: 0,
|
|
total: 158_719,
|
|
},
|
|
},
|
|
},
|
|
} as EmbeddedAgentRunResult,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(158_653);
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(true);
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)?.totalTokens).toBe(158_653);
|
|
});
|
|
});
|
|
|
|
it("uses the compaction snapshot when non-CLI last-call context is unavailable", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-unavailable-context";
|
|
const sessionId = "test-unavailable-context-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
totalTokens: 95_000,
|
|
totalTokensFresh: true,
|
|
},
|
|
};
|
|
await replaceSessionEntry({ storePath, sessionKey }, sessionStore[sessionKey]!);
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg: {} as OpenClawConfig,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-fable-5",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "anthropic",
|
|
model: "claude-fable-5",
|
|
usage: {
|
|
input: 12,
|
|
output: 15_104,
|
|
cacheRead: 819_661,
|
|
cacheWrite: 93_130,
|
|
total: 927_907,
|
|
},
|
|
lastCallUsage: {
|
|
input: 12,
|
|
output: 15_104,
|
|
cacheRead: 819_661,
|
|
cacheWrite: 93_130,
|
|
contextUsage: { state: "unavailable" },
|
|
total: 927_907,
|
|
},
|
|
compactionTokensAfter: 80_000,
|
|
},
|
|
},
|
|
} as EmbeddedAgentRunResult,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(80_000);
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(true);
|
|
expect(sessionStore[sessionKey]?.cacheRead).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("persists CLI lastCallUsage as the context snapshot (totalTokens)", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {},
|
|
},
|
|
} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-cli-last-call-usage";
|
|
const sessionId = "test-cli-last-call-usage-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
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: 6,
|
|
output: 25,
|
|
cacheRead: 50_000,
|
|
cacheWrite: 0,
|
|
},
|
|
lastCallUsage: {
|
|
input: 6,
|
|
output: 25,
|
|
cacheRead: 50_000,
|
|
cacheWrite: 0,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(50_006);
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(true);
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)?.totalTokens).toBe(50_006);
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)?.totalTokensFresh).toBe(true);
|
|
});
|
|
});
|
|
|
|
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 seedSessionStore(storePath, sessionStore);
|
|
|
|
const result: EmbeddedAgentRunResult = {
|
|
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 = loadPersistedSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.totalTokens).toBe(21_225);
|
|
expect(persisted[sessionKey]?.totalTokensFresh).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("prefers fresh CLI usage over zero compaction tokensAfter", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-zero-compaction-with-usage";
|
|
const sessionId = "test-zero-compaction-with-usage-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
totalTokens: 1_794_391,
|
|
totalTokensFresh: true,
|
|
inputTokens: 20,
|
|
outputTokens: 10_855,
|
|
cacheRead: 1_761_324,
|
|
cacheWrite: 33_047,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "claude-cli",
|
|
defaultModel: "claude-opus-4-7",
|
|
result: {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "claude-cli",
|
|
model: "claude-opus-4-7",
|
|
usage: {
|
|
input: 20,
|
|
output: 10_855,
|
|
cacheRead: 1_761_324,
|
|
cacheWrite: 33_047,
|
|
},
|
|
lastCallUsage: {
|
|
input: 20,
|
|
output: 10_855,
|
|
cacheRead: 1_761_324,
|
|
cacheWrite: 33_047,
|
|
},
|
|
compactionCount: 1,
|
|
compactionTokensAfter: 0,
|
|
},
|
|
},
|
|
} as EmbeddedAgentRunResult,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(1_794_391);
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(true);
|
|
expect(sessionStore[sessionKey]?.inputTokens).toBe(20);
|
|
expect(sessionStore[sessionKey]?.outputTokens).toBe(10_855);
|
|
expect(sessionStore[sessionKey]?.cacheRead).toBe(1_761_324);
|
|
expect(sessionStore[sessionKey]?.cacheWrite).toBe(33_047);
|
|
});
|
|
});
|
|
|
|
it("prefers fresh last-call usage over positive compaction tokensAfter", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-positive-compaction-with-usage";
|
|
const sessionId = "test-positive-compaction-with-usage-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
totalTokens: 180_000,
|
|
totalTokensFresh: true,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
result: {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-5.5",
|
|
usage: {
|
|
input: 100_000,
|
|
output: 3_000,
|
|
cacheRead: 20_000,
|
|
},
|
|
lastCallUsage: {
|
|
input: 91_000,
|
|
output: 1_000,
|
|
cacheRead: 4_000,
|
|
},
|
|
compactionCount: 1,
|
|
compactionTokensAfter: 80_000,
|
|
},
|
|
},
|
|
} as EmbeddedAgentRunResult,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(95_000);
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(true);
|
|
expect(sessionStore[sessionKey]?.inputTokens).toBe(100_000);
|
|
expect(sessionStore[sessionKey]?.outputTokens).toBe(3_000);
|
|
expect(sessionStore[sessionKey]?.cacheRead).toBe(20_000);
|
|
});
|
|
});
|
|
|
|
it("accepts zero compaction tokensAfter when provider usage is unavailable", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-zero-compaction-tokens-after";
|
|
const sessionId = "test-zero-compaction-tokens-after-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
totalTokens: 12_000,
|
|
totalTokensFresh: true,
|
|
inputTokens: 20,
|
|
outputTokens: 10_855,
|
|
cacheRead: 1_761_324,
|
|
cacheWrite: 33_047,
|
|
contextBudgetStatus: {
|
|
schemaVersion: 1,
|
|
source: "pre-prompt-estimate",
|
|
updatedAt: 1,
|
|
provider: "claude-cli",
|
|
model: "claude-opus-4-7",
|
|
route: "compact_only",
|
|
shouldCompact: true,
|
|
estimatedPromptTokens: 1_794_391,
|
|
contextTokenBudget: 1_048_576,
|
|
promptBudgetBeforeReserve: 1_044_480,
|
|
reserveTokens: 4_096,
|
|
effectiveReserveTokens: 4_096,
|
|
remainingPromptBudgetTokens: 0,
|
|
overflowTokens: 749_911,
|
|
toolResultReducibleChars: 0,
|
|
messageCount: 0,
|
|
unwindowedMessageCount: 0,
|
|
},
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
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: 0,
|
|
},
|
|
},
|
|
} as EmbeddedAgentRunResult,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(0);
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(true);
|
|
expect(sessionStore[sessionKey]?.compactionCount).toBe(1);
|
|
expect(sessionStore[sessionKey]?.inputTokens).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.outputTokens).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cacheRead).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cacheWrite).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.contextBudgetStatus).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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 seedSessionStore(storePath, sessionStore);
|
|
|
|
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 seedSessionStore(storePath, sessionStore);
|
|
|
|
// Simulate a run with 10k input + 5k output tokens
|
|
// Cost = (10000 * 10 + 5000 * 30) / 1e6 = $0.25
|
|
const result: EmbeddedAgentRunResult = {
|
|
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 = loadPersistedSessionStore(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 seedSessionStore(storePath, sessionStore);
|
|
|
|
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]?.lastActivityAt).toEqual(expect.any(Number));
|
|
expect(sessionStore[sessionKey]?.lastActivityAt).toBeGreaterThan(lastInteractionAt);
|
|
expect(sessionStore[sessionKey]?.sessionStartedAt).toBe(sessionStartedAt);
|
|
expect(sessionStore[sessionKey]?.updatedAt).toBeGreaterThan(lastInteractionAt);
|
|
});
|
|
});
|
|
|
|
it("preserves lastActivityAt for heartbeat-style runs", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-heartbeat-run";
|
|
const sessionId = "test-heartbeat-run-session";
|
|
const lastActivityAt = Date.now() - 60 * 60_000;
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: Date.now() - 10_000,
|
|
lastActivityAt,
|
|
},
|
|
};
|
|
await replaceSessionEntry({ storePath, sessionKey }, sessionStore[sessionKey]!);
|
|
|
|
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,
|
|
touchActivity: false,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.lastActivityAt).toBe(lastActivityAt);
|
|
expect(sessionStore[sessionKey]?.updatedAt).toBeGreaterThan(lastActivityAt);
|
|
});
|
|
});
|
|
|
|
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 seedSessionStore(storePath, sessionStore);
|
|
|
|
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("clears main recovery markers after settled background progress", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-clear-recovery-state";
|
|
const sessionId = "test-clear-recovery-state-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
abortedLastRun: true,
|
|
restartRecoveryRuns: [
|
|
{ runId: "initial-wedged-run", lifecycleGeneration: "gen-1" },
|
|
{ runId: "recovery-run-1", lifecycleGeneration: "gen-2" },
|
|
{ runId: "recovery-run-2", lifecycleGeneration: "gen-3" },
|
|
{ runId: "recovery-run-3", lifecycleGeneration: "gen-4" },
|
|
],
|
|
mainRestartRecovery: {
|
|
cycleId: "cycle-1",
|
|
revision: 3,
|
|
chargedAttempts: 2,
|
|
},
|
|
subagentRecovery: {
|
|
automaticAttempts: 2,
|
|
lastAttemptAt: 3,
|
|
wedgedAt: 4,
|
|
wedgedReason: "automatic_attempt_budget_exceeded",
|
|
},
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
touchInteraction: false,
|
|
touchActivity: false,
|
|
preserveRuntimeModel: true,
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
aborted: false,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-5.5",
|
|
},
|
|
},
|
|
} as EmbeddedAgentRunResult,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.abortedLastRun).toBe(false);
|
|
expect(sessionStore[sessionKey]?.restartRecoveryRuns).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.mainRestartRecovery).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.subagentRecovery).toEqual({
|
|
automaticAttempts: 2,
|
|
lastAttemptAt: 3,
|
|
wedgedAt: 4,
|
|
wedgedReason: "automatic_attempt_budget_exceeded",
|
|
});
|
|
const persisted = loadPersistedSessionEntry(storePath, sessionKey);
|
|
expect(persisted?.abortedLastRun).toBe(false);
|
|
expect(persisted?.restartRecoveryRuns).toBeUndefined();
|
|
expect(persisted).not.toHaveProperty("mainRestartRecovery");
|
|
expect(persisted?.subagentRecovery).toEqual({
|
|
automaticAttempts: 2,
|
|
lastAttemptAt: 3,
|
|
wedgedAt: 4,
|
|
wedgedReason: "automatic_attempt_budget_exceeded",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("preserves a replacement recovery cycle from an older healthy finalizer", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-recovery-finalizer-aba";
|
|
const sessionId = "test-recovery-finalizer-aba-session";
|
|
const staleEntry: SessionEntry = {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
status: "running",
|
|
abortedLastRun: true,
|
|
mainRestartRecovery: {
|
|
cycleId: "cycle-old",
|
|
revision: 2,
|
|
chargedAttempts: 1,
|
|
},
|
|
};
|
|
const sessionStore = { [sessionKey]: staleEntry };
|
|
const replacementEntry: SessionEntry = {
|
|
...staleEntry,
|
|
updatedAt: 2,
|
|
restartRecoveryRuns: [{ runId: "replacement-run", lifecycleGeneration: "gen-new" }],
|
|
mainRestartRecovery: {
|
|
cycleId: "cycle-new",
|
|
revision: 1,
|
|
chargedAttempts: 0,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, { [sessionKey]: replacementEntry });
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg: {} as OpenClawConfig,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
aborted: false,
|
|
agentMeta: { sessionId, provider: "openai", model: "gpt-5.5" },
|
|
},
|
|
} as EmbeddedAgentRunResult,
|
|
});
|
|
|
|
const persisted = loadPersistedSessionEntry(storePath, sessionKey);
|
|
expect(persisted).toMatchObject({
|
|
abortedLastRun: true,
|
|
restartRecoveryRuns: [{ runId: "replacement-run", lifecycleGeneration: "gen-new" }],
|
|
mainRestartRecovery: {
|
|
cycleId: "cycle-new",
|
|
revision: 1,
|
|
chargedAttempts: 0,
|
|
},
|
|
});
|
|
expect(sessionStore[sessionKey]).toEqual(persisted);
|
|
});
|
|
});
|
|
|
|
it("preserves a concurrent restart marker when a stale run settles healthy", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-restart-finalizer-race";
|
|
const sessionId = "test-restart-finalizer-race-session";
|
|
const initialEntry: SessionEntry = {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
status: "running",
|
|
abortedLastRun: true,
|
|
restartRecoveryRuns: [{ runId: "run-1", lifecycleGeneration: "generation-1" }],
|
|
mainRestartRecovery: {
|
|
cycleId: "cycle-1",
|
|
revision: 2,
|
|
chargedAttempts: 0,
|
|
foregroundClaims: {
|
|
lifecycleGeneration: "generation-1",
|
|
tokens: ["owner-1"],
|
|
},
|
|
},
|
|
};
|
|
const sessionStore = { [sessionKey]: initialEntry };
|
|
const concurrentEntry: SessionEntry = {
|
|
...structuredClone(initialEntry),
|
|
updatedAt: 2,
|
|
restartRecoveryRuns: [
|
|
...(initialEntry.restartRecoveryRuns ?? []),
|
|
{ runId: "run-1", lifecycleGeneration: "generation-2" },
|
|
],
|
|
mainRestartRecovery: {
|
|
...initialEntry.mainRestartRecovery!,
|
|
revision: 3,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, { [sessionKey]: concurrentEntry });
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg: {} as OpenClawConfig,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
aborted: false,
|
|
agentMeta: { sessionId, provider: "openai", model: "gpt-5.5" },
|
|
},
|
|
} as EmbeddedAgentRunResult,
|
|
});
|
|
|
|
for (const entry of [
|
|
sessionStore[sessionKey],
|
|
loadPersistedSessionEntry(storePath, sessionKey),
|
|
]) {
|
|
expect(entry?.abortedLastRun).toBe(true);
|
|
expect(entry?.restartRecoveryRuns).toEqual(concurrentEntry.restartRecoveryRuns);
|
|
expect(entry?.mainRestartRecovery).toEqual(concurrentEntry.mainRestartRecovery);
|
|
}
|
|
});
|
|
});
|
|
|
|
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",
|
|
agentHarnessId: "openclaw",
|
|
contextTokens: 1_000_000,
|
|
cliSessionBindings: {
|
|
"claude-cli": { sessionId: "existing-cli-session" },
|
|
},
|
|
cliSessionIds: {
|
|
"claude-cli": "existing-cli-session",
|
|
},
|
|
claudeCliSessionId: "existing-cli-session",
|
|
contextBudgetStatus: {
|
|
schemaVersion: 1,
|
|
source: "pre-prompt-estimate",
|
|
updatedAt: 100,
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-6",
|
|
route: "fits",
|
|
shouldCompact: false,
|
|
estimatedPromptTokens: 640_000,
|
|
contextTokenBudget: 1_000_000,
|
|
promptBudgetBeforeReserve: 900_000,
|
|
reserveTokens: 100_000,
|
|
effectiveReserveTokens: 100_000,
|
|
remainingPromptBudgetTokens: 260_000,
|
|
overflowTokens: 0,
|
|
toolResultReducibleChars: 0,
|
|
messageCount: 12,
|
|
unwindowedMessageCount: 12,
|
|
},
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
// Heartbeat turn uses a different model
|
|
const result: EmbeddedAgentRunResult = {
|
|
meta: {
|
|
durationMs: 500,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "claude-cli",
|
|
model: "claude-sonnet-4-6",
|
|
agentHarnessId: "codex",
|
|
contextTokens: 128_000,
|
|
cliSessionBinding: { sessionId: "heartbeat-cli-session" },
|
|
contextBudgetStatus: {
|
|
schemaVersion: 1,
|
|
source: "pre-prompt-estimate",
|
|
updatedAt: 200,
|
|
provider: "ollama",
|
|
model: "llama3.2:1b",
|
|
route: "fits",
|
|
shouldCompact: false,
|
|
estimatedPromptTokens: 40_000,
|
|
contextTokenBudget: 128_000,
|
|
promptBudgetBeforeReserve: 112_000,
|
|
reserveTokens: 16_000,
|
|
effectiveReserveTokens: 16_000,
|
|
remainingPromptBudgetTokens: 72_000,
|
|
overflowTokens: 0,
|
|
toolResultReducibleChars: 0,
|
|
messageCount: 3,
|
|
unwindowedMessageCount: 3,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
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]?.agentHarnessId).toBe("openclaw");
|
|
expect(sessionStore[sessionKey]?.contextTokens).toBe(1_000_000);
|
|
expect(sessionStore[sessionKey]?.contextBudgetStatus?.provider).toBe("anthropic");
|
|
expect(sessionStore[sessionKey]?.contextBudgetStatus?.estimatedPromptTokens).toBe(640_000);
|
|
expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({
|
|
sessionId: "existing-cli-session",
|
|
});
|
|
|
|
const persisted = loadPersistedSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.model).toBe("claude-opus-4-6");
|
|
expect(persisted[sessionKey]?.modelProvider).toBe("anthropic");
|
|
expect(persisted[sessionKey]?.agentHarnessId).toBe("openclaw");
|
|
expect(persisted[sessionKey]?.contextTokens).toBe(1_000_000);
|
|
expect(persisted[sessionKey]?.contextBudgetStatus?.provider).toBe("anthropic");
|
|
expect(persisted[sessionKey]?.contextBudgetStatus?.estimatedPromptTokens).toBe(640_000);
|
|
expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({
|
|
sessionId: "existing-cli-session",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("preserves user-facing run accounting while allowing session touch metadata", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {},
|
|
},
|
|
} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:test-preserve-user-facing-run-state";
|
|
const sessionId = "test-preserve-user-facing-run-state-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
lastInteractionAt: 10,
|
|
modelProvider: "anthropic",
|
|
model: "claude-opus-4-6",
|
|
contextTokens: 1_000_000,
|
|
inputTokens: 11,
|
|
outputTokens: 22,
|
|
totalTokens: 333,
|
|
totalTokensFresh: true,
|
|
cacheRead: 4,
|
|
cacheWrite: 5,
|
|
estimatedCostUsd: 0.25,
|
|
abortedLastRun: false,
|
|
cliSessionBindings: {
|
|
"claude-cli": { sessionId: "visible-cli-session" },
|
|
},
|
|
compactionCount: 7,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
const freshVisibleEntry: SessionEntry = {
|
|
sessionId: "fresh-visible-session-id",
|
|
updatedAt: 2,
|
|
sessionStartedAt: 777,
|
|
lastInteractionAt: 20,
|
|
lastActivityAt: 21,
|
|
modelProvider: "openai",
|
|
model: "gpt-5.5",
|
|
contextTokens: 400_000,
|
|
inputTokens: 44,
|
|
outputTokens: 55,
|
|
totalTokens: 666,
|
|
totalTokensFresh: true,
|
|
cacheRead: 7,
|
|
cacheWrite: 8,
|
|
estimatedCostUsd: 0.5,
|
|
abortedLastRun: false,
|
|
cliSessionBindings: {
|
|
"claude-cli": { sessionId: "new-visible-cli-session" },
|
|
},
|
|
compactionCount: 9,
|
|
};
|
|
await seedSessionStore(storePath, { [sessionKey]: freshVisibleEntry });
|
|
|
|
const result: EmbeddedAgentRunResult = {
|
|
meta: {
|
|
durationMs: 500,
|
|
aborted: true,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "claude-cli",
|
|
model: "claude-sonnet-4-6",
|
|
contextTokens: 200_000,
|
|
usage: {
|
|
input: 100,
|
|
output: 50,
|
|
cacheRead: 10,
|
|
cacheWrite: 20,
|
|
},
|
|
compactionCount: 3,
|
|
cliSessionBinding: {
|
|
sessionId: "handoff-cli-session",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "claude-cli",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
result,
|
|
preserveUserFacingSessionModelState: true,
|
|
});
|
|
|
|
const next = sessionStore[sessionKey];
|
|
expect(next?.sessionId).toBe("fresh-visible-session-id");
|
|
expect(next?.sessionStartedAt).toBe(777);
|
|
expect(next?.modelProvider).toBe("openai");
|
|
expect(next?.model).toBe("gpt-5.5");
|
|
expect(next?.contextTokens).toBe(400_000);
|
|
expect(next?.inputTokens).toBe(44);
|
|
expect(next?.outputTokens).toBe(55);
|
|
expect(next?.totalTokens).toBe(666);
|
|
expect(next?.totalTokensFresh).toBe(true);
|
|
expect(next?.cacheRead).toBe(7);
|
|
expect(next?.cacheWrite).toBe(8);
|
|
expect(next?.estimatedCostUsd).toBe(0.5);
|
|
expect(next?.abortedLastRun).toBe(false);
|
|
expect(next?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe("new-visible-cli-session");
|
|
expect(next?.compactionCount).toBe(9);
|
|
expect(next?.lastInteractionAt).toBeGreaterThan(20);
|
|
// Preserved-state runs must not re-flag the session unread.
|
|
expect(next?.lastActivityAt).toBe(21);
|
|
});
|
|
});
|
|
|
|
it("does not recreate a missing persisted row while preserving user-facing state", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:missing-visible-row";
|
|
const sessionId = "missing-visible-row-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
modelProvider: "openai",
|
|
model: "gpt-5.5",
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "claude-cli",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
preserveUserFacingSessionModelState: true,
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "claude-cli",
|
|
model: "claude-sonnet-4-6",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]).toEqual({
|
|
sessionId,
|
|
updatedAt: 1,
|
|
modelProvider: "openai",
|
|
model: "gpt-5.5",
|
|
});
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("creates a missing persisted row for a new normal run", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:new-normal-row";
|
|
const sessionId = "new-normal-row-session";
|
|
const sessionStore: Record<string, SessionEntry> = {};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-5.5",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]).toMatchObject({ sessionId });
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)).toMatchObject({
|
|
sessionId,
|
|
});
|
|
});
|
|
});
|
|
|
|
it("does not recreate a missing persisted row after a normal run with a preloaded entry", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:deleted-normal-row";
|
|
const sessionId = "deleted-normal-row-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
modelProvider: "openai",
|
|
model: "gpt-5.5",
|
|
},
|
|
};
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.5",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "openai",
|
|
model: "gpt-5.5",
|
|
usage: { input: 100, output: 20 },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]).toEqual({
|
|
sessionId,
|
|
updatedAt: 1,
|
|
modelProvider: "openai",
|
|
model: "gpt-5.5",
|
|
});
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("does not overwrite a replacement persisted row after a normal run", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const sessionKey = "agent:main:explicit:rebound-visible-row";
|
|
const sessionId = "run-session-id";
|
|
const replacementEntry: SessionEntry = {
|
|
sessionId: "replacement-session-id",
|
|
updatedAt: 2,
|
|
delivery: { kind: "none" },
|
|
modelProvider: "openai",
|
|
model: "gpt-5.5",
|
|
};
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
modelProvider: "anthropic",
|
|
model: "claude-sonnet-4-6",
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, { [sessionKey]: replacementEntry });
|
|
|
|
await updateSessionStoreAfterAgentRun({
|
|
cfg,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
sessionStore,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
result: {
|
|
meta: {
|
|
durationMs: 1,
|
|
agentMeta: {
|
|
sessionId,
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4-6",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)).toEqual(replacementEntry);
|
|
});
|
|
});
|
|
|
|
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 seedSessionStore(storePath, sessionStore);
|
|
|
|
// Heartbeat turn uses a different, smaller model
|
|
const result: EmbeddedAgentRunResult = {
|
|
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 seedSessionStore(storePath, sessionStore);
|
|
|
|
const result: EmbeddedAgentRunResult = {
|
|
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 seedSessionStore(storePath, sessionStore);
|
|
|
|
// Heartbeat turn uses a different provider
|
|
const result: EmbeddedAgentRunResult = {
|
|
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 = loadPersistedSessionStore(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 seedSessionStore(storePath, sessionStore);
|
|
|
|
const result: EmbeddedAgentRunResult = {
|
|
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("recordCliCompactionInStore", () => {
|
|
it("persists native compaction token counts and clears stale CLI usage breakdown", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-record-cli-compaction";
|
|
const sessionId = "test-record-cli-compaction-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
totalTokens: 12_000,
|
|
totalTokensFresh: true,
|
|
inputTokens: 9_000,
|
|
outputTokens: 100,
|
|
cacheRead: 2_900,
|
|
cacheWrite: 0,
|
|
contextBudgetStatus: {
|
|
schemaVersion: 1,
|
|
source: "pre-prompt-estimate",
|
|
updatedAt: 123,
|
|
provider: "codex",
|
|
model: "gpt-5.5",
|
|
route: "fits",
|
|
shouldCompact: false,
|
|
estimatedPromptTokens: 18_000,
|
|
contextTokenBudget: 32_000,
|
|
promptBudgetBeforeReserve: 28_000,
|
|
reserveTokens: 4_000,
|
|
effectiveReserveTokens: 4_000,
|
|
remainingPromptBudgetTokens: 10_000,
|
|
overflowTokens: 0,
|
|
toolResultReducibleChars: 0,
|
|
messageCount: 4,
|
|
unwindowedMessageCount: 4,
|
|
},
|
|
cliSessionBindings: {
|
|
codex: {
|
|
sessionId: "stale-cli-session",
|
|
},
|
|
},
|
|
cliSessionIds: {
|
|
codex: "stale-cli-session",
|
|
},
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
await recordCliCompactionInStore({
|
|
provider: "codex",
|
|
sessionKey,
|
|
sessionStore,
|
|
storePath,
|
|
tokensAfter: 0,
|
|
});
|
|
|
|
const persisted = loadPersistedSessionStore(storePath);
|
|
expect(sessionStore[sessionKey]?.compactionCount).toBe(1);
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(0);
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(true);
|
|
expect(sessionStore[sessionKey]?.inputTokens).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.outputTokens).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cacheRead).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cacheWrite).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.contextBudgetStatus).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cliSessionBindings?.codex).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cliSessionIds?.codex).toBeUndefined();
|
|
expect(persisted[sessionKey]?.totalTokens).toBe(0);
|
|
expect(persisted[sessionKey]?.totalTokensFresh).toBe(true);
|
|
expect(persisted[sessionKey]?.contextBudgetStatus).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("marks CLI token counts stale when native compaction returns no token count", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-record-cli-compaction-unknown";
|
|
const sessionId = "test-record-cli-compaction-unknown-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
totalTokens: 37_000,
|
|
totalTokensFresh: true,
|
|
inputTokens: 30_000,
|
|
outputTokens: 100,
|
|
cacheRead: 6_900,
|
|
cacheWrite: 0,
|
|
contextBudgetStatus: {
|
|
schemaVersion: 1,
|
|
source: "pre-prompt-estimate",
|
|
updatedAt: 123,
|
|
provider: "codex",
|
|
model: "gpt-5.5",
|
|
route: "compact_only",
|
|
shouldCompact: true,
|
|
estimatedPromptTokens: 48_000,
|
|
contextTokenBudget: 32_000,
|
|
promptBudgetBeforeReserve: 28_000,
|
|
reserveTokens: 4_000,
|
|
effectiveReserveTokens: 4_000,
|
|
remainingPromptBudgetTokens: 0,
|
|
overflowTokens: 20_000,
|
|
toolResultReducibleChars: 0,
|
|
messageCount: 40,
|
|
unwindowedMessageCount: 40,
|
|
},
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
await recordCliCompactionInStore({
|
|
provider: "codex",
|
|
sessionKey,
|
|
sessionStore,
|
|
storePath,
|
|
});
|
|
|
|
const persisted = loadPersistedSessionStore(storePath);
|
|
expect(sessionStore[sessionKey]?.compactionCount).toBe(1);
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(37_000);
|
|
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(false);
|
|
expect(sessionStore[sessionKey]?.inputTokens).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.outputTokens).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cacheRead).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.cacheWrite).toBeUndefined();
|
|
expect(sessionStore[sessionKey]?.contextBudgetStatus).toBeUndefined();
|
|
expect(persisted[sessionKey]?.totalTokens).toBe(37_000);
|
|
expect(persisted[sessionKey]?.totalTokensFresh).toBe(false);
|
|
expect(persisted[sessionKey]?.contextBudgetStatus).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("persists successor session ids from native CLI compaction", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-record-cli-compaction-rotate";
|
|
const sessionId = "test-record-cli-compaction-rotate-session";
|
|
const nextSessionId = "test-record-cli-compaction-rotate-next";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
await recordCliCompactionInStore({
|
|
provider: "codex",
|
|
sessionKey,
|
|
sessionStore,
|
|
storePath,
|
|
newSessionId: nextSessionId,
|
|
});
|
|
|
|
expect(sessionStore[sessionKey]?.sessionId).toBe(nextSessionId);
|
|
expect(sessionStore[sessionKey]?.usageFamilyKey).toBe(sessionKey);
|
|
expect(sessionStore[sessionKey]?.usageFamilySessionIds).toEqual([sessionId, nextSessionId]);
|
|
expect(sessionStore[sessionKey]).not.toHaveProperty("sessionFile");
|
|
|
|
const persisted = loadPersistedSessionStore(storePath);
|
|
expect(persisted[sessionKey]?.sessionId).toBe(nextSessionId);
|
|
expect(persisted[sessionKey]).not.toHaveProperty("sessionFile");
|
|
});
|
|
});
|
|
|
|
it("recreates a complete persisted row when the caller snapshot survived a missing store row", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-record-cli-compaction-missing-row";
|
|
const sessionId = "test-record-cli-compaction-missing-row-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
modelProvider: "openai",
|
|
model: "gpt-5.5",
|
|
totalTokens: 12_000,
|
|
totalTokensFresh: true,
|
|
inputTokens: 9_000,
|
|
outputTokens: 100,
|
|
cacheRead: 2_900,
|
|
cacheWrite: 0,
|
|
cliSessionBindings: {
|
|
codex: {
|
|
sessionId: "stale-cli-session",
|
|
},
|
|
},
|
|
cliSessionIds: {
|
|
codex: "stale-cli-session",
|
|
},
|
|
},
|
|
};
|
|
|
|
await recordCliCompactionInStore({
|
|
provider: "codex",
|
|
sessionKey,
|
|
sessionStore,
|
|
storePath,
|
|
tokensAfter: 42,
|
|
});
|
|
|
|
const persisted = loadPersistedSessionEntry(storePath, sessionKey);
|
|
expect(sessionStore[sessionKey]?.sessionId).toBe(sessionId);
|
|
expect(sessionStore[sessionKey]?.modelProvider).toBe("openai");
|
|
expect(sessionStore[sessionKey]?.model).toBe("gpt-5.5");
|
|
expect(sessionStore[sessionKey]?.compactionCount).toBe(1);
|
|
expect(sessionStore[sessionKey]?.totalTokens).toBe(42);
|
|
expect(sessionStore[sessionKey]?.cliSessionBindings?.codex).toBeUndefined();
|
|
expect(persisted?.sessionId).toBe(sessionId);
|
|
expect(persisted?.modelProvider).toBe("openai");
|
|
expect(persisted?.model).toBe("gpt-5.5");
|
|
expect(persisted?.compactionCount).toBe(1);
|
|
expect(persisted?.totalTokens).toBe(42);
|
|
expect(persisted?.cliSessionBindings?.codex).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("does not recreate a missing row when a post-run compaction has an expected session id", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-record-cli-compaction-deleted";
|
|
const sessionId = "test-record-cli-compaction-deleted-session";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
cliSessionIds: {
|
|
codex: "stale-cli-session",
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await recordCliCompactionInStore({
|
|
provider: "codex",
|
|
sessionKey,
|
|
sessionStore,
|
|
storePath,
|
|
expectedSessionId: sessionId,
|
|
tokensAfter: 42,
|
|
});
|
|
|
|
expect(result).toEqual(sessionStore[sessionKey]);
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("consumeCliSessionForkInStore", () => {
|
|
it("clears the one-shot marker while preserving the bound source id", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:catalog-adopt:claude:test";
|
|
const entry: SessionEntry = {
|
|
sessionId: "openclaw-session-1",
|
|
updatedAt: 1,
|
|
cliSessionBindings: {
|
|
"claude-cli": {
|
|
sessionId: "claude-source-session",
|
|
resumeCheckpointId: "assistant-before-turn",
|
|
forceReuse: true,
|
|
forkNextResume: true,
|
|
},
|
|
},
|
|
};
|
|
const sessionStore = { [sessionKey]: entry };
|
|
await seedSessionStore(storePath, sessionStore);
|
|
await replaceSessionEntry(
|
|
{ storePath, sessionKey },
|
|
{ ...entry, label: "concurrent update" },
|
|
);
|
|
const consumed = await consumeCliSessionForkInStore({
|
|
provider: "claude-cli",
|
|
sessionKey,
|
|
sessionStore,
|
|
storePath,
|
|
expectedCliSessionId: "claude-source-session",
|
|
});
|
|
expect(consumed?.cliSessionBindings?.["claude-cli"]).toEqual({
|
|
sessionId: "claude-source-session",
|
|
resumeCheckpointId: "assistant-before-turn",
|
|
forceReuse: true,
|
|
});
|
|
expect(consumed?.label).toBe("concurrent update");
|
|
expect(
|
|
loadPersistedSessionEntry(storePath, sessionKey)?.cliSessionBindings?.["claude-cli"],
|
|
).toEqual({
|
|
sessionId: "claude-source-session",
|
|
resumeCheckpointId: "assistant-before-turn",
|
|
forceReuse: true,
|
|
});
|
|
await expect(
|
|
consumeCliSessionForkInStore({
|
|
provider: "claude-cli",
|
|
sessionKey,
|
|
sessionStore,
|
|
storePath,
|
|
expectedCliSessionId: "claude-source-session",
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("re-arms a claimed marker after a failed turn", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:plugin:anthropic:catalog-adopt:claude:test";
|
|
const entry: SessionEntry = {
|
|
sessionId: "openclaw-session-1",
|
|
updatedAt: 1,
|
|
cliSessionBindings: {
|
|
"claude-cli": { sessionId: "claude-source-session", forceReuse: true },
|
|
},
|
|
};
|
|
const sessionStore = { [sessionKey]: entry };
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
const restored = await restoreCliSessionForkInStore({
|
|
provider: "claude-cli",
|
|
sessionKey,
|
|
sessionStore,
|
|
storePath,
|
|
expectedCliSessionId: "claude-source-session",
|
|
});
|
|
|
|
expect(restored?.cliSessionBindings?.["claude-cli"]?.forkNextResume).toBe(true);
|
|
expect(
|
|
loadPersistedSessionEntry(storePath, sessionKey)?.cliSessionBindings?.["claude-cli"]
|
|
?.forkNextResume,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("persists the fork successor before turn finalization", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:plugin:anthropic:catalog-adopt:claude:test";
|
|
const entry: SessionEntry = {
|
|
sessionId: "openclaw-session-1",
|
|
updatedAt: 1,
|
|
cliSessionBindings: {
|
|
"claude-cli": {
|
|
sessionId: "claude-source-session",
|
|
resumeCheckpointId: "assistant-before-turn",
|
|
forceReuse: true,
|
|
authProfileId: "claude:work",
|
|
authEpoch: "epoch-1",
|
|
authEpochVersion: 3,
|
|
},
|
|
},
|
|
};
|
|
const sessionStore = { [sessionKey]: entry };
|
|
await seedSessionStore(storePath, sessionStore);
|
|
|
|
const persisted = await persistCliSessionForkSuccessorInStore({
|
|
provider: "claude-cli",
|
|
sessionKey,
|
|
sessionStore,
|
|
storePath,
|
|
expectedCliSessionId: "claude-source-session",
|
|
successorCliSessionId: "claude-fork-session",
|
|
});
|
|
|
|
expect(persisted?.cliSessionBindings?.["claude-cli"]).toEqual({
|
|
sessionId: "claude-fork-session",
|
|
resumeCheckpointId: "assistant-before-turn",
|
|
forceReuse: true,
|
|
authProfileId: "claude:work",
|
|
authEpoch: "epoch-1",
|
|
authEpochVersion: 3,
|
|
});
|
|
expect(
|
|
loadPersistedSessionEntry(storePath, sessionKey)?.cliSessionBindings?.["claude-cli"],
|
|
).toEqual({
|
|
sessionId: "claude-fork-session",
|
|
resumeCheckpointId: "assistant-before-turn",
|
|
forceReuse: true,
|
|
authProfileId: "claude:work",
|
|
authEpoch: "epoch-1",
|
|
authEpochVersion: 3,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
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 seedSessionStore(storePath, sessionStore);
|
|
|
|
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 = loadPersistedSessionEntry(storePath, 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 seedSessionStore(storePath, sessionStore);
|
|
|
|
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(loadPersistedSessionEntry(storePath, existingKey)?.claudeCliSessionId).toBe(
|
|
"claude-session-1",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("clears the caller snapshot and recreates a complete persisted row when the store row is missing", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-clear-cli-missing-row";
|
|
const entry: SessionEntry = {
|
|
sessionId: "openclaw-session-1",
|
|
updatedAt: 1,
|
|
modelProvider: "anthropic",
|
|
model: "claude-opus-4-6",
|
|
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 };
|
|
|
|
const cleared = await clearCliSessionInStore({
|
|
provider: "claude-cli",
|
|
sessionKey,
|
|
sessionStore,
|
|
storePath,
|
|
});
|
|
|
|
const persisted = loadPersistedSessionEntry(storePath, sessionKey);
|
|
expect(cleared?.sessionId).toBe("openclaw-session-1");
|
|
expect(cleared?.modelProvider).toBe("anthropic");
|
|
expect(cleared?.model).toBe("claude-opus-4-6");
|
|
expect(cleared?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
|
expect(cleared?.cliSessionBindings?.["codex-cli"]).toEqual({
|
|
sessionId: "codex-session-1",
|
|
});
|
|
expect(cleared?.claudeCliSessionId).toBeUndefined();
|
|
expect(sessionStore[sessionKey]).toEqual(cleared);
|
|
expect(persisted?.sessionId).toBe("openclaw-session-1");
|
|
expect(persisted?.modelProvider).toBe("anthropic");
|
|
expect(persisted?.model).toBe("claude-opus-4-6");
|
|
expect(persisted?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
|
|
expect(persisted?.cliSessionBindings?.["codex-cli"]).toEqual({
|
|
sessionId: "codex-session-1",
|
|
});
|
|
expect(persisted?.claudeCliSessionId).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("does not recreate a missing row when a post-run binding clear has an expected session id", async () => {
|
|
await withTempSessionStore(async ({ storePath }) => {
|
|
const sessionKey = "agent:main:explicit:test-clear-cli-deleted-row";
|
|
const sessionId = "openclaw-session-1";
|
|
const sessionStore: Record<string, SessionEntry> = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
updatedAt: 1,
|
|
claudeCliSessionId: "claude-session-1",
|
|
},
|
|
};
|
|
|
|
await clearCliSessionInStore({
|
|
provider: "claude-cli",
|
|
sessionKey,
|
|
sessionStore,
|
|
storePath,
|
|
expectedSessionId: sessionId,
|
|
});
|
|
|
|
expect(loadPersistedSessionEntry(storePath, sessionKey)).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|