openclaw / src /agents /command /session-store.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
e249c6d verified
Raw History Blame Contribute Delete
125 kB
// 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 {
resolveFreshSessionTotalTokens,
type InternalSessionEntry as SessionEntry,
} from "../../config/sessions.js";
import * as sessionAccessor from "../../config/sessions/session-accessor.js";
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
import { buildAgentRunTerminalOutcomeFromLifecycleEvent } from "../agent-run-terminal-outcome.js";
import { clearCliSessionInStore, persistCliSessionBindingResult } from "../cli-session-store.js";
import type { EmbeddedAgentRunResult } from "../embedded-agent.js";
import {
consumeCliSessionForkInStore,
persistCliSessionForkSuccessorInStore,
restoreCliSessionForkInStore,
recordCliCompactionInStore,
updateSessionStoreAfterAgentRun as updateSessionStoreAfterAgentRunBase,
} from "./session-store.js";
import { resolveSession } from "./session.js";
const { listSessionEntriesCore, loadSessionEntry, patchSessionEntryCore, replaceSessionEntry } =
sessionAccessor;
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(),
}));
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 patchSessionEntryCore({ storePath, sessionKey }, () => entry, {
fallbackEntry: entry,
replaceEntry: true,
skipMaintenance: true,
});
}
}
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.each(["finalizer", "cli-compaction"] as const)(
"publishes %s cache at commit without overwriting a subsequent writer",
async (operation) => {
await withTempSessionStore(async ({ storePath }) => {
const sessionKey = `agent:main:commit-publication:${operation}`;
const owner: SessionEntry = {
sessionId: "committed-session",
lifecycleRevision: "lifecycle",
activeWriterRunId: "current-writer",
updatedAt: 1,
compactionCount: 2,
totalTokens: 900,
totalTokensFresh: true,
};
await seedSessionStore(storePath, { [sessionKey]: owner });
const sessionStore = { [sessionKey]: owner };
const replacement: SessionEntry = {
...owner,
activeWriterRunId: "replacement-writer",
updatedAt: 2,
compactionCount: 9,
totalTokens: 777,
};
const observed: Array<{ cached: SessionEntry; persisted?: SessionEntry }> = [];
let replacementSnapshot: SessionEntry | undefined;
const originalPatch = sessionAccessor.patchSessionEntryCore;
const patch = vi
.spyOn(sessionAccessor, "patchSessionEntryCore")
.mockImplementation((scope, update, options) =>
originalPatch(scope, update, {
...options,
onCommitted: (committed) => {
options?.onCommitted?.(committed);
if (scope.sessionKey !== sessionKey || scope.storePath !== storePath) {
return;
}
observed.push({
cached: sessionStore[sessionKey]!,
persisted: loadSessionEntry({ ...scope, readConsistency: "latest" }),
});
sessionAccessor.replaceSessionEntrySync(scope, replacement);
const canonicalReplacement = loadSessionEntry({
...scope,
readConsistency: "latest",
});
if (!canonicalReplacement) {
throw new Error("expected the committed replacement writer");
}
replacementSnapshot = structuredClone(canonicalReplacement);
sessionStore[sessionKey] = canonicalReplacement;
},
}),
);
try {
let recorded: SessionEntry | undefined;
if (operation === "cli-compaction") {
recorded = await recordCliCompactionInStore({
compactionKind: "native-harness",
sessionKey,
sessionStore,
storePath,
expectedSession: owner,
tokensAfter: 42,
});
} else {
await updateSessionStoreAfterAgentRun({
cfg: {},
sessionId: owner.sessionId,
sessionKey,
sessionStore,
storePath,
defaultProvider: "openai",
defaultModel: "gpt-5.6-luna",
compactionAccounting: {
kind: "durable",
count: 0,
currentContextSnapshot: { tokens: 42 },
target: {
agentId: "main",
sessionId: owner.sessionId,
sessionKey,
storePath,
lifecycleRevision: owner.lifecycleRevision,
activeWriterRunId: owner.activeWriterRunId,
},
},
result: { meta: { durationMs: 1 } },
});
}
expect(observed).toHaveLength(1);
expect(observed[0]?.persisted).toMatchObject({
activeWriterRunId: "current-writer",
totalTokens: 42,
compactionCount: operation === "cli-compaction" ? 3 : 2,
});
expect(observed[0]?.cached).toEqual(observed[0]?.persisted);
expect(replacementSnapshot).toBeDefined();
expect(sessionStore[sessionKey]).toEqual(replacementSnapshot);
expect(loadPersistedSessionEntry(storePath, sessionKey)).toEqual(replacementSnapshot);
if (operation === "cli-compaction") {
expect(recorded).toEqual(observed[0]?.persisted);
}
} finally {
patch.mockRestore();
}
});
},
);
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> = {};
const agentDir = path.join(dir, "agents", "marie", "agent");
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
path.join(agentDir, "models.json"),
JSON.stringify({
providers: {
openai: {
models: [
{ id: "gpt-5.5", cost: { input: 3, output: 5, cacheRead: 0, cacheWrite: 0 } },
],
},
},
}),
);
await updateSessionStoreAfterAgentRun({
cfg: {
agents: { ownership: "explicit", entries: { main: {}, 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,
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(8);
});
});
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,
});
await vi.waitFor(
() => {
const persisted = loadPersistedSessionStore(storePath);
expect(Object.keys(persisted)).toHaveLength(46);
expect(
Object.values(persisted).filter((entry) => entry.archivedAt === undefined),
).toHaveLength(42);
expect(persisted[sessionKey]?.sessionId).toBe(sessionId);
expect(persisted[sessionKey]?.archivedAt).toBeUndefined();
for (let index = 0; index < 45; index += 1) {
const entry = persisted[`agent:main:stale:${index}`];
expect(entry?.sessionId).toBe(`stale-${index}`);
if (index >= 41) {
expect(entry?.archivedAt).toEqual(expect.any(Number));
} else {
expect(entry?.archivedAt).toBeUndefined();
}
}
},
{ timeout: 5_000 },
);
});
});
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("rejects a finalizer attempting to rebind from public compaction metadata", 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]?.sessionId).toBe(sessionId);
expect(loadPersistedSessionEntry(storePath, sessionKey)?.sessionId).toBe(sessionId);
expect(loadPersistedSessionEntry(storePath, sessionKey)?.compactionCount).toBeUndefined();
});
});
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,
agentHarnessId: "openclaw",
},
};
await seedSessionStore(storePath, sessionStore);
const result: EmbeddedAgentRunResult = {
meta: {
durationMs: 1,
agentMeta: {
sessionId,
provider: "openai",
model: "gpt-5.5",
contextTokens: 400_000,
contextTokensSource: "runtime",
},
},
};
await updateSessionStoreAfterAgentRun({
cfg,
sessionId,
sessionKey,
storePath,
sessionStore,
defaultProvider: "openai",
defaultModel: "gpt-5.5",
result,
});
expect(sessionStore[sessionKey]?.contextTokens).toBe(400_000);
expect(sessionStore[sessionKey]?.contextTokensSource).toBe("runtime");
expect(sessionStore[sessionKey]?.agentHarnessId).toBeUndefined();
expect(loadPersistedSessionEntry(storePath, sessionKey)?.contextTokens).toBe(400_000);
expect(loadPersistedSessionEntry(storePath, sessionKey)?.contextTokensSource).toBe("runtime");
expect(loadPersistedSessionEntry(storePath, sessionKey)?.agentHarnessId).toBeUndefined();
});
});
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,
sessionId,
sessionKey,
storePath,
sessionStore,
defaultProvider: "openai",
defaultModel: "gpt-5.5",
result,
});
expect(sessionStore[sessionKey]?.contextTokens).toBe(272_000);
expect(sessionStore[sessionKey]?.contextTokensSource).toBe("resolved");
expect(loadPersistedSessionEntry(storePath, sessionKey)?.contextTokens).toBe(272_000);
expect(loadPersistedSessionEntry(storePath, sessionKey)?.contextTokensSource).toBe(
"resolved",
);
});
});
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 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",
},
},
},
};
const settled = await persistCliSessionBindingResult({
assertSettlementCurrent: () => {},
expectedSession: sessionStore[sessionKey],
provider: "claude-cli",
sessionKey,
storePath,
sessionStore,
result,
});
expect(settled).toBe(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).toBeUndefined();
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).toBeUndefined();
});
});
it("clears stale CLI bindings when a successful run reports an unflushed replacement", async () => {
await withTempSessionStore(async ({ storePath }) => {
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 persistCliSessionBindingResult({
assertSettlementCurrent: () => {},
expectedSession: sessionStore[sessionKey],
provider: "claude-cli",
sessionKey,
storePath,
sessionStore,
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,
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,
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: {},
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",
},
},
} as never,
});
const second = resolveSession({
cfg,
sessionId: "explicit-session-123",
});
expect(second.sessionKey).toBe(first.sessionKey);
expect(second.sessionEntry).toMatchObject({
modelProvider: "claude-cli",
model: "claude-sonnet-4-6",
});
const persisted = loadPersistedSessionEntry(storePath, first.sessionKey!);
expect(persisted).toMatchObject({
modelProvider: "claude-cli",
model: "claude-sonnet-4-6",
});
});
});
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.each([21_225, 0])(
"marks previous totalTokens=%i stale without provider usage (#67667)",
async (totalTokens) => {
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,
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(totalTokens);
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(false);
const persisted = loadPersistedSessionStore(storePath);
expect(persisted[sessionKey]?.totalTokens).toBe(totalTokens);
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,
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,
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.each([
{ observation: "compaction after model", tokens: 80_000, count: 1 },
{ observation: "model after compaction", tokens: 95_000, count: 1 },
{ observation: "zero-token compaction", tokens: 0, count: 1 },
{ observation: "unknown context", tokens: undefined, count: 1 },
{ observation: "model-only initial writer", tokens: 95_000, count: 0 },
])("persists private $observation independently of billing usage", async ({ tokens, count }) => {
await withTempSessionStore(async ({ storePath }) => {
const sessionKey = "agent:main:explicit:ordered-context";
const sessionId = "ordered-context-session";
const sessionStore: Record<string, SessionEntry> = {
[sessionKey]: {
sessionId,
updatedAt: 1,
totalTokens: 180_000,
totalTokensFresh: true,
compactionCount: 3,
estimatedCostUsd: 1.25,
lifecycleRevision: "lifecycle",
activeWriterRunId: "previous-writer",
},
};
await seedSessionStore(storePath, {
[sessionKey]: { ...sessionStore[sessionKey]!, activeWriterRunId: "current-writer" },
});
const usage = {
input: 100_000,
output: 3_000,
cacheRead: 20_000,
cacheWrite: 1_000,
cost: { total: 0.75 },
};
const lastCallUsage = { input: 91_000, output: 1_000, cacheRead: 4_000 };
const result: EmbeddedAgentRunResult = {
meta: {
durationMs: 1,
agentMeta: {
sessionId,
provider: "openai",
model: "gpt-5.6-luna",
usage,
lastCallUsage,
promptTokens: 95_000,
compactionCount: 99,
compactionTokensAfter: 80_000,
},
},
};
await updateSessionStoreAfterAgentRun({
cfg: {},
sessionId,
sessionKey,
storePath,
sessionStore,
defaultProvider: "openai",
defaultModel: "gpt-5.6-luna",
compactionAccounting: {
kind: "durable",
count,
currentContextSnapshot: { tokens },
target: {
agentId: "main",
sessionId,
sessionKey,
storePath,
lifecycleRevision: "lifecycle",
activeWriterRunId: "current-writer",
},
},
result,
});
for (const entry of [
sessionStore[sessionKey],
loadPersistedSessionEntry(storePath, sessionKey),
]) {
expect(entry).toMatchObject({
inputTokens: usage.input,
outputTokens: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
estimatedCostUsd: 0.75,
compactionCount: 3,
activeWriterRunId: "current-writer",
totalTokensFresh: tokens !== undefined,
});
expect(entry?.totalTokens).toBe(tokens);
expect(resolveFreshSessionTotalTokens(entry)).toBe(tokens);
}
expect(result.meta.agentMeta?.usage).toEqual(usage);
expect(result.meta.agentMeta?.lastCallUsage).toEqual(lastCallUsage);
});
});
it.each(["missing", "replaced"] as const)(
"does not write through a private fact whose owner is %s",
async (ownerState) => {
await withTempSessionStore(async ({ storePath }) => {
const sessionId = "stale-private-fact";
const sessionKey = `agent:main:explicit:${sessionId}`;
const replacement: SessionEntry | undefined =
ownerState === "replaced"
? {
sessionId,
updatedAt: 1,
lifecycleRevision: "lifecycle",
activeWriterRunId: "replacement-writer",
totalTokens: 73_000,
totalTokensFresh: true,
}
: undefined;
if (replacement) {
await seedSessionStore(storePath, { [sessionKey]: replacement });
}
const before = loadPersistedSessionEntry(storePath, sessionKey);
const sessionStore: Record<string, SessionEntry> = {};
await updateSessionStoreAfterAgentRun({
cfg: {},
sessionId,
sessionKey,
storePath,
sessionStore,
defaultProvider: "openai",
defaultModel: "gpt-5.6-luna",
compactionAccounting: {
kind: "durable",
count: 0,
currentContextSnapshot: { tokens: 42 },
target: {
agentId: "main",
sessionId,
sessionKey,
storePath,
lifecycleRevision: "lifecycle",
activeWriterRunId: "previous-writer",
},
},
result: {
meta: {
durationMs: 1,
agentMeta: {
sessionId,
provider: "openai",
model: "gpt-5.6-luna",
usage: { cost: { total: 0.75 } },
},
},
},
});
expect(loadPersistedSessionEntry(storePath, sessionKey)).toEqual(before);
expect(sessionStore[sessionKey]).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,
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.each([21_225, 0])(
"persists private context %s without erasing prior billing",
async (tokens) => {
await withTempSessionStore(async ({ storePath }) => {
const sessionKey = "agent:main:explicit:private-context-without-usage";
const sessionId = "private-context-without-usage";
const billing = { inputTokens: 20, outputTokens: 10, cacheRead: 30, cacheWrite: 40 };
const sessionStore: Record<string, SessionEntry> = {
[sessionKey]: { sessionId, updatedAt: 1, ...billing },
};
await seedSessionStore(storePath, sessionStore);
await updateSessionStoreAfterAgentRun({
cfg: {},
sessionId,
sessionKey,
storePath,
sessionStore,
defaultProvider: "openai",
defaultModel: "gpt-5.6-luna",
compactionAccounting: {
kind: "durable",
count: 1,
currentContextSnapshot: { tokens },
target: {
agentId: "main",
sessionId,
sessionKey,
storePath,
lifecycleRevision: undefined,
activeWriterRunId: undefined,
},
},
result: {
meta: {
durationMs: 500,
agentMeta: {
sessionId,
provider: "openai",
model: "gpt-5.6-luna",
compactionCount: 99,
compactionTokensAfter: 80_000,
},
},
},
});
for (const entry of [
sessionStore[sessionKey],
loadPersistedSessionEntry(storePath, sessionKey),
]) {
expect(entry).toMatchObject({ ...billing, totalTokens: tokens, totalTokensFresh: true });
expect(resolveFreshSessionTotalTokens(entry)).toBe(tokens);
expect(entry?.compactionCount).toBeUndefined();
}
});
},
);
it.each([
{
runtime: "CLI",
sessionKey: "agent:main:explicit:test-zero-compaction-with-usage",
sessionId: "test-zero-compaction-with-usage-session",
provider: "claude-cli",
model: "claude-opus-4-7",
initial: {
totalTokens: 1_794_391,
inputTokens: 20,
outputTokens: 10_855,
cacheRead: 1_761_324,
cacheWrite: 33_047,
},
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 },
compactionTokensAfter: 0,
expected: {
totalTokens: 1_794_391,
inputTokens: 20,
outputTokens: 10_855,
cacheRead: 1_761_324,
cacheWrite: 33_047,
},
},
{
runtime: "model",
sessionKey: "agent:main:explicit:test-positive-compaction-with-usage",
sessionId: "test-positive-compaction-with-usage-session",
provider: "openai",
model: "gpt-5.5",
initial: { totalTokens: 180_000 },
usage: { input: 100_000, output: 3_000, cacheRead: 20_000 },
lastCallUsage: { input: 91_000, output: 1_000, cacheRead: 4_000 },
compactionTokensAfter: 80_000,
expected: {
totalTokens: 95_000,
inputTokens: 100_000,
outputTokens: 3_000,
cacheRead: 20_000,
},
},
])(
"keeps ordinary $runtime context independent of historical compaction metadata",
async (testCase) => {
await withTempSessionStore(async ({ storePath }) => {
const cfg = {} as OpenClawConfig;
const { sessionKey, sessionId, provider, model } = testCase;
const sessionStore: Record<string, SessionEntry> = {
[sessionKey]: {
sessionId,
updatedAt: 1,
...testCase.initial,
totalTokensFresh: true,
},
};
await seedSessionStore(storePath, sessionStore);
await updateSessionStoreAfterAgentRun({
cfg,
sessionId,
sessionKey,
storePath,
sessionStore,
defaultProvider: provider,
defaultModel: model,
result: {
meta: {
durationMs: 500,
agentMeta: {
sessionId,
provider,
model,
usage: testCase.usage,
lastCallUsage: testCase.lastCallUsage,
compactionCount: 1,
compactionTokensAfter: testCase.compactionTokensAfter,
},
},
} as EmbeddedAgentRunResult,
});
expect(sessionStore[sessionKey]).toMatchObject({
...testCase.expected,
totalTokensFresh: true,
});
});
},
);
it.each([0, 80_000, Number.POSITIVE_INFINITY])(
"does not revive historical compaction snapshot %s without a private fact",
async (compactionTokensAfter) => {
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,
},
},
},
});
expect(sessionStore[sessionKey]?.totalTokens).toBe(12_000);
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(false);
});
},
);
it.each([
{
name: "estimated token usage",
usage: { input: 10_000, output: 5_000 },
total: 0.25,
hasTokens: true,
},
{
name: "cost-only positive total",
usage: { cost: { total: 0.75 } },
total: 0.75,
hasTokens: false,
},
{ name: "cost-only zero total", usage: { cost: { total: 0 } }, total: 0, hasTokens: false },
])("snapshots $name instead of accumulating", async ({ usage, total, hasTokens }) => {
await withTempSessionStore(async ({ storePath }) => {
const cfg: OpenClawConfig = {
models: {
providers: {
fixture: {
baseUrl: "https://fixture.invalid",
models: [
{
id: "priced",
name: "Priced",
reasoning: false,
input: ["text"],
contextWindow: 128_000,
maxTokens: 8_192,
cost: { input: 10, output: 30, cacheRead: 0, cacheWrite: 0 },
},
],
},
},
},
};
const sessionKey = "agent:main:explicit:test-cost-snapshot";
const sessionId = "test-cost-snapshot-session";
const sessionStore: Record<string, SessionEntry> = {
[sessionKey]: { sessionId, updatedAt: 1, estimatedCostUsd: 1.25 },
};
await seedSessionStore(storePath, sessionStore);
const result: EmbeddedAgentRunResult = {
meta: {
durationMs: 500,
agentMeta: { sessionId, provider: "fixture", model: "priced", usage },
},
};
// Repeated finalization replaces the prior run's dollars rather than adding them again.
for (let persist = 0; persist < 2; persist += 1) {
await updateSessionStoreAfterAgentRun({
cfg,
sessionId,
sessionKey,
storePath,
sessionStore,
defaultProvider: "fixture",
defaultModel: "priced",
result,
});
for (const entry of [
sessionStore[sessionKey],
loadPersistedSessionEntry(storePath, sessionKey),
]) {
expect(entry?.estimatedCostUsd).toBeCloseTo(total, 4);
if (!hasTokens) {
for (const key of [
"inputTokens",
"outputTokens",
"cacheRead",
"cacheWrite",
"totalTokens",
] as const) {
expect(entry?.[key]).toBeUndefined();
}
expect(entry?.totalTokensFresh).not.toBe(true);
}
}
}
});
});
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 without clearing its CLI binding", 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,
estimatedCostUsd: 0.04,
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({
expectedSession: { sessionId, lifecycleRevision: undefined, activeWriterRunId: undefined },
compactionKind: "native-harness",
sessionKey,
sessionStore,
storePath,
tokensAfter: 3_210,
});
const persisted = loadPersistedSessionStore(storePath);
expect(sessionStore[sessionKey]?.compactionCount).toBe(1);
expect(sessionStore[sessionKey]?.totalTokens).toBe(3_210);
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(true);
expect(resolveFreshSessionTotalTokens(sessionStore[sessionKey])).toBe(3_210);
expect(sessionStore[sessionKey]?.inputTokens).toBeUndefined();
expect(sessionStore[sessionKey]?.outputTokens).toBeUndefined();
expect(sessionStore[sessionKey]?.cacheRead).toBeUndefined();
expect(sessionStore[sessionKey]?.cacheWrite).toBeUndefined();
expect(sessionStore[sessionKey]?.estimatedCostUsd).toBeUndefined();
expect(sessionStore[sessionKey]?.contextBudgetStatus).toBeUndefined();
expect(sessionStore[sessionKey]?.cliSessionBindings?.codex).toEqual({
sessionId: "stale-cli-session",
});
expect(sessionStore[sessionKey]?.cliSessionIds?.codex).toBe("stale-cli-session");
expect(persisted[sessionKey]?.totalTokens).toBe(3_210);
expect(persisted[sessionKey]?.estimatedCostUsd).toBeUndefined();
expect(persisted[sessionKey]?.totalTokensFresh).toBe(true);
expect(resolveFreshSessionTotalTokens(persisted[sessionKey])).toBe(3_210);
expect(persisted[sessionKey]?.contextBudgetStatus).toBeUndefined();
expect(persisted[sessionKey]?.cliSessionBindings?.codex).toEqual({
sessionId: "stale-cli-session",
});
expect(persisted[sessionKey]?.cliSessionIds?.codex).toBe("stale-cli-session");
});
});
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({
expectedSession: { sessionId, lifecycleRevision: undefined, activeWriterRunId: undefined },
compactionKind: "native-harness",
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("records compaction on the existing row and clears its context-engine CLI binding", 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",
},
"claude-cli": {
sessionId: "stale-claude-session",
},
},
cliSessionIds: {
codex: "stale-cli-session",
"claude-cli": "stale-claude-session",
},
claudeCliSessionId: "stale-claude-session",
},
};
await seedSessionStore(storePath, sessionStore);
await recordCliCompactionInStore({
expectedSession: { sessionId, lifecycleRevision: undefined, activeWriterRunId: undefined },
compactionKind: "context-engine",
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(sessionStore[sessionKey]?.cliSessionBindings).toBeUndefined();
expect(sessionStore[sessionKey]?.cliSessionIds).toBeUndefined();
expect(sessionStore[sessionKey]?.claudeCliSessionId).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();
expect(persisted?.cliSessionIds?.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({
expectedSession: { sessionId, lifecycleRevision: undefined, activeWriterRunId: undefined },
compactionKind: "context-engine",
sessionKey,
sessionStore,
storePath,
tokensAfter: 42,
});
expect(result).toBeUndefined();
expect(loadPersistedSessionEntry(storePath, sessionKey)).toBeUndefined();
});
});
it.each([{ lifecycleRevision: "replacement" }, { activeWriterRunId: "replacement" }])(
"does not account against a changed CLI owner: %j",
async (replacement) => {
await withTempSessionStore(async ({ storePath }) => {
const sessionKey = "agent:main:cli-fenced-count";
const entry = {
sessionId: "same-session",
updatedAt: 1,
lifecycleRevision: "lifecycle",
activeWriterRunId: "writer",
compactionCount: 3,
};
const sessionStore = { [sessionKey]: entry };
const changed = { ...entry, ...replacement };
await seedSessionStore(storePath, { [sessionKey]: changed });
const result = await recordCliCompactionInStore({
compactionKind: "context-engine",
sessionKey,
storePath,
sessionStore,
expectedSession: entry,
tokensAfter: 42,
});
expect(result).toBeUndefined();
expect(loadPersistedSessionEntry(storePath, sessionKey)).toMatchObject(changed);
});
},
);
});
describe("CLI binding settlement", () => {
it.each([
{ state: "successful", terminal: {}, reason: "failed" },
{
state: "failed",
terminal: {
error: {
kind: "incomplete_turn",
message: "Primary execution failure",
fallbackSafe: true,
},
},
reason: "failed",
},
{
state: "timed-out",
terminal: {
aborted: true,
stopReason: "timeout",
timeoutPhase: "provider",
providerStarted: true,
},
reason: "hard_timeout",
},
{ state: "cancelled", terminal: { aborted: true, stopReason: "stop" }, reason: "cancelled" },
] as const)(
"retains a $state result when CLI binding publication fails",
async ({ terminal, reason }) => {
await withTempSessionStore(async ({ storePath }) => {
const sessionKey = "agent:main:cli-settlement-result";
const entry: SessionEntry = { sessionId: "local-session", updatedAt: 1 };
await seedSessionStore(storePath, { [sessionKey]: entry });
const beforeEntry = loadPersistedSessionEntry(storePath, sessionKey);
const result: EmbeddedAgentRunResult = {
payloads: [{ text: "Captured answer" }],
didSendViaMessagingTool: true,
didDeliverSourceReplyViaMessageTool: true,
messagingToolSentTexts: ["Already delivered"],
acceptedSessionSpawns: [
{
runId: "child",
childSessionKey: "agent:main:subagent:child",
expectsCompletionMessage: true,
},
],
meta: {
durationMs: 25,
finalAssistantVisibleText: "Captured answer",
finalAssistantRawText: "Captured raw answer",
terminalReply: {
disposition: "visible",
text: "Captured answer",
modelRouteChange: "Captured route",
},
agentMeta: {
sessionId: "native-session",
provider: "fixture-cli",
model: "fixture-model",
usage: { input: 71, output: 9, total: 80 },
cliSessionBinding: { sessionId: "native-session" },
},
...terminal,
},
};
const beforeResult = structuredClone(result);
const failure = new Error("Synthetic persistence failure (password=fixture-secret);", {
cause: new Error(`Synthetic storage cause ${"x".repeat(2_000)}`),
});
const settled = await persistCliSessionBindingResult({
result,
provider: "fixture-cli",
sessionKey,
storePath,
expectedSession: entry,
assertSettlementCurrent: () => {
throw failure;
},
});
const { payloads, meta: originalMeta, ...facts } = result;
const { error: primaryError, ...meta } = originalMeta;
expect(settled).toMatchObject({ ...facts, meta: { ...meta, replayInvalid: true } });
expect(settled.payloads?.slice(0, -1)).toEqual(payloads);
const diagnostic = settled.payloads?.at(-1);
expect(diagnostic).toMatchObject({
isError: true,
text: expect.stringContaining("CLI session continuity could not be saved"),
});
expect(diagnostic?.text).toContain("Synthetic storage cause");
expect(diagnostic?.text).not.toContain("fixture-secret");
expect(diagnostic?.text?.length).toBeLessThanOrEqual(1_024);
expect(settled.meta.error).toMatchObject({
kind: primaryError?.kind ?? "incomplete_turn",
fallbackSafe: false,
});
if (primaryError) {
expect(settled.meta.error?.message).toContain(primaryError.message);
}
expect(
buildAgentRunTerminalOutcomeFromLifecycleEvent({
phase: "error",
data: { ...settled.meta, error: settled.meta.error?.message },
}).reason,
).toBe(reason);
expect(settled).not.toBe(result);
expect(result).toEqual(beforeResult);
expect(loadPersistedSessionEntry(storePath, sessionKey)).toEqual(beforeEntry);
});
},
);
it.each(["deleted", "session", "lifecycle", "writer"])(
"cannot publish a native binding after its owner is %s",
async (change) => {
await withTempSessionStore(async ({ storePath }) => {
const sessionKey = "agent:main:cli-settlement-fence";
const entry = {
sessionId: "original",
lifecycleRevision: "original-lifecycle",
activeWriterRunId: "original-writer",
updatedAt: 1,
};
const sessionStore = { [sessionKey]: entry };
const current = {
...entry,
...(change === "session" ? { sessionId: "replacement" } : {}),
...(change === "lifecycle" ? { lifecycleRevision: "replacement" } : {}),
...(change === "writer" ? { activeWriterRunId: "replacement" } : {}),
};
if (change !== "deleted") {
await seedSessionStore(storePath, { [sessionKey]: current });
}
const before = loadPersistedSessionEntry(storePath, sessionKey);
await persistCliSessionBindingResult({
assertSettlementCurrent: () => {},
sessionKey,
storePath,
sessionStore,
expectedSession: entry,
provider: "claude-cli",
result: {
meta: {
durationMs: 1,
agentMeta: {
sessionId: "late-native-session",
cliSessionBinding: { sessionId: "late-native-session" },
provider: "claude-cli",
model: "claude-sonnet-4-6",
},
},
},
});
expect(loadPersistedSessionEntry(storePath, sessionKey)).toEqual(before);
});
},
);
it.each(["aborted-publish", "aborted-clear", "closed-publish", "closed-clear"])(
"revalidates settlement at the commit edge for %s",
async (operation) => {
await withTempSessionStore(async ({ storePath }) => {
const sessionKey = "agent:main:cli-settlement-commit";
const entry: SessionEntry = {
sessionId: "local-session",
updatedAt: 1,
cliSessionBindings: { "claude-cli": { sessionId: "existing-native-session" } },
};
await seedSessionStore(storePath, { [sessionKey]: entry });
const before = loadPersistedSessionEntry(storePath, sessionKey);
const controller = new AbortController();
let open = true;
const settlement = persistCliSessionBindingResult({
sessionKey,
storePath,
expectedSession: entry,
provider: "claude-cli",
abortSignal: controller.signal,
assertSettlementCurrent: () => {
if (!open) {
throw new Error("owner closed");
}
},
result: {
meta: {
durationMs: 1,
agentMeta: {
provider: "claude-cli",
model: "claude-sonnet-4-6",
sessionId: "replacement-native-session",
cliSessionBinding: { sessionId: "replacement-native-session" },
...(operation.endsWith("clear") ? { clearCliSessionBinding: true } : {}),
},
},
},
});
// The row is unchanged; revocation happens after async patch planning starts.
if (operation.startsWith("closed")) {
open = false;
}
controller.abort(new Error("run aborted"));
if (operation === "aborted-clear") {
await settlement;
expect(
loadPersistedSessionEntry(storePath, sessionKey)?.cliSessionBindings,
).toBeUndefined();
} else {
expect(await settlement).toMatchObject({
meta: {
replayInvalid: true,
error: {
message: expect.stringContaining(
operation.startsWith("closed") ? "owner closed" : "run aborted",
),
fallbackSafe: false,
},
},
});
expect(loadPersistedSessionEntry(storePath, sessionKey)).toEqual(before);
}
});
},
);
});
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,
});
});
});
it.each(
(["consume", "restore", "successor"] as const).flatMap((operation) =>
(
[
"rebound",
"deleted",
"session-replaced",
"lifecycle-replaced",
"writer-replaced",
"claim-released",
] as const
).map((durableState) => ({ durableState, operation })),
),
)("rejects a stale $operation after the durable row is $durableState", async (testCase) => {
await withTempSessionStore(async ({ storePath }) => {
const { durableState, operation } = testCase;
const sessionKey = `agent:main:cli-fork-cas:${operation}`;
const sourceBinding = {
sessionId: "claude-source-session",
forceReuse: true,
...(operation === "consume" ? { forkNextResume: true as const } : {}),
};
const cached: SessionEntry = {
sessionId: "openclaw-session-1",
updatedAt: 1,
lifecycleRevision: "source-lifecycle",
activeWriterRunId: "source-writer",
cliSessionBindings: { "claude-cli": sourceBinding },
};
const rebound: SessionEntry = {
...cached,
cliSessionBindings: {
"claude-cli": { sessionId: "claude-other-session", forceReuse: true },
},
};
const sessionStore = { [sessionKey]: cached };
const ownerReplacement =
durableState === "session-replaced"
? { sessionId: "openclaw-session-2" }
: durableState === "lifecycle-replaced"
? { lifecycleRevision: "replacement-lifecycle" }
: durableState === "writer-replaced"
? { activeWriterRunId: "replacement-writer" }
: {};
const durableEntry =
durableState === "rebound" ? rebound : { ...cached, ...ownerReplacement };
if (durableState !== "deleted") {
await seedSessionStore(storePath, { [sessionKey]: durableEntry });
}
let open = true;
const common = {
provider: "claude-cli",
sessionKey,
sessionStore,
storePath,
expectedCliSessionId: "claude-source-session",
assertCommitAllowed: () => {
if (!open) {
throw new Error("claim released");
}
},
};
const result =
operation === "consume"
? consumeCliSessionForkInStore(common)
: operation === "restore"
? restoreCliSessionForkInStore(common)
: persistCliSessionForkSuccessorInStore({
...common,
successorCliSessionId: "claude-successor-session",
});
if (durableState === "claim-released") {
open = false;
await expect(result).rejects.toThrow("claim released");
} else {
expect(await result).toBeUndefined();
}
expect(sessionStore[sessionKey]).toEqual(cached);
expect(loadPersistedSessionEntry(storePath, sessionKey)).toEqual(
durableState === "deleted" ? undefined : expect.objectContaining(durableEntry),
);
});
});
});
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. */