diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index df5610d7666..7b2e402feaf 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -29,6 +29,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { bundleMcp: true, bundleMcpMode: "claude-config-file", nativeToolMode: "always-on", + ownsNativeCompaction: true, config: { command: "claude", args: [ diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 9419319e626..fa2f3e8f4b9 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -31,6 +31,7 @@ function createBackendEntry(params: { bundleMcpMode?: CliBundleMcpMode; defaultAuthProfileId?: string; authEpochMode?: CliBackendAuthEpochMode; + ownsNativeCompaction?: boolean; prepareExecution?: () => Promise; resolveExecutionArgs?: CliBackendResolveExecutionArgs; normalizeConfig?: ( @@ -48,6 +49,7 @@ function createBackendEntry(params: { ...(params.bundleMcpMode ? { bundleMcpMode: params.bundleMcpMode } : {}), ...(params.defaultAuthProfileId ? { defaultAuthProfileId: params.defaultAuthProfileId } : {}), ...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}), + ...(params.ownsNativeCompaction ? { ownsNativeCompaction: params.ownsNativeCompaction } : {}), ...(params.prepareExecution ? { prepareExecution: params.prepareExecution } : {}), ...(params.resolveExecutionArgs ? { resolveExecutionArgs: params.resolveExecutionArgs } : {}), ...(params.normalizeConfig ? { normalizeConfig: params.normalizeConfig } : {}), @@ -230,6 +232,7 @@ beforeEach(() => { id: "claude-cli", bundleMcp: true, bundleMcpMode: "claude-config-file", + ownsNativeCompaction: true, config: { command: "claude", args: [ @@ -546,6 +549,11 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions"); }); + it("declares ownsNativeCompaction for claude-cli", () => { + const resolved = requireCliBackendConfig("claude-cli"); + expect(resolved?.ownsNativeCompaction).toBe(true); + }); + it("keeps Claude permission mode unset when OpenClaw exec policy is not YOLO", () => { const resolved = requireCliBackendConfig("claude-cli", { tools: { exec: { security: "allowlist", ask: "on-miss" } }, diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index 44da751e317..a59aed5bdf8 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -46,6 +46,7 @@ export type ResolvedCliBackend = { defaultAuthProfileId?: string; authEpochMode?: CliBackendAuthEpochMode; contextEngineHostCapabilities?: readonly ContextEngineHostCapability[]; + ownsNativeCompaction?: boolean; prepareExecution?: CliBackendPlugin["prepareExecution"]; resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"]; nativeToolMode?: CliBackendNativeToolMode; @@ -79,6 +80,7 @@ type FallbackCliBackendPolicy = { defaultAuthProfileId?: string; authEpochMode?: CliBackendAuthEpochMode; contextEngineHostCapabilities?: readonly ContextEngineHostCapability[]; + ownsNativeCompaction?: boolean; prepareExecution?: CliBackendPlugin["prepareExecution"]; resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"]; nativeToolMode?: CliBackendNativeToolMode; @@ -119,6 +121,7 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic defaultAuthProfileId: entry.backend.defaultAuthProfileId, authEpochMode: entry.backend.authEpochMode, contextEngineHostCapabilities: entry.backend.contextEngineHostCapabilities, + ownsNativeCompaction: entry.backend.ownsNativeCompaction, prepareExecution: entry.backend.prepareExecution, resolveExecutionArgs: entry.backend.resolveExecutionArgs, nativeToolMode: entry.backend.nativeToolMode, @@ -411,6 +414,7 @@ export function resolveCliBackendConfig( defaultAuthProfileId: registered.defaultAuthProfileId, authEpochMode: registered.authEpochMode, contextEngineHostCapabilities: registered.contextEngineHostCapabilities, + ownsNativeCompaction: registered.ownsNativeCompaction, prepareExecution: registered.prepareExecution, resolveExecutionArgs: registered.resolveExecutionArgs, nativeToolMode: registered.nativeToolMode, @@ -443,6 +447,7 @@ export function resolveCliBackendConfig( defaultAuthProfileId: fallbackPolicy.defaultAuthProfileId, authEpochMode: fallbackPolicy.authEpochMode, contextEngineHostCapabilities: fallbackPolicy.contextEngineHostCapabilities, + ownsNativeCompaction: fallbackPolicy.ownsNativeCompaction, prepareExecution: fallbackPolicy.prepareExecution, resolveExecutionArgs: fallbackPolicy.resolveExecutionArgs, nativeToolMode: fallbackPolicy.nativeToolMode, @@ -472,6 +477,7 @@ export function resolveCliBackendConfig( defaultAuthProfileId: fallbackPolicy?.defaultAuthProfileId, authEpochMode: fallbackPolicy?.authEpochMode, contextEngineHostCapabilities: fallbackPolicy?.contextEngineHostCapabilities, + ownsNativeCompaction: fallbackPolicy?.ownsNativeCompaction, prepareExecution: fallbackPolicy?.prepareExecution, resolveExecutionArgs: fallbackPolicy?.resolveExecutionArgs, nativeToolMode: fallbackPolicy?.nativeToolMode, diff --git a/src/agents/command/cli-compaction.test.ts b/src/agents/command/cli-compaction.test.ts index cedd43d76e2..d00a29ae5af 100644 --- a/src/agents/command/cli-compaction.test.ts +++ b/src/agents/command/cli-compaction.test.ts @@ -76,6 +76,13 @@ describe("runCliTurnCompactionLifecycle", () => { beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-compaction-")); + // Default backends to non-owning so the context-engine compaction-path tests + // exercise that path. On current main resolveCliBackendConfig("claude-cli") + // resolves the (now ownsNativeCompaction) backend even in unit tests, which + // would otherwise route every claude-cli compaction test through the #88315 + // defer no-op. The ownsNativeCompaction-specific tests override this with an + // owning backend to exercise the defer. + setCliCompactionTestDeps({ resolveCliBackendConfig: () => null }); }); afterEach(async () => { @@ -1406,4 +1413,212 @@ describe("runCliTurnCompactionLifecycle", () => { "claude-session", ); }); + + it("skips compaction when backend declares ownsNativeCompaction and has no harness endpoint", async () => { + const sessionKey = "agent:main:claude-owns-compaction"; + const sessionId = "session-claude-owns"; + const sessionFile = path.join(tmpDir, "session-claude-owns.jsonl"); + const storePath = path.join(tmpDir, "sessions-claude-owns.json"); + await writeSessionFile({ sessionFile, sessionId }); + + const sessionEntry: SessionEntry = { + sessionId, + updatedAt: Date.now(), + sessionFile, + contextTokens: 1_000, + totalTokens: 950, + totalTokensFresh: true, + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const compactCalls: Array[0]> = []; + const compactAgentHarnessSession = vi.fn(); + const recordCliCompactionInStore = vi.fn(); + setCliCompactionTestDeps({ + resolveContextEngine: async () => buildContextEngine({ compactCalls }), + maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, + resolveCliBackendConfig: () => ({ + id: "claude-cli", + config: { command: "claude" }, + bundleMcp: true, + ownsNativeCompaction: true, + }), + createPreparedEmbeddedAgentSettingsManager: async () => ({ + getCompactionReserveTokens: () => 200, + getCompactionKeepRecentTokens: () => 0, + applyOverrides: () => {}, + }), + shouldPreemptivelyCompactBeforePrompt: () => ({ + route: "fits", + shouldCompact: false, + estimatedPromptTokens: 600, + promptBudgetBeforeReserve: 800, + overflowTokens: 0, + toolResultReducibleChars: 0, + effectiveReserveTokens: 200, + }), + resolveLiveToolResultMaxChars: () => 20_000, + recordCliCompactionInStore, + }); + + const updatedEntry = await runCliTurnCompactionLifecycle({ + cfg: {} as OpenClawConfig, + sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId: "main", + workspaceDir: tmpDir, + agentDir: tmpDir, + provider: "claude-cli", + model: "opus", + }); + + expect(compactAgentHarnessSession).not.toHaveBeenCalled(); + expect(compactCalls).toHaveLength(0); + expect(recordCliCompactionInStore).not.toHaveBeenCalled(); + expect(updatedEntry).toBe(sessionEntry); + }); + + it("does not skip compaction when backend does not declare ownsNativeCompaction", async () => { + const sessionKey = "agent:main:generic-no-ownership"; + const sessionId = "session-generic"; + const sessionFile = path.join(tmpDir, "session-generic.jsonl"); + const storePath = path.join(tmpDir, "sessions-generic.json"); + await writeSessionFile({ sessionFile, sessionId }); + + const sessionEntry: SessionEntry = { + sessionId, + updatedAt: Date.now(), + sessionFile, + contextTokens: 1_000, + totalTokens: 950, + totalTokensFresh: true, + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const compactCalls: Array[0]> = []; + const maintenance = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 })); + setCliCompactionTestDeps({ + resolveContextEngine: async () => buildContextEngine({ compactCalls }), + resolveCliBackendConfig: () => ({ + id: "generic-backend", + config: { command: "generic" }, + bundleMcp: false, + }), + createPreparedEmbeddedAgentSettingsManager: async () => ({ + getCompactionReserveTokens: () => 200, + getCompactionKeepRecentTokens: () => 0, + applyOverrides: () => {}, + }), + shouldPreemptivelyCompactBeforePrompt: () => ({ + route: "fits", + shouldCompact: false, + estimatedPromptTokens: 600, + promptBudgetBeforeReserve: 800, + overflowTokens: 0, + toolResultReducibleChars: 0, + effectiveReserveTokens: 200, + }), + resolveLiveToolResultMaxChars: () => 20_000, + runContextEngineMaintenance: maintenance, + }); + + await runCliTurnCompactionLifecycle({ + cfg: {} as OpenClawConfig, + sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId: "main", + workspaceDir: tmpDir, + agentDir: tmpDir, + provider: "generic-backend", + model: "model", + }); + + expect(compactCalls).toHaveLength(1); + }); + + it("still uses native harness path when backend declares ownsNativeCompaction and has agentHarnessId", async () => { + const sessionKey = "agent:main:codex-with-ownership"; + const sessionId = "session-codex-ownership"; + const sessionFile = path.join(tmpDir, "session-codex-ownership.jsonl"); + const storePath = path.join(tmpDir, "sessions-codex-ownership.json"); + await writeSessionFile({ sessionFile, sessionId }); + + const sessionEntry: SessionEntry = { + sessionId, + updatedAt: Date.now(), + sessionFile, + contextTokens: 1_000, + totalTokens: 950, + totalTokensFresh: true, + agentHarnessId: "codex", + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const compactCalls: Array[0]> = []; + const contextEngine = buildContextEngine({ compactCalls }); + const compactAgentHarnessSession = vi.fn(async () => ({ + ok: true, + compacted: true, + result: { tokensBefore: 950, tokensAfter: 100 }, + })); + const recordCliCompactionInStore = vi.fn(async () => ({ + ...sessionEntry, + compactionCount: 1, + })); + setCliCompactionTestDeps({ + resolveContextEngine: async () => contextEngine, + ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined), + maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, + resolveCliBackendConfig: () => ({ + id: "codex", + config: { command: "codex" }, + bundleMcp: false, + ownsNativeCompaction: true, + }), + createPreparedEmbeddedAgentSettingsManager: async () => ({ + getCompactionReserveTokens: () => 200, + getCompactionKeepRecentTokens: () => 0, + applyOverrides: () => {}, + }), + shouldPreemptivelyCompactBeforePrompt: () => ({ + route: "fits", + shouldCompact: false, + estimatedPromptTokens: 600, + promptBudgetBeforeReserve: 800, + overflowTokens: 0, + toolResultReducibleChars: 0, + effectiveReserveTokens: 200, + }), + resolveLiveToolResultMaxChars: () => 20_000, + applyAgentAutoCompactionGuard: vi.fn(async () => ({ supported: true, disabled: false })), + recordCliCompactionInStore, + }); + + await runCliTurnCompactionLifecycle({ + cfg: {} as OpenClawConfig, + sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId: "main", + workspaceDir: tmpDir, + agentDir: tmpDir, + provider: "openai", + model: "gpt-5.5", + }); + + expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1); + expect(compactCalls).toHaveLength(0); + expect(recordCliCompactionInStore).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/agents/command/cli-compaction.ts b/src/agents/command/cli-compaction.ts index bb86cc6d3c9..ce5cbce0d50 100644 --- a/src/agents/command/cli-compaction.ts +++ b/src/agents/command/cli-compaction.ts @@ -29,6 +29,7 @@ import { ensureSelectedAgentHarnessPlugin as ensureSelectedAgentHarnessPluginImp import { maybeCompactAgentHarnessSession as maybeCompactAgentHarnessSessionImpl } from "../harness/selection.js"; import type { AgentMessage } from "../runtime/index.js"; import { SessionManager } from "../sessions/session-manager.js"; +import { resolveCliBackendConfig as resolveCliBackendConfigImpl } from "../cli-backends.js"; import { clearCliSessionInStore as clearCliSessionInStoreImpl, recordCliCompactionInStore as recordCliCompactionInStoreImpl, @@ -69,6 +70,7 @@ type CliCompactionDeps = { ensureSelectedAgentHarnessPlugin: typeof ensureSelectedAgentHarnessPluginImpl; maybeCompactAgentHarnessSession: typeof maybeCompactAgentHarnessSessionImpl; clearCliSessionInStore: typeof clearCliSessionInStoreImpl; + resolveCliBackendConfig: typeof resolveCliBackendConfigImpl; recordCliCompactionInStore: typeof recordCliCompactionInStoreImpl; }; @@ -117,6 +119,7 @@ const cliCompactionDeps: CliCompactionDeps = { ensureSelectedAgentHarnessPlugin: ensureSelectedAgentHarnessPluginImpl, maybeCompactAgentHarnessSession: maybeCompactAgentHarnessSessionImpl, clearCliSessionInStore: clearCliSessionInStoreImpl, + resolveCliBackendConfig: resolveCliBackendConfigImpl, recordCliCompactionInStore: recordCliCompactionInStoreImpl, }; @@ -137,6 +140,7 @@ export function resetCliCompactionTestDeps(): void { ensureSelectedAgentHarnessPlugin: ensureSelectedAgentHarnessPluginImpl, maybeCompactAgentHarnessSession: maybeCompactAgentHarnessSessionImpl, clearCliSessionInStore: clearCliSessionInStoreImpl, + resolveCliBackendConfig: resolveCliBackendConfigImpl, recordCliCompactionInStore: recordCliCompactionInStoreImpl, }); } @@ -525,6 +529,21 @@ export async function runCliTurnCompactionLifecycle(params: { return params.sessionEntry; } + // When the backend declares native compaction ownership but has no harness + // compaction endpoint (e.g. claude-cli — Claude Code compacts its own + // transcript internally), skip both native-harness and context-engine + // compaction. The backend will handle it; OpenClaw returns a no-op. + const resolvedBackend = cliCompactionDeps.resolveCliBackendConfig(params.provider, params.cfg); + if ( + resolvedBackend?.ownsNativeCompaction && + !isNativeHarnessCompactionSession(params.sessionEntry, params.provider) + ) { + log.info( + `CLI backend "${params.provider}" owns native compaction — deferring to backend`, + ); + return params.sessionEntry; + } + let compacted = false; let nativeCompactionResult: EmbeddedAgentCompactResult | undefined; let useContextEngineCompaction = true; diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index f8175cf03dd..780a30530d4 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -82,6 +82,13 @@ export type CliBackendPlugin = { * driven through the generic CLI runner. */ contextEngineHostCapabilities?: readonly ContextEngineHostCapability[]; + /** + * When true, the backend manages its own transcript compaction lifecycle + * (e.g. Claude Code's internal auto-compaction). OpenClaw will skip its + * safeguard summarizer and return a no-op from the compaction path instead + * of fighting the backend's own compaction or hard-failing the turn. + */ + ownsNativeCompaction?: boolean; /** * Optional live-smoke metadata owned by the backend plugin. *