From 92c0b0789a7a5d32f79adc83f9bdd69f9fb3e4d2 Mon Sep 17 00:00:00 2001 From: Per <123196452+noxaur@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:47:23 +0100 Subject: [PATCH 1/7] fix(claude): context meter follows the /autocompact window Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 77 ++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 105 +++++++++++++----- apps/web/src/lib/contextWindow.test.ts | 6 +- apps/web/src/lib/contextWindow.ts | 6 +- 4 files changed, 158 insertions(+), 36 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 40f33b3a1a30..b668aa108bba 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -738,7 +738,7 @@ describe("ClaudeAdapterV2 context usage", () => { }, CLAUDE_TEST_MODEL_SELECTION, "2026-08-29T00:03:00.000Z", - "", + undefined, DateTime.makeUnsafe("2026-08-29T00:00:00.000Z"), ); assert.strictEqual(usage.promptCacheExpiresAt, "2026-08-29T00:05:00.000Z"); @@ -770,7 +770,7 @@ describe("ClaudeAdapterV2 approval presentation", () => { permissionMode: "bypassPermissions", prompt: "Claude wants to run git push", callbackOptions: { - matchedAskRule: { source: "userSettings", toolName: "Bash", ruleContent: "git push:*" }, + matchedAskRule: { source: "user", toolName: "Bash", ruleContent: "git push:*" }, }, }); assert.deepEqual(presentation, { fullAccessOverride: "ask_rule" }); @@ -6086,18 +6086,79 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each([ + it.effect.each<{ + autoCompactWindow: string; + model: string; + threshold: number | undefined; + userSettings?: string; + projectSettings?: string; + launchArgs?: string; + }>([ { autoCompactWindow: "", model: "claude-sonnet-4-6", threshold: undefined }, { autoCompactWindow: "150000", model: "claude-sonnet-4-6", threshold: 150_000 }, { autoCompactWindow: "200000", model: "claude-sonnet-4-6", threshold: undefined }, { autoCompactWindow: "650000", model: "claude-sonnet-4-6", threshold: undefined }, { autoCompactWindow: "650000", model: "uncatalogued-model", threshold: 650_000 }, + // /autocompact writes the per-model value, which beats the file's top-level one. + { + autoCompactWindow: "", + model: "claude-opus-5-5", + userSettings: + '{"autoCompactWindow":300000,"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', + threshold: 400_000, + }, + { + autoCompactWindow: "", + model: "claude-opus-5-5", + userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', + projectSettings: '{"autoCompactWindow":300000}', + threshold: 300_000, + }, + { + autoCompactWindow: "", + model: "claude-opus-5-5", + userSettings: '{"autoCompactWindow":300000}', + projectSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":"auto"}}}', + threshold: undefined, + }, + { + autoCompactWindow: "", + model: "claude-opus-5-5", + userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', + launchArgs: `--settings '{"autoCompactWindow":250000}'`, + threshold: 250_000, + }, + { + autoCompactWindow: "250000", + model: "claude-opus-5-5", + userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', + threshold: 250_000, + }, ])( - "ignores stale snapshots after compaction and reports threshold $autoCompactWindow for $model", - ({ autoCompactWindow, model, threshold }) => + "ignores stale snapshots after compaction and reports threshold $threshold for $model", + ({ + autoCompactWindow, + model, + threshold, + userSettings = "{}", + projectSettings, + launchArgs = "", + }) => Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-compact-" }); + const project = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-compact-" }); + yield* fileSystem.writeFileString(path.join(home, "settings.json"), userSettings); + if (projectSettings !== undefined) { + yield* fileSystem.makeDirectory(path.join(project, ".claude")); + yield* fileSystem.writeFileString( + path.join(project, ".claude", "settings.json"), + projectSettings, + ); + } const harness = yield* makeWakeHarnessWithOptions({ - settings: { ...DEFAULT_CLAUDE_SETTINGS, autoCompactWindow }, + settings: { ...DEFAULT_CLAUDE_SETTINGS, autoCompactWindow, homePath: home, launchArgs }, }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -6108,6 +6169,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { text: "Continue.", attachments: [], modelSelection: { ...CLAUDE_TEST_MODEL_SELECTION, model }, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + ...CLAUDE_TEST_RUNTIME_POLICY, + cwd: project, + }), }), ); const usage = { ...zeroUsage, input_tokens: 1_000, output_tokens: 75 }; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f42f37d1ab1b..c0adcbbd7da2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -231,10 +231,14 @@ function claudePromptCacheTtlMs( } // Claude caps the window at the model's context, so a setting at or above it never applies. -function claudeAutoCompactThreshold(autoCompactWindow: string, maxTokens: number | null) { - const threshold = Number(autoCompactWindow); - return threshold > 0 && (maxTokens === null || threshold < maxTokens) - ? { autoCompactThreshold: threshold } +function claudeAutoCompactThreshold( + autoCompactWindow: number | undefined, + maxTokens: number | null, +) { + return autoCompactWindow !== undefined && + autoCompactWindow > 0 && + (maxTokens === null || autoCompactWindow < maxTokens) + ? { autoCompactThreshold: autoCompactWindow } : {}; } @@ -260,7 +264,7 @@ export function claudeProviderTurnTokenUsage( }, modelSelection: ModelSelection, updatedAt: string, - autoCompactWindow = "", + autoCompactWindow?: number, requestedAt?: DateTime.Utc, ) { const inputTokens = claudeMessageInputTokens(usage); @@ -2619,6 +2623,20 @@ const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect( ), ), ); +const ClaudeAutoCompactWindowSetting = Schema.Union([Schema.Finite, Schema.Literal("auto")]); +const ClaudeAutoCompactSettings = Schema.Struct({ + autoCompactWindow: Schema.optionalKey(ClaudeAutoCompactWindowSetting), + modelSettings: Schema.optionalKey( + Schema.Record( + Schema.String, + Schema.Struct({ autoCompactWindow: Schema.optionalKey(ClaudeAutoCompactWindowSetting) }), + ), + ), +}); +const decodeClaudeAutoCompactSettings = Schema.decodeUnknownOption(ClaudeAutoCompactSettings); +const decodeClaudeAutoCompactSettingsFile = Schema.decodeUnknownEffect( + fromLenientJson(ClaudeAutoCompactSettings), +); const decodeClaudeEnvSettings = Schema.decodeUnknownEffect( fromLenientJson( Schema.StructWithRest( @@ -3898,6 +3916,7 @@ interface ActiveClaudeTurnContext { { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string } >; }; + readonly autoCompactWindow: number | undefined; // Snapshots repeat message_start input, sometimes as zeros, so only fill gaps in streamed usage. // Compaction clears usage without forgetting the root request. // `requestedAt` starts the request's prompt cache lifetime. @@ -4509,25 +4528,26 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (path.dirname(directory) === directory) return cwd; } }); - // Settings env beats the process env, --settings over local over project over user, and a local - // file an older Claude Code left in cwd applies under the root one. resolveSettings reads local - // from cwd only. + // Local over project over user, and a local file an older Claude Code left in cwd applies under + // the root one. resolveSettings reads local from cwd only. + const settingsFilesByPrecedence = Effect.fn("ClaudeAdapterV2.settingsFilesByPrecedence")( + function* (cwd: string | null) { + if (cwd === null) return [path.join(homePath, "settings.json")]; + const localDirectory = yield* localSettingsDirectory(cwd); + return [ + path.join(localDirectory, ".claude", "settings.local.json"), + ...(localDirectory === cwd ? [] : [path.join(cwd, ".claude", "settings.local.json")]), + path.join(cwd, ".claude", "settings.json"), + path.join(homePath, "settings.json"), + ]; + }, + ); + // Settings env beats the process env, and --settings beats every settings file. const resolveToolSearchOverride = Effect.fn("ClaudeAdapterV2.resolveToolSearchOverride")( function* (cwd: string | null, launchArgSettings: ClaudeSdkSettings | undefined) { const launchValue = launchArgSettings?.env?.ENABLE_TOOL_SEARCH; if (typeof launchValue === "string") return claudeToolSearchOverride(launchValue); - const localDirectory = cwd === null ? null : yield* localSettingsDirectory(cwd); - const settingsFiles = [ - ...(cwd === null || localDirectory === null - ? [] - : [ - path.join(localDirectory, ".claude", "settings.local.json"), - ...(localDirectory === cwd ? [] : [path.join(cwd, ".claude", "settings.local.json")]), - path.join(cwd, ".claude", "settings.json"), - ]), - path.join(homePath, "settings.json"), - ]; - for (const settingsFile of settingsFiles) { + for (const settingsFile of yield* settingsFilesByPrecedence(cwd)) { const value = yield* readToolSearchSetting(settingsFile); if (value !== undefined) return claudeToolSearchOverride(value); } @@ -4535,6 +4555,39 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, ); + // Claude Code's auto-compact window: its env var, then T3's setting (sent as flag settings), then + // --settings, then settings files. In one source, modelSettings..autoCompactWindow (what + // /autocompact writes) replaces the top-level value; "auto" keeps Claude's tuned window. + const resolveAutoCompactWindow = Effect.fn("ClaudeAdapterV2.resolveAutoCompactWindow")(function* ( + cwd: string | null, + modelSelection: ModelSelection, + ) { + const fromEnvironment = Number(adapterOptions.environment.CLAUDE_CODE_AUTO_COMPACT_WINDOW); + if (fromEnvironment > 0) return fromEnvironment; + const fromT3 = Number(adapterOptions.settings.autoCompactWindow); + if (fromT3 > 0) return fromT3; + const model = compileClaudeModelSelection(modelSelection).apiModelId.replace(/\[1m\]$/i, ""); + const windowIn = (settings: typeof ClaudeAutoCompactSettings.Type | undefined) => + settings?.modelSettings?.[model]?.autoCompactWindow ?? settings?.autoCompactWindow; + const launchArgSettings = yield* readClaudeLaunchArgSettings({ + settings: adapterOptions.settings, + cwd, + fileSystem, + path, + }).pipe(Effect.orElseSucceed(() => undefined)); + let value = windowIn(Option.getOrUndefined(decodeClaudeAutoCompactSettings(launchArgSettings))); + for (const settingsFile of yield* settingsFilesByPrecedence(cwd)) { + if (value !== undefined) break; + value = windowIn( + yield* fileSystem.readFileString(settingsFile).pipe( + Effect.flatMap(decodeClaudeAutoCompactSettingsFile), + Effect.orElseSucceed(() => undefined), + ), + ); + } + return value === "auto" ? undefined : value; + }); + const userInvocableSkills = (cwd: string | null) => discoverClaudeSkills( adapterOptions.settings, @@ -4916,7 +4969,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( usage, context.input.modelSelection, DateTime.formatIso(now), - adapterOptions.settings.autoCompactWindow, + context.autoCompactWindow, context.streamUsage?.requestedAt, ); if (tokenUsage.inputTokens <= 0) return; @@ -8495,10 +8548,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( tokenUsage: { usedTokens: afterTokenCount, maxTokens, - ...claudeAutoCompactThreshold( - adapterOptions.settings.autoCompactWindow, - maxTokens, - ), + ...claudeAutoCompactThreshold(context.autoCompactWindow, maxTokens), updatedAt: DateTime.formatIso(now), }, }, @@ -10172,8 +10222,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.provideService(Path.Path, path), ); const querySession = yield* openQuery(turnInput, nativeThreadId); + const autoCompactWindow = yield* resolveAutoCompactWindow( + turnInput.runtimePolicy.cwd, + turnInput.modelSelection, + ); const context: ActiveClaudeTurnContext = { input: turnInput, + autoCompactWindow, queryPolicy: querySession.queryPolicy, nativeThreadId, nativeTurnId, diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index c27ddd9a0465..b9f52f208239 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -60,9 +60,9 @@ describe("live provider-turn usage (#8144)", () => { const snapshot = deriveLatestContextWindowSnapshot([], usage); expect(snapshot).not.toBeNull(); expect(snapshot?.usedTokens).toBe(42_000); - expect(snapshot?.maxTokens).toBe(200_000); - expect(snapshot?.remainingTokens).toBe(158_000); - expect(snapshot?.usedPercentage).toBe(21); + expect(snapshot?.maxTokens).toBe(150_000); + expect(snapshot?.remainingTokens).toBe(108_000); + expect(snapshot?.usedPercentage).toBeCloseTo(28); expect(snapshot?.autoCompactThreshold).toBe(150_000); expect( formatContextWindowCompactionMessage("Claude Sonnet 4.6", snapshot?.autoCompactThreshold), diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 8c449249e033..a04a434f9462 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -63,7 +63,8 @@ export function deriveLatestContextWindowSnapshot( ): ContextWindowSnapshot | null { if (liveUsage != null) { const usedTokens = Math.max(0, liveUsage.usedTokens); - const maxTokens = liveUsage.maxTokens ?? null; + // Like Claude's /context, measure against the auto-compact window when it is smaller. + const maxTokens = liveUsage.autoCompactThreshold ?? liveUsage.maxTokens ?? null; const usedPercentage = maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; const remainingTokens = @@ -100,7 +101,8 @@ export function deriveLatestContextWindowSnapshot( providerUsage !== undefined && providerUsageUpdatedAt !== undefined ) { - const maxTokens = asFiniteNumber(providerUsage.maxTokens); + const maxTokens = + asFiniteNumber(providerUsage.autoCompactThreshold) ?? asFiniteNumber(providerUsage.maxTokens); const usedTokens = providerUsage.usedTokens; const usedPercentage = maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; From b792d6d7ad1dc6d1da12c3c3bb1524d46fc6e69f Mon Sep 17 00:00:00 2001 From: Per <123196452+noxaur@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:04:19 +0100 Subject: [PATCH 2/7] fix(claude): read --autocompact and Claude's window formats Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 38 ++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 43 ++++++++++++++++--- apps/web/src/lib/contextWindow.test.ts | 4 +- 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 5519c9ad5911..8608ffc5ca19 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6360,6 +6360,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { userSettings?: string; projectSettings?: string; launchArgs?: string; + environment?: NodeJS.ProcessEnv; }>([ { autoCompactWindow: "", model: "claude-sonnet-4-6", threshold: undefined }, { autoCompactWindow: "150000", model: "claude-sonnet-4-6", threshold: 150_000 }, @@ -6401,6 +6402,41 @@ describe("ClaudeAdapterV2 background wake turns", () => { userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', threshold: 250_000, }, + { + autoCompactWindow: "", + model: "claude-opus-5-5", + userSettings: '{"autoCompactWindow":150000.5}', + threshold: undefined, + }, + // --autocompact reads 100 to 1000 as thousands and beats T3's setting and settings files. + { + autoCompactWindow: "300000", + model: "claude-opus-5-5", + userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', + launchArgs: "--autocompact 250", + threshold: 250_000, + }, + { + autoCompactWindow: "", + model: "claude-opus-5-5", + userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', + launchArgs: "--autocompact auto", + threshold: undefined, + }, + // The env var beats every other source, takes k/M suffixes, and clamps to 100k. + { + autoCompactWindow: "", + model: "claude-opus-5-5", + launchArgs: "--autocompact 250k", + environment: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "200k" }, + threshold: 200_000, + }, + { + autoCompactWindow: "", + model: "claude-opus-5-5", + environment: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "200" }, + threshold: 100_000, + }, ])( "ignores stale snapshots after compaction and reports threshold $threshold for $model", ({ @@ -6410,6 +6446,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { userSettings = "{}", projectSettings, launchArgs = "", + environment, }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -6426,6 +6463,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { } const harness = yield* makeWakeHarnessWithOptions({ settings: { ...DEFAULT_CLAUDE_SETTINGS, autoCompactWindow, homePath: home, launchArgs }, + ...(environment === undefined ? {} : { environment }), }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 3543dd6f6df8..2970265d5025 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -233,6 +233,28 @@ function claudePromptCacheTtlMs( return undefined; } +// Claude reads a token count or a k/M suffix and clamps it to 100k-1M. Only --autocompact also +// takes "auto" and reads 100 to 1000 as thousands. +function parseClaudeAutoCompactWindow(raw: string | null | undefined, source: "env" | "flag") { + const value = raw?.trim().toLowerCase(); + if (value === "auto") return source === "flag" ? "auto" : undefined; + const match = value === undefined ? null : /^(\d+(?:\.\d+)?)([km])?$/.exec(value); + if (match === null) return undefined; + const amount = Number(match[1]); + const scale = + match[2] === "k" + ? 1_000 + : match[2] === "m" + ? 1_000_000 + : source === "flag" && amount >= 100 && amount <= 1_000 + ? 1_000 + : 1; + const tokens = amount * scale; + return Number.isInteger(tokens) && tokens > 0 + ? Math.min(1_000_000, Math.max(100_000, tokens)) + : undefined; +} + // Claude caps the window at the model's context, so a setting at or above it never applies. function claudeAutoCompactThreshold( autoCompactWindow: number | undefined, @@ -2628,7 +2650,7 @@ const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect( ), ), ); -const ClaudeAutoCompactWindowSetting = Schema.Union([Schema.Finite, Schema.Literal("auto")]); +const ClaudeAutoCompactWindowSetting = Schema.Union([Schema.Int, Schema.Literal("auto")]); const ClaudeAutoCompactSettings = Schema.Struct({ autoCompactWindow: Schema.optionalKey(ClaudeAutoCompactWindowSetting), modelSettings: Schema.optionalKey( @@ -4623,15 +4645,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, ); - // Claude Code's auto-compact window: its env var, then T3's setting (sent as flag settings), then - // --settings, then settings files. In one source, modelSettings..autoCompactWindow (what - // /autocompact writes) replaces the top-level value; "auto" keeps Claude's tuned window. + // Claude Code's auto-compact window: its env var, then --autocompact, then T3's setting (sent as + // flag settings), then --settings, then settings files. In one source, + // modelSettings..autoCompactWindow (what /autocompact writes) replaces the top-level + // value; "auto" keeps Claude's tuned window. const resolveAutoCompactWindow = Effect.fn("ClaudeAdapterV2.resolveAutoCompactWindow")(function* ( cwd: string | null, modelSelection: ModelSelection, ) { - const fromEnvironment = Number(adapterOptions.environment.CLAUDE_CODE_AUTO_COMPACT_WINDOW); - if (fromEnvironment > 0) return fromEnvironment; + const fromEnvironment = parseClaudeAutoCompactWindow( + adapterOptions.environment.CLAUDE_CODE_AUTO_COMPACT_WINDOW, + "env", + ); + if (typeof fromEnvironment === "number") return fromEnvironment; + const fromFlag = parseClaudeAutoCompactWindow( + parseCliArgs(adapterOptions.settings.launchArgs).flags.autocompact, + "flag", + ); + if (fromFlag !== undefined) return fromFlag === "auto" ? undefined : fromFlag; const fromT3 = Number(adapterOptions.settings.autoCompactWindow); if (fromT3 > 0) return fromT3; const model = compileClaudeModelSelection(modelSelection).apiModelId.replace(/\[1m\]$/i, ""); diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index b9f52f208239..08a4cc50efe7 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -149,7 +149,8 @@ describe("latestProviderTurnTokenUsage", () => { const providerThread = { contextUsage: { usedTokens, - maxTokens: 200_000, + maxTokens: 1_000_000, + autoCompactThreshold: 200_000, cost: { amount: 0.42, currency: "USD" }, }, updatedAt: DateTime.makeUnsafe("2026-10-19T12:10:00.000Z"), @@ -160,6 +161,7 @@ describe("latestProviderTurnTokenUsage", () => { expect(snapshot).toMatchObject({ usedTokens, maxTokens: 200_000, + remainingTokens: 200_000 - usedTokens, cost: { amount: 0.42, currency: "USD" }, updatedAt: "2026-10-19T12:10:00.000Z", }); From 4a16f3c2b8d768f1645af6c2dc76e4428b52c4e4 Mon Sep 17 00:00:00 2001 From: Per <123196452+noxaur@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:31:55 +0100 Subject: [PATCH 3/7] fix(claude): limit the window lookup to Claude's settings files Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 51 +--------------- .../Adapters/ClaudeAdapterV2.ts | 61 +++---------------- 2 files changed, 11 insertions(+), 101 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 8608ffc5ca19..50762f3f1982 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6359,8 +6359,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { threshold: number | undefined; userSettings?: string; projectSettings?: string; - launchArgs?: string; - environment?: NodeJS.ProcessEnv; }>([ { autoCompactWindow: "", model: "claude-sonnet-4-6", threshold: undefined }, { autoCompactWindow: "150000", model: "claude-sonnet-4-6", threshold: 150_000 }, @@ -6389,13 +6387,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { projectSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":"auto"}}}', threshold: undefined, }, - { - autoCompactWindow: "", - model: "claude-opus-5-5", - userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', - launchArgs: `--settings '{"autoCompactWindow":250000}'`, - threshold: 250_000, - }, { autoCompactWindow: "250000", model: "claude-opus-5-5", @@ -6408,46 +6399,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { userSettings: '{"autoCompactWindow":150000.5}', threshold: undefined, }, - // --autocompact reads 100 to 1000 as thousands and beats T3's setting and settings files. - { - autoCompactWindow: "300000", - model: "claude-opus-5-5", - userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', - launchArgs: "--autocompact 250", - threshold: 250_000, - }, - { - autoCompactWindow: "", - model: "claude-opus-5-5", - userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', - launchArgs: "--autocompact auto", - threshold: undefined, - }, - // The env var beats every other source, takes k/M suffixes, and clamps to 100k. - { - autoCompactWindow: "", - model: "claude-opus-5-5", - launchArgs: "--autocompact 250k", - environment: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "200k" }, - threshold: 200_000, - }, - { - autoCompactWindow: "", - model: "claude-opus-5-5", - environment: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "200" }, - threshold: 100_000, - }, ])( "ignores stale snapshots after compaction and reports threshold $threshold for $model", - ({ - autoCompactWindow, - model, - threshold, - userSettings = "{}", - projectSettings, - launchArgs = "", - environment, - }) => + ({ autoCompactWindow, model, threshold, userSettings = "{}", projectSettings }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -6462,8 +6416,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); } const harness = yield* makeWakeHarnessWithOptions({ - settings: { ...DEFAULT_CLAUDE_SETTINGS, autoCompactWindow, homePath: home, launchArgs }, - ...(environment === undefined ? {} : { environment }), + settings: { ...DEFAULT_CLAUDE_SETTINGS, autoCompactWindow, homePath: home }, }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 2970265d5025..71afb4c14d94 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -233,28 +233,6 @@ function claudePromptCacheTtlMs( return undefined; } -// Claude reads a token count or a k/M suffix and clamps it to 100k-1M. Only --autocompact also -// takes "auto" and reads 100 to 1000 as thousands. -function parseClaudeAutoCompactWindow(raw: string | null | undefined, source: "env" | "flag") { - const value = raw?.trim().toLowerCase(); - if (value === "auto") return source === "flag" ? "auto" : undefined; - const match = value === undefined ? null : /^(\d+(?:\.\d+)?)([km])?$/.exec(value); - if (match === null) return undefined; - const amount = Number(match[1]); - const scale = - match[2] === "k" - ? 1_000 - : match[2] === "m" - ? 1_000_000 - : source === "flag" && amount >= 100 && amount <= 1_000 - ? 1_000 - : 1; - const tokens = amount * scale; - return Number.isInteger(tokens) && tokens > 0 - ? Math.min(1_000_000, Math.max(100_000, tokens)) - : undefined; -} - // Claude caps the window at the model's context, so a setting at or above it never applies. function claudeAutoCompactThreshold( autoCompactWindow: number | undefined, @@ -2660,8 +2638,7 @@ const ClaudeAutoCompactSettings = Schema.Struct({ ), ), }); -const decodeClaudeAutoCompactSettings = Schema.decodeUnknownOption(ClaudeAutoCompactSettings); -const decodeClaudeAutoCompactSettingsFile = Schema.decodeUnknownEffect( +const decodeClaudeAutoCompactSettings = Schema.decodeUnknownEffect( fromLenientJson(ClaudeAutoCompactSettings), ); const decodeClaudeEnvSettings = Schema.decodeUnknownEffect( @@ -4645,46 +4622,26 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, ); - // Claude Code's auto-compact window: its env var, then --autocompact, then T3's setting (sent as - // flag settings), then --settings, then settings files. In one source, + // T3's setting is sent as flag settings, so it beats every settings file. In one file, // modelSettings..autoCompactWindow (what /autocompact writes) replaces the top-level // value; "auto" keeps Claude's tuned window. const resolveAutoCompactWindow = Effect.fn("ClaudeAdapterV2.resolveAutoCompactWindow")(function* ( cwd: string | null, modelSelection: ModelSelection, ) { - const fromEnvironment = parseClaudeAutoCompactWindow( - adapterOptions.environment.CLAUDE_CODE_AUTO_COMPACT_WINDOW, - "env", - ); - if (typeof fromEnvironment === "number") return fromEnvironment; - const fromFlag = parseClaudeAutoCompactWindow( - parseCliArgs(adapterOptions.settings.launchArgs).flags.autocompact, - "flag", - ); - if (fromFlag !== undefined) return fromFlag === "auto" ? undefined : fromFlag; const fromT3 = Number(adapterOptions.settings.autoCompactWindow); if (fromT3 > 0) return fromT3; const model = compileClaudeModelSelection(modelSelection).apiModelId.replace(/\[1m\]$/i, ""); - const windowIn = (settings: typeof ClaudeAutoCompactSettings.Type | undefined) => - settings?.modelSettings?.[model]?.autoCompactWindow ?? settings?.autoCompactWindow; - const launchArgSettings = yield* readClaudeLaunchArgSettings({ - settings: adapterOptions.settings, - cwd, - fileSystem, - path, - }).pipe(Effect.orElseSucceed(() => undefined)); - let value = windowIn(Option.getOrUndefined(decodeClaudeAutoCompactSettings(launchArgSettings))); for (const settingsFile of yield* settingsFilesByPrecedence(cwd)) { - if (value !== undefined) break; - value = windowIn( - yield* fileSystem.readFileString(settingsFile).pipe( - Effect.flatMap(decodeClaudeAutoCompactSettingsFile), - Effect.orElseSucceed(() => undefined), - ), + const settings = yield* fileSystem.readFileString(settingsFile).pipe( + Effect.flatMap(decodeClaudeAutoCompactSettings), + Effect.orElseSucceed(() => undefined), ); + const value = + settings?.modelSettings?.[model]?.autoCompactWindow ?? settings?.autoCompactWindow; + if (value !== undefined) return value === "auto" ? undefined : value; } - return value === "auto" ? undefined : value; + return undefined; }); const userInvocableSkills = (cwd: string | null) => From a643c48fac029d5ade4ef29b8a4454758beb4632 Mon Sep 17 00:00:00 2001 From: Per <123196452+noxaur@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:01:10 +0100 Subject: [PATCH 4/7] fix(claude): a bad modelSettings entry no longer hides the window Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 2 +- .../Adapters/ClaudeAdapterV2.ts | 32 ++++++++++++------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 50762f3f1982..92c3744c7f1c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6370,7 +6370,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { autoCompactWindow: "", model: "claude-opus-5-5", userSettings: - '{"autoCompactWindow":300000,"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', + '{"autoCompactWindow":300000,"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000},"claude-sonnet-5-5":{"autoCompactWindow":"big"}}}', threshold: 400_000, }, { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 71afb4c14d94..33635dcf5c60 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2629,18 +2629,19 @@ const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect( ), ); const ClaudeAutoCompactWindowSetting = Schema.Union([Schema.Int, Schema.Literal("auto")]); -const ClaudeAutoCompactSettings = Schema.Struct({ - autoCompactWindow: Schema.optionalKey(ClaudeAutoCompactWindowSetting), - modelSettings: Schema.optionalKey( - Schema.Record( - Schema.String, - Schema.Struct({ autoCompactWindow: Schema.optionalKey(ClaudeAutoCompactWindowSetting) }), - ), - ), -}); +// Values are checked one at a time, so a bad entry for another model can't hide this one. const decodeClaudeAutoCompactSettings = Schema.decodeUnknownEffect( - fromLenientJson(ClaudeAutoCompactSettings), + fromLenientJson( + Schema.Struct({ + autoCompactWindow: Schema.optionalKey(Schema.Unknown), + modelSettings: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + }), + ), +); +const decodeClaudeModelAutoCompactSettings = Schema.decodeUnknownOption( + Schema.Struct({ autoCompactWindow: ClaudeAutoCompactWindowSetting }), ); +const decodeClaudeAutoCompactWindow = Schema.decodeUnknownOption(ClaudeAutoCompactWindowSetting); const decodeClaudeEnvSettings = Schema.decodeUnknownEffect( fromLenientJson( Schema.StructWithRest( @@ -4637,8 +4638,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.flatMap(decodeClaudeAutoCompactSettings), Effect.orElseSucceed(() => undefined), ); - const value = - settings?.modelSettings?.[model]?.autoCompactWindow ?? settings?.autoCompactWindow; + const value = Option.getOrUndefined( + Option.orElse( + Option.map( + decodeClaudeModelAutoCompactSettings(settings?.modelSettings?.[model]), + (entry) => entry.autoCompactWindow, + ), + () => decodeClaudeAutoCompactWindow(settings?.autoCompactWindow), + ), + ); if (value !== undefined) return value === "auto" ? undefined : value; } return undefined; From 09c5ad727f759462e3f590ed8a060e4e53e55b04 Mon Sep 17 00:00:00 2001 From: Per <123196452+noxaur@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:48:17 +0100 Subject: [PATCH 5/7] fix(claude): report the auto-compact window as the context size Like Claude's /context, the meter measures usage against the window, so the web change is no longer needed. The window is also read before the query opens. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 47 +++++++++++++++---- .../Adapters/ClaudeAdapterV2.ts | 25 +++++----- apps/web/src/lib/contextWindow.test.ts | 10 ++-- apps/web/src/lib/contextWindow.ts | 6 +-- 4 files changed, 58 insertions(+), 30 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 5e8aea9ec4d0..43a710c7b331 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6517,14 +6517,35 @@ describe("ClaudeAdapterV2 background wake turns", () => { autoCompactWindow: string; model: string; threshold: number | undefined; + maxTokens: number; userSettings?: string; projectSettings?: string; }>([ - { autoCompactWindow: "", model: "claude-sonnet-4-6", threshold: undefined }, - { autoCompactWindow: "150000", model: "claude-sonnet-4-6", threshold: 117_000 }, - { autoCompactWindow: "200000", model: "claude-sonnet-4-6", threshold: undefined }, - { autoCompactWindow: "650000", model: "claude-sonnet-4-6", threshold: undefined }, - { autoCompactWindow: "650000", model: "uncatalogued-model", threshold: 617_000 }, + { autoCompactWindow: "", model: "claude-sonnet-4-6", threshold: undefined, maxTokens: 200_000 }, + { + autoCompactWindow: "150000", + model: "claude-sonnet-4-6", + threshold: 117_000, + maxTokens: 150_000, + }, + { + autoCompactWindow: "200000", + model: "claude-sonnet-4-6", + threshold: undefined, + maxTokens: 200_000, + }, + { + autoCompactWindow: "650000", + model: "claude-sonnet-4-6", + threshold: undefined, + maxTokens: 200_000, + }, + { + autoCompactWindow: "650000", + model: "uncatalogued-model", + threshold: 617_000, + maxTokens: 650_000, + }, // /autocompact writes the per-model value, which beats the file's top-level one. { autoCompactWindow: "", @@ -6532,6 +6553,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { userSettings: '{"autoCompactWindow":300000,"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000},"claude-sonnet-5-5":{"autoCompactWindow":"big"}}}', threshold: 367_000, + maxTokens: 400_000, }, { autoCompactWindow: "", @@ -6539,6 +6561,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', projectSettings: '{"autoCompactWindow":300000}', threshold: 267_000, + maxTokens: 300_000, }, { autoCompactWindow: "", @@ -6546,22 +6569,25 @@ describe("ClaudeAdapterV2 background wake turns", () => { userSettings: '{"autoCompactWindow":300000}', projectSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":"auto"}}}', threshold: undefined, + maxTokens: 1_000_000, }, { autoCompactWindow: "250000", model: "claude-opus-5-5", userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', threshold: 217_000, + maxTokens: 250_000, }, { autoCompactWindow: "", model: "claude-opus-5-5", userSettings: '{"autoCompactWindow":150000.5}', threshold: undefined, + maxTokens: 1_000_000, }, ])( "ignores stale snapshots after compaction and reports threshold $threshold for $model", - ({ autoCompactWindow, model, threshold, userSettings = "{}", projectSettings }) => + ({ autoCompactWindow, model, threshold, maxTokens, userSettings = "{}", projectSettings }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -6619,10 +6645,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual( harness.events.flatMap((event) => event.type === "provider_turn.updated" && event.providerTurn.tokenUsage - ? [event.providerTurn.tokenUsage.autoCompactThreshold] + ? [ + [ + event.providerTurn.tokenUsage.maxTokens, + event.providerTurn.tokenUsage.autoCompactThreshold, + ], + ] : [], ), - [threshold, threshold, threshold], + Array.from({ length: 3 }, () => [maxTokens, threshold]), ); }).pipe( Effect.provide( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 846a9187a2d0..5521d5e9ceab 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -238,15 +238,20 @@ function claudePromptCacheTtlMs( // getContextUsage reports a 150,000 window as a 117,000 threshold, for every catalog model. const CLAUDE_AUTO_COMPACT_RESERVE_TOKENS = 33_000; -function claudeAutoCompactThreshold( +// Like Claude's /context, measure usage against the window when it is below the model's. +function claudeContextLimits( autoCompactWindow: number | undefined, - maxTokens: number | null, + modelSelection: ModelSelection, ) { + const contextWindow = claudeContextWindow(modelSelection); return autoCompactWindow !== undefined && autoCompactWindow > 0 && - (maxTokens === null || autoCompactWindow < maxTokens) - ? { autoCompactThreshold: autoCompactWindow - CLAUDE_AUTO_COMPACT_RESERVE_TOKENS } - : {}; + (contextWindow === null || autoCompactWindow < contextWindow) + ? { + maxTokens: autoCompactWindow, + autoCompactThreshold: autoCompactWindow - CLAUDE_AUTO_COMPACT_RESERVE_TOKENS, + } + : { maxTokens: contextWindow }; } function claudeMessageInputTokens(usage: { @@ -277,7 +282,6 @@ export function claudeProviderTurnTokenUsage( const inputTokens = claudeMessageInputTokens(usage); const outputTokens = usage.output_tokens; const promptCacheTtlMs = claudePromptCacheTtlMs(usage.cache_creation); - const maxTokens = claudeContextWindow(modelSelection); // The cache lifetime starts with the request, not when its response ends. const promptCacheExpiresAt = promptCacheTtlMs === undefined || requestedAt === undefined @@ -285,13 +289,12 @@ export function claudeProviderTurnTokenUsage( : DateTime.formatIso(DateTime.add(requestedAt, { milliseconds: promptCacheTtlMs })); return { usedTokens: inputTokens + outputTokens, - maxTokens, + ...claudeContextLimits(autoCompactWindow, modelSelection), inputTokens, cachedInputTokens: usage.cache_read_input_tokens ?? 0, outputTokens, reasoningOutputTokens: 0, ...(promptCacheTtlMs === undefined ? {} : { promptCacheTtlMs }), - ...claudeAutoCompactThreshold(autoCompactWindow, maxTokens), ...(promptCacheExpiresAt === undefined ? {} : { promptCacheExpiresAt }), updatedAt, }; @@ -8642,7 +8645,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? Math.round(postTokens) : undefined; if (afterTokenCount !== undefined) { - const maxTokens = claudeContextWindow(context.input.modelSelection); yield* emitProviderEvent({ type: "provider_turn.updated", driver: CLAUDE_PROVIDER, @@ -8663,8 +8665,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( completedAt: null, tokenUsage: { usedTokens: afterTokenCount, - maxTokens, - ...claudeAutoCompactThreshold(context.autoCompactWindow, maxTokens), + ...claudeContextLimits(context.autoCompactWindow, context.input.modelSelection), updatedAt: DateTime.formatIso(now), }, }, @@ -10477,11 +10478,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Path.Path, path), ); - const querySession = yield* openQuery(turnInput, nativeThreadId); const autoCompactWindow = yield* resolveAutoCompactWindow( turnInput.runtimePolicy.cwd, turnInput.modelSelection, ); + const querySession = yield* openQuery(turnInput, nativeThreadId); const context: ActiveClaudeTurnContext = { input: turnInput, autoCompactWindow, diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index 08a4cc50efe7..c27ddd9a0465 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -60,9 +60,9 @@ describe("live provider-turn usage (#8144)", () => { const snapshot = deriveLatestContextWindowSnapshot([], usage); expect(snapshot).not.toBeNull(); expect(snapshot?.usedTokens).toBe(42_000); - expect(snapshot?.maxTokens).toBe(150_000); - expect(snapshot?.remainingTokens).toBe(108_000); - expect(snapshot?.usedPercentage).toBeCloseTo(28); + expect(snapshot?.maxTokens).toBe(200_000); + expect(snapshot?.remainingTokens).toBe(158_000); + expect(snapshot?.usedPercentage).toBe(21); expect(snapshot?.autoCompactThreshold).toBe(150_000); expect( formatContextWindowCompactionMessage("Claude Sonnet 4.6", snapshot?.autoCompactThreshold), @@ -149,8 +149,7 @@ describe("latestProviderTurnTokenUsage", () => { const providerThread = { contextUsage: { usedTokens, - maxTokens: 1_000_000, - autoCompactThreshold: 200_000, + maxTokens: 200_000, cost: { amount: 0.42, currency: "USD" }, }, updatedAt: DateTime.makeUnsafe("2026-10-19T12:10:00.000Z"), @@ -161,7 +160,6 @@ describe("latestProviderTurnTokenUsage", () => { expect(snapshot).toMatchObject({ usedTokens, maxTokens: 200_000, - remainingTokens: 200_000 - usedTokens, cost: { amount: 0.42, currency: "USD" }, updatedAt: "2026-10-19T12:10:00.000Z", }); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index a04a434f9462..8c449249e033 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -63,8 +63,7 @@ export function deriveLatestContextWindowSnapshot( ): ContextWindowSnapshot | null { if (liveUsage != null) { const usedTokens = Math.max(0, liveUsage.usedTokens); - // Like Claude's /context, measure against the auto-compact window when it is smaller. - const maxTokens = liveUsage.autoCompactThreshold ?? liveUsage.maxTokens ?? null; + const maxTokens = liveUsage.maxTokens ?? null; const usedPercentage = maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; const remainingTokens = @@ -101,8 +100,7 @@ export function deriveLatestContextWindowSnapshot( providerUsage !== undefined && providerUsageUpdatedAt !== undefined ) { - const maxTokens = - asFiniteNumber(providerUsage.autoCompactThreshold) ?? asFiniteNumber(providerUsage.maxTokens); + const maxTokens = asFiniteNumber(providerUsage.maxTokens); const usedTokens = providerUsage.usedTokens; const usedPercentage = maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; From 6b67472f1634dc001f89ac846b422359b57f9454 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 4 Nov 2026 02:09:01 +0000 Subject: [PATCH 6/7] fix(claude): validate saved windows and match catalog aliases --- .../Adapters/ClaudeAdapterV2.test.ts | 14 +++++++++++ .../Adapters/ClaudeAdapterV2.ts | 24 ++++++++++++------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 48fdd982f6ef..a0430443c9a6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7308,6 +7308,20 @@ describe("ClaudeAdapterV2 background wake turns", () => { threshold: 617_000, maxTokens: 650_000, }, + ...[1, 50_000, 1_000_001].map((window) => ({ + autoCompactWindow: "", + model: "claude-sonnet-4-6", + userSettings: JSON.stringify({ autoCompactWindow: window }), + threshold: undefined, + maxTokens: 200_000, + })), + { + autoCompactWindow: "", + model: "claude-sonnet-4-6", + userSettings: '{"modelSettings":{"claude-sonnet-4-6-20251117":{"autoCompactWindow":150000}}}', + threshold: 117_000, + maxTokens: 150_000, + }, // /autocompact writes the per-model value, which beats the file's top-level one. { autoCompactWindow: "", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index adecd49cc0b4..18369ca4f280 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2745,7 +2745,10 @@ const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect( ), ), ); -const ClaudeAutoCompactWindowSetting = Schema.Union([Schema.Int, Schema.Literal("auto")]); +const ClaudeAutoCompactWindowSetting = Schema.Union([ + Schema.Int.check(Schema.isBetween({ minimum: 100_000, maximum: 1_000_000 })), + Schema.Literal("auto"), +]); // Values are checked one at a time, so a bad entry for another model can't hide this one. const decodeClaudeAutoCompactSettings = Schema.decodeUnknownEffect( fromLenientJson( @@ -4801,14 +4804,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.flatMap(decodeClaudeAutoCompactSettings), Effect.orElseSucceed(() => undefined), ); + for (const [key, entry] of Object.entries(settings?.modelSettings ?? {})) { + const entryModel = compileClaudeModelSelection({ + ...modelSelection, + model: key, + }).apiModelId.replace(/\[1m\]$/i, ""); + if (entryModel !== model) continue; + const window = Option.getOrUndefined(decodeClaudeModelAutoCompactSettings(entry)); + if (window !== undefined) { + return window.autoCompactWindow === "auto" ? undefined : window.autoCompactWindow; + } + } const value = Option.getOrUndefined( - Option.orElse( - Option.map( - decodeClaudeModelAutoCompactSettings(settings?.modelSettings?.[model]), - (entry) => entry.autoCompactWindow, - ), - () => decodeClaudeAutoCompactWindow(settings?.autoCompactWindow), - ), + decodeClaudeAutoCompactWindow(settings?.autoCompactWindow), ); if (value !== undefined) return value === "auto" ? undefined : value; } From 8882f7cce215beafa6aa9b43733bdc577488ee7b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 4 Nov 2026 02:12:08 +0000 Subject: [PATCH 7/7] fix(claude): prefer canonical saved model windows --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 8 ++++++++ .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index a0430443c9a6..e6d742bf17c3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7322,6 +7322,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { threshold: 117_000, maxTokens: 150_000, }, + { + autoCompactWindow: "", + model: "claude-sonnet-4-6", + userSettings: + '{"modelSettings":{"claude-sonnet-4-6-20251117":{"autoCompactWindow":150000},"claude-sonnet-4-6":{"autoCompactWindow":180000}}}', + threshold: 147_000, + maxTokens: 180_000, + }, // /autocompact writes the per-model value, which beats the file's top-level one. { autoCompactWindow: "", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 18369ca4f280..ac41559fcfbe 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4804,6 +4804,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.flatMap(decodeClaudeAutoCompactSettings), Effect.orElseSucceed(() => undefined), ); + const canonical = Option.getOrUndefined( + decodeClaudeModelAutoCompactSettings(settings?.modelSettings?.[model]), + ); + if (canonical !== undefined) { + return canonical.autoCompactWindow === "auto" ? undefined : canonical.autoCompactWindow; + } for (const [key, entry] of Object.entries(settings?.modelSettings ?? {})) { const entryModel = compileClaudeModelSelection({ ...modelSelection,