From 940c359a36203ea3ce1d8b817623936b0cbce271 Mon Sep 17 00:00:00 2001 From: Vito Cappello Date: Sun, 6 Sep 2026 21:46:33 -0400 Subject: [PATCH] fix(anthropic): preserve resumed CLI caches across Git changes (#140566) * fix(anthropic): preserve resumed CLI caches across Git changes * test(anthropic): expect cache policy with context windows --------- Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> --- docs/gateway/cli-backends.md | 7 +++++ extensions/anthropic/cli-backend.ts | 26 ++++++++++--------- .../anthropic/cli-context-window.test.ts | 8 +++++- .../cli-shared.execution-env.test.ts | 14 +++++++++- extensions/anthropic/cli-shared.test.ts | 25 +++++++----------- src/node-host/invoke-agent-cli-claude.test.ts | 4 +++ src/node-host/invoke-agent-cli-claude.ts | 3 ++- 7 files changed, 57 insertions(+), 30 deletions(-) diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 37b41c2460a5..6702d50ce0d7 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -143,6 +143,13 @@ claude update The bundled `claude-cli` backend prefers Claude Code's native skill resolver. When the current skills snapshot has at least one selected skill with a materialized path, OpenClaw passes a temporary Claude Code plugin via `--plugin-dir` and omits the duplicate OpenClaw skills catalog from the appended system prompt. Without a materialized plugin skill, OpenClaw keeps the prompt catalog as a fallback. Skill env/API key overrides still apply to the child process environment for the run. +OpenClaw disables Claude Code's built-in Git workflow instructions and startup +Git-status snapshot with `CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1`. Claude Code +rebuilds that snapshot when a process resumes, so workspace edits or commits +would otherwise invalidate cached conversation history. Git tools and workspace +instructions remain available. This does not prevent cache misses after prompt +changes, compaction, model or thinking changes, or cache expiry. + OpenClaw always launches Claude Code with its default permission mode. OpenClaw's permission responses and `PreToolUse` hook keep native tools under host control, including when user or enterprise settings would otherwise diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index 1f5a614e352a..e6c015aa54ab 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -272,23 +272,25 @@ export function buildAnthropicCliBackend( } : undefined; const env = { + // Claude rebuilds the startup Git snapshot on process resume, rewriting + // the conversation prefix after workspace edits or commits. OpenClaw + // supplies workspace instructions; Git state can be read with tools. + CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS: "1", ...resolveClaudeCliAutoCompactEnv(context.contextTokenBudget), ...(context.contextWindow === "200k" ? { CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" } : {}), ...resolveClaudeCliThinkingEnv(context.thinkingLevel, context.modelId), ...authInput?.env, }; - return Object.keys(env).length > 0 || isolatedCompletion || cliExecution - ? { - env, - // The paired side-question argv projection disables settings, memory, - // hooks, session persistence, and tools before process launch. - ...(isolatedCompletion ? { isolatedCompletionEnforced: true as const } : {}), - ...(authInput?.clearEnv ? { clearEnv: authInput.clearEnv } : {}), - ...(authInput?.secretInput ? { secretInput: authInput.secretInput } : {}), - ...(authInput?.cleanup ? { cleanup: authInput.cleanup } : {}), - ...cliExecution, - } - : undefined; + return { + env, + // The paired side-question argv projection disables settings, memory, + // hooks, session persistence, and tools before process launch. + ...(isolatedCompletion ? { isolatedCompletionEnforced: true as const } : {}), + ...(authInput?.clearEnv ? { clearEnv: authInput.clearEnv } : {}), + ...(authInput?.secretInput ? { secretInput: authInput.secretInput } : {}), + ...(authInput?.cleanup ? { cleanup: authInput.cleanup } : {}), + ...cliExecution, + }; }; const supportProbe = options.ensureDynamicSystemPromptSectionsSupport?.(); return supportProbe ? supportProbe.then(prepare) : prepare(); diff --git a/extensions/anthropic/cli-context-window.test.ts b/extensions/anthropic/cli-context-window.test.ts index 5ca3290b6137..c085f484743b 100644 --- a/extensions/anthropic/cli-context-window.test.ts +++ b/extensions/anthropic/cli-context-window.test.ts @@ -25,6 +25,7 @@ describe("Claude CLI context-window selection", () => { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "200000", CLAUDE_CODE_DISABLE_1M_CONTEXT: "1", + CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS: "1", }, }); expect(resolveModelId?.({ modelId, contextWindow: "1m" })).toBe(`${modelId}[1m]`); @@ -36,7 +37,12 @@ describe("Claude CLI context-window selection", () => { contextWindow: "1m", contextTokenBudget: 1_000_000, }), - ).toEqual({ env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "1000000" } }); + ).toEqual({ + env: { + CLAUDE_CODE_AUTO_COMPACT_WINDOW: "1000000", + CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS: "1", + }, + }); expect(backend.config.clearEnv).toContain("CLAUDE_CODE_DISABLE_1M_CONTEXT"); expect(resolveModelId?.({ modelId: "claude-opus-4-8" })).toBe("claude-opus-4-8"); }, diff --git a/extensions/anthropic/cli-shared.execution-env.test.ts b/extensions/anthropic/cli-shared.execution-env.test.ts index e4acd399dc74..0f636569818a 100644 --- a/extensions/anthropic/cli-shared.execution-env.test.ts +++ b/extensions/anthropic/cli-shared.execution-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveClaudeCliThinkingEnv } from "./cli-shared.js"; +import { resolveClaudeCliAutoCompactEnv, resolveClaudeCliThinkingEnv } from "./cli-shared.js"; describe("Claude CLI execution environment", () => { it.each([ @@ -17,3 +17,15 @@ describe("Claude CLI execution environment", () => { }, ); }); + +describe("resolveClaudeCliAutoCompactEnv", () => { + it("maps the effective OpenClaw context budget into Claude Code compaction", () => { + expect(resolveClaudeCliAutoCompactEnv(100_000.9)).toEqual({ + CLAUDE_CODE_AUTO_COMPACT_WINDOW: "100000", + }); + }); + + it.each([undefined, 0, 0.5, Number.NaN])("rejects an invalid context budget: %s", (budget) => { + expect(resolveClaudeCliAutoCompactEnv(budget)).toBeUndefined(); + }); +}); diff --git a/extensions/anthropic/cli-shared.test.ts b/extensions/anthropic/cli-shared.test.ts index 337ee368c69f..971a6e8d56af 100644 --- a/extensions/anthropic/cli-shared.test.ts +++ b/extensions/anthropic/cli-shared.test.ts @@ -5,7 +5,6 @@ import { buildAnthropicCliBackend } from "./cli-backend.js"; import { CLAUDE_CLI_CLEAR_ENV, normalizeClaudeBackendConfig, - resolveClaudeCliAutoCompactEnv, resolveClaudeCliExecutionArgs, supportsClaudeDynamicSystemPromptSections, } from "./cli-shared.js"; @@ -70,7 +69,10 @@ describe("Claude CLI adapter equivalence", () => { isolatedCompletionSystemPrompt: string; }) as { env?: Record; isolatedCompletionEnforced?: true }; - expect(prepared).toEqual({ env: {}, isolatedCompletionEnforced: true }); + expect(prepared).toEqual({ + env: { CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS: "1" }, + isolatedCompletionEnforced: true, + }); }); it("builds Claude Code's native manual compaction command", () => { @@ -110,18 +112,6 @@ describe("Claude CLI adapter equivalence", () => { }); }); -describe("resolveClaudeCliAutoCompactEnv", () => { - it("maps the effective OpenClaw context budget into Claude Code compaction", () => { - expect(resolveClaudeCliAutoCompactEnv(100_000.9)).toEqual({ - CLAUDE_CODE_AUTO_COMPACT_WINDOW: "100000", - }); - }); - - it.each([undefined, 0, 0.5, Number.NaN])("rejects an invalid context budget: %s", (budget) => { - expect(resolveClaudeCliAutoCompactEnv(budget)).toBeUndefined(); - }); -}); - function expectDefaultDisallowedTools(args: readonly string[] | undefined) { const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1; expect(disallowedIndex).toBeGreaterThanOrEqual(0); @@ -918,7 +908,10 @@ describe("normalizeClaudeBackendConfig", () => { contextTokenBudget: 100_000, }), ).toEqual({ - env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "100000" }, + env: { + CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS: "1", + CLAUDE_CODE_AUTO_COMPACT_WINDOW: "100000", + }, }); }); @@ -948,6 +941,7 @@ describe("normalizeClaudeBackendConfig", () => { }) as ClaudePreparedExecutionWithSecret; expect(prepared.env).toEqual({ + CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS: "1", CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3", }); expect(prepared.env).not.toHaveProperty("CLAUDE_CODE_OAUTH_TOKEN"); @@ -1058,6 +1052,7 @@ describe("normalizeClaudeBackendConfig", () => { }) as ClaudePreparedExecutionWithSecret; expect(prepared.env).toEqual({ + CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS: "1", CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR: "3", }); expect(prepared.env).not.toHaveProperty("ANTHROPIC_API_KEY"); diff --git a/src/node-host/invoke-agent-cli-claude.test.ts b/src/node-host/invoke-agent-cli-claude.test.ts index 85c4c5c42281..a31433fae171 100644 --- a/src/node-host/invoke-agent-cli-claude.test.ts +++ b/src/node-host/invoke-agent-cli-claude.test.ts @@ -299,6 +299,7 @@ process.stdout.write(JSON.stringify({ descriptor: descriptor ?? null, rawPresent: Object.hasOwn(process.env, "CLAUDE_CODE_OAUTH_TOKEN") || Object.hasOwn(process.env, "ANTHROPIC_API_KEY"), scrubPresent: Object.hasOwn(process.env, "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB"), + gitInstructionsDisabled: process.env.CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS, }) + "\\n");`); const calls: Array<{ method: string; params: unknown }> = []; const handleSystemRun = vi.fn( @@ -356,6 +357,7 @@ process.stdout.write(JSON.stringify({ }); expect(progress).toContain('"rawPresent":false'); expect(progress).toContain('"scrubPresent":false'); + expect(progress).toContain('"gitInstructionsDisabled":"1"'); expect(calls).toContainEqual({ method: "node.invoke.result", params: expect.objectContaining({ @@ -520,6 +522,7 @@ process.stdout.write(JSON.stringify({ descriptor: process.env[${JSON.stringify(descriptorEnv)}], rawPresent: Object.hasOwn(process.env, ${JSON.stringify(rawEnv)}), scrubPresent: Object.hasOwn(process.env, "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB"), + gitInstructionsDisabled: process.env.CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS, }) + "\\n");`); const request = { argv: ["-p"], idleTimeoutMs: 1_000, timeoutMs: 5_000 }; const calls: Array<{ method: string; params: unknown }> = []; @@ -543,6 +546,7 @@ process.stdout.write(JSON.stringify({ expect(progress).toContain('"descriptor":"3"'); expect(progress).toContain('"rawPresent":false'); expect(progress).toContain('"scrubPresent":false'); + expect(progress).toContain('"gitInstructionsDisabled":"1"'); expect(result).toMatchObject({ exitCode: 0, success: true }); }, ); diff --git a/src/node-host/invoke-agent-cli-claude.ts b/src/node-host/invoke-agent-cli-claude.ts index 88397bd09b6e..d6193c90e90d 100644 --- a/src/node-host/invoke-agent-cli-claude.ts +++ b/src/node-host/invoke-agent-cli-claude.ts @@ -151,7 +151,8 @@ export async function runClaudeCliNodeCommand(params: { mode: "child", argv, cwd: params.cwd, - env: params.env, + // Apply the cache-stable Git policy locally without extending the node wire contract. + env: { ...(params.env ?? process.env), CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS: "1" }, exactEnv: true, input: skillSession?.rewriteReferences(params.request.stdin ?? "") ?? params.request.stdin ?? "",