From 71ea4e3838c6e7c564e4fa4f24254519ee65c026 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 21:55:36 -0700 Subject: [PATCH] fix(agents): preserve native CLI compaction bindings (#123466) --- src/agents/command/cli-compaction.test.ts | 15 ++++++++------ src/agents/command/cli-compaction.ts | 21 ++++++++----------- src/agents/command/session-store.test.ts | 20 ++++++++++++++---- src/agents/command/session-store.ts | 25 ++++++++++++----------- 4 files changed, 47 insertions(+), 34 deletions(-) diff --git a/src/agents/command/cli-compaction.test.ts b/src/agents/command/cli-compaction.test.ts index 019724ae11e1..5887328fa8d3 100644 --- a/src/agents/command/cli-compaction.test.ts +++ b/src/agents/command/cli-compaction.test.ts @@ -391,6 +391,7 @@ describe("runCliTurnCompactionLifecycle", () => { ); expect(recordCliCompactionInStore).toHaveBeenCalledWith( expect.objectContaining({ + compactionKind: "context-engine", newSessionId: successorSessionId, tokensAfter: 100, }), @@ -494,12 +495,12 @@ describe("runCliTurnCompactionLifecycle", () => { }); it.each([ - ["agent", { agentId: "other" }], - ["session key", { sessionKey: "agent:main:other" }], - ["store", { storePath: "/tmp/other-openclaw-sessions.sqlite" }], - ])("rejects a CLI successor outside the active %s binding", async (_label, override) => { + ["agent", () => ({ agentId: "other" })], + ["session key", () => ({ sessionKey: "agent:main:other" })], + ["store", () => ({ storePath: path.join(tmpDir, "other-openclaw-sessions.sqlite") })], + ])("rejects a CLI successor outside the active %s binding", async (label, buildOverride) => { const scenario = await prepareContextSuccessorScenario({ - suffix: `outside-${_label.replace(" ", "-")}`, + suffix: `outside-${label.replace(" ", "-")}`, tmpDir, result: ({ sessionKey, storePath }) => ({ ok: true, @@ -511,7 +512,7 @@ describe("runCliTurnCompactionLifecycle", () => { sessionId: "outside-successor", sessionKey, storePath, - ...override, + ...buildOverride(), }, }, }), @@ -672,6 +673,7 @@ describe("runCliTurnCompactionLifecycle", () => { expect(recordCliCompactionInStore).toHaveBeenCalledTimes(1); expect(recordCliCompactionInStore).toHaveBeenCalledWith( expect.objectContaining({ + compactionKind: "native-harness", provider: "openai", sessionKey, tokensAfter: 100, @@ -924,6 +926,7 @@ describe("runCliTurnCompactionLifecycle", () => { expect(maintenance).toHaveBeenCalledTimes(1); expect(recordCliCompactionInStore).toHaveBeenCalledWith( expect.objectContaining({ + compactionKind: "context-engine", provider: "external-harness", sessionKey, tokensAfter: 100, diff --git a/src/agents/command/cli-compaction.ts b/src/agents/command/cli-compaction.ts index 2411aa8da63f..d15794e78e86 100644 --- a/src/agents/command/cli-compaction.ts +++ b/src/agents/command/cli-compaction.ts @@ -646,7 +646,7 @@ export async function runCliTurnCompactionLifecycle(params: { return params.sessionEntry; } - let compacted = false; + let compactionKind: "context-engine" | "native-harness" | undefined; let contextCompactionOutcome: CliTranscriptCompactionOutcome | undefined; let nativeCompactionResult: EmbeddedAgentCompactResult | undefined; let useContextEngineCompaction = true; @@ -695,7 +695,7 @@ export async function runCliTurnCompactionLifecycle(params: { extraSystemPrompt: params.extraSystemPrompt, }); if (nativeOutcome.compacted) { - compacted = true; + compactionKind = "native-harness"; nativeCompactionResult = nativeOutcome.result; useContextEngineCompaction = false; } else if (nativeOutcome.fallbackToContextEngine) { @@ -704,9 +704,7 @@ export async function runCliTurnCompactionLifecycle(params: { nativeFallbackNeedsBindingClear = nativeOutcome.clearCliSessionBinding === true; } else if (nativeOutcome.failureReason) { throw new Error( - `CLI native harness compaction failed for ${params.provider}/${params.model}: ${ - nativeOutcome.failureReason ?? "compaction did not reduce context" - }`, + `CLI native harness compaction failed for ${params.provider}/${params.model}: ${nativeOutcome.failureReason}`, ); } else { useContextEngineCompaction = false; @@ -749,17 +747,15 @@ export async function runCliTurnCompactionLifecycle(params: { bestEffortMaintenance: nativeFallbackToContextEngine, }); contextCompactionOutcome = contextOutcome; - compacted = contextOutcome.compacted; - if (!compacted && contextOutcome.failureReason) { + compactionKind = contextOutcome.compacted ? "context-engine" : undefined; + if (!compactionKind && contextOutcome.failureReason) { throw new Error( - `CLI transcript compaction failed for ${params.provider}/${params.model}: ${ - contextOutcome.failureReason ?? "compaction did not reduce context" - }`, + `CLI transcript compaction failed for ${params.provider}/${params.model}: ${contextOutcome.failureReason}`, ); } } - if (nativeFallbackNeedsBindingClear && !compacted && params.sessionStore && params.storePath) { + if (nativeFallbackNeedsBindingClear && !compactionKind && params.sessionStore) { return ( (await cliCompactionDeps.clearCliSessionInStore({ provider: params.provider, @@ -771,12 +767,13 @@ export async function runCliTurnCompactionLifecycle(params: { ); } - if (!compacted || !params.sessionStore || !params.storePath) { + if (!compactionKind || !params.sessionStore) { return params.sessionEntry; } return ( (await cliCompactionDeps.recordCliCompactionInStore({ + compactionKind, provider: params.provider, sessionKey: params.sessionKey, sessionStore: params.sessionStore, diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 3bf757ba1413..a0c1d829e199 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -2729,7 +2729,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }); describe("recordCliCompactionInStore", () => { - it("persists native compaction token counts and clears stale CLI usage breakdown", async () => { + 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"; @@ -2775,6 +2775,7 @@ describe("recordCliCompactionInStore", () => { await seedSessionStore(storePath, sessionStore); await recordCliCompactionInStore({ + compactionKind: "native-harness", provider: "codex", sessionKey, sessionStore, @@ -2791,11 +2792,17 @@ describe("recordCliCompactionInStore", () => { expect(sessionStore[sessionKey]?.cacheRead).toBeUndefined(); expect(sessionStore[sessionKey]?.cacheWrite).toBeUndefined(); expect(sessionStore[sessionKey]?.contextBudgetStatus).toBeUndefined(); - expect(sessionStore[sessionKey]?.cliSessionBindings?.codex).toBeUndefined(); - expect(sessionStore[sessionKey]?.cliSessionIds?.codex).toBeUndefined(); + expect(sessionStore[sessionKey]?.cliSessionBindings?.codex).toEqual({ + sessionId: "stale-cli-session", + }); + expect(sessionStore[sessionKey]?.cliSessionIds?.codex).toBe("stale-cli-session"); expect(persisted[sessionKey]?.totalTokens).toBe(0); expect(persisted[sessionKey]?.totalTokensFresh).toBe(true); expect(persisted[sessionKey]?.contextBudgetStatus).toBeUndefined(); + expect(persisted[sessionKey]?.cliSessionBindings?.codex).toEqual({ + sessionId: "stale-cli-session", + }); + expect(persisted[sessionKey]?.cliSessionIds?.codex).toBe("stale-cli-session"); }); }); @@ -2837,6 +2844,7 @@ describe("recordCliCompactionInStore", () => { await seedSessionStore(storePath, sessionStore); await recordCliCompactionInStore({ + compactionKind: "native-harness", provider: "codex", sessionKey, sessionStore, @@ -2872,6 +2880,7 @@ describe("recordCliCompactionInStore", () => { await seedSessionStore(storePath, sessionStore); await recordCliCompactionInStore({ + compactionKind: "native-harness", provider: "codex", sessionKey, sessionStore, @@ -2890,7 +2899,7 @@ describe("recordCliCompactionInStore", () => { }); }); - it("recreates a complete persisted row when the caller snapshot survived a missing store row", async () => { + it("recreates a complete persisted 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"; @@ -2918,6 +2927,7 @@ describe("recordCliCompactionInStore", () => { }; await recordCliCompactionInStore({ + compactionKind: "context-engine", provider: "codex", sessionKey, sessionStore, @@ -2938,6 +2948,7 @@ describe("recordCliCompactionInStore", () => { expect(persisted?.compactionCount).toBe(1); expect(persisted?.totalTokens).toBe(42); expect(persisted?.cliSessionBindings?.codex).toBeUndefined(); + expect(persisted?.cliSessionIds?.codex).toBeUndefined(); }); }); @@ -2956,6 +2967,7 @@ describe("recordCliCompactionInStore", () => { }; const result = await recordCliCompactionInStore({ + compactionKind: "context-engine", provider: "codex", sessionKey, sessionStore, diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 9f27a266d1ad..2e52608a9d46 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -494,6 +494,7 @@ export async function persistCliSessionForkSuccessorInStore(params: { /** Records CLI compaction metadata on the persisted session entry. */ export async function recordCliCompactionInStore(params: { + compactionKind: "context-engine" | "native-harness"; provider: string; sessionKey: string; sessionStore: Record; @@ -502,19 +503,23 @@ export async function recordCliCompactionInStore(params: { newSessionId?: string; expectedSessionId?: string; }): Promise { - const { provider, sessionKey, sessionStore, storePath, expectedSessionId } = params; + const { compactionKind, provider, sessionKey, sessionStore, storePath, expectedSessionId } = + params; const entry = sessionStore[sessionKey]; if (!entry) { return undefined; } const next = { ...entry }; - clearCliSession(next, provider); + // Context-engine compaction rewrites history outside the CLI process, invalidating its native + // session id. Native harness compaction updates that same session in place, so preserve it. + if (compactionKind === "context-engine") { + clearCliSession(next, provider); + } next.compactionCount = (entry.compactionCount ?? 0) + 1; next.updatedAt = Date.now(); const newSessionId = normalizeOptionalString(params.newSessionId); - const sessionIdChanged = Boolean(newSessionId && newSessionId !== entry.sessionId); - if (sessionIdChanged && newSessionId) { + if (newSessionId && newSessionId !== entry.sessionId) { delete (next as { sessionFile?: unknown }).sessionFile; next.sessionId = newSessionId; next.usageFamilyKey = entry.usageFamilyKey ?? sessionKey; @@ -524,21 +529,17 @@ export async function recordCliCompactionInStore(params: { } const tokensAfterCompaction = asNonNegativeFiniteNumber(params.tokensAfter); next.contextBudgetStatus = undefined; + next.inputTokens = undefined; + next.outputTokens = undefined; + next.cacheRead = undefined; + next.cacheWrite = undefined; if (tokensAfterCompaction !== undefined) { next.totalTokens = Math.floor(tokensAfterCompaction); next.totalTokensFresh = true; next.totalTokensVersion = SESSION_TOTAL_TOKENS_VERSION; - next.inputTokens = undefined; - next.outputTokens = undefined; - next.cacheRead = undefined; - next.cacheWrite = undefined; } else { next.totalTokensFresh = false; next.totalTokensVersion = undefined; - next.inputTokens = undefined; - next.outputTokens = undefined; - next.cacheRead = undefined; - next.cacheWrite = undefined; } const persisted = await patchSessionEntryCore(