diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ef721644f6a8f..e6d742bf17c3e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -740,7 +740,7 @@ describe("ClaudeAdapterV2 context usage", () => { }, null, "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"); @@ -7275,18 +7275,118 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each([ - { 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 }, + it.effect.each<{ + autoCompactWindow: string; + model: string; + threshold: number | undefined; + maxTokens: number; + userSettings?: string; + projectSettings?: string; + }>([ + { 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, + }, + ...[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, + }, + { + 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: "", + model: "claude-opus-5-5", + userSettings: + '{"autoCompactWindow":300000,"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000},"claude-sonnet-5-5":{"autoCompactWindow":"big"}}}', + threshold: 367_000, + maxTokens: 400_000, + }, + { + autoCompactWindow: "", + model: "claude-opus-5-5", + userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}', + projectSettings: '{"autoCompactWindow":300000}', + threshold: 267_000, + maxTokens: 300_000, + }, + { + autoCompactWindow: "", + model: "claude-opus-5-5", + 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 $autoCompactWindow for $model", - ({ autoCompactWindow, model, threshold }) => + "ignores stale snapshots after compaction and reports threshold $threshold for $model", + ({ autoCompactWindow, model, threshold, maxTokens, userSettings = "{}", projectSettings }) => 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 }, }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -7297,6 +7397,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 }; @@ -7325,10 +7429,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 d2ae14545d965..ac41559fcfbed 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -241,11 +241,16 @@ 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(autoCompactWindow: string, maxTokens: number | null) { - const windowTokens = Number(autoCompactWindow); - return windowTokens > 0 && (maxTokens === null || windowTokens < maxTokens) - ? { autoCompactThreshold: windowTokens - CLAUDE_AUTO_COMPACT_RESERVE_TOKENS } - : {}; +// Like Claude's /context, measure usage against the window when it is below the model's. +function claudeContextLimits(autoCompactWindow: number | undefined, contextWindow: number | null) { + return autoCompactWindow !== undefined && + autoCompactWindow > 0 && + (contextWindow === null || autoCompactWindow < contextWindow) + ? { + maxTokens: autoCompactWindow, + autoCompactThreshold: autoCompactWindow - CLAUDE_AUTO_COMPACT_RESERVE_TOKENS, + } + : { maxTokens: contextWindow }; } function claudeMessageInputTokens(usage: { @@ -270,7 +275,7 @@ export function claudeProviderTurnTokenUsage( }, maxTokens: number | null, updatedAt: string, - autoCompactWindow = "", + autoCompactWindow?: number, requestedAt?: DateTime.Utc, ) { const inputTokens = claudeMessageInputTokens(usage); @@ -283,13 +288,12 @@ export function claudeProviderTurnTokenUsage( : DateTime.formatIso(DateTime.add(requestedAt, { milliseconds: promptCacheTtlMs })); return { usedTokens: inputTokens + outputTokens, - maxTokens, + ...claudeContextLimits(autoCompactWindow, maxTokens), inputTokens, cachedInputTokens: usage.cache_read_input_tokens ?? 0, outputTokens, reasoningOutputTokens: 0, ...(promptCacheTtlMs === undefined ? {} : { promptCacheTtlMs }), - ...claudeAutoCompactThreshold(autoCompactWindow, maxTokens), ...(promptCacheExpiresAt === undefined ? {} : { promptCacheExpiresAt }), updatedAt, }; @@ -2741,6 +2745,23 @@ const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect( ), ), ); +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( + 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( @@ -4109,6 +4130,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. @@ -4740,25 +4762,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); } @@ -4766,6 +4789,46 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, ); + // 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 fromT3 = Number(adapterOptions.settings.autoCompactWindow); + if (fromT3 > 0) return fromT3; + const model = compileClaudeModelSelection(modelSelection).apiModelId.replace(/\[1m\]$/i, ""); + for (const settingsFile of yield* settingsFilesByPrecedence(cwd)) { + const settings = yield* fileSystem.readFileString(settingsFile).pipe( + 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, + 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( + decodeClaudeAutoCompactWindow(settings?.autoCompactWindow), + ); + if (value !== undefined) return value === "auto" ? undefined : value; + } + return undefined; + }); + const userInvocableSkills = (cwd: string | null) => discoverClaudeSkills( adapterOptions.settings, @@ -5153,18 +5216,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( typeof usage === "number" ? { usedTokens: usage, - maxTokens, - ...claudeAutoCompactThreshold( - adapterOptions.settings.autoCompactWindow, - maxTokens, - ), + ...claudeContextLimits(context.autoCompactWindow, maxTokens), updatedAt: DateTime.formatIso(now), } : claudeProviderTurnTokenUsage( usage, maxTokens, DateTime.formatIso(now), - adapterOptions.settings.autoCompactWindow, + context.autoCompactWindow, context.streamUsage?.requestedAt, ); if (typeof usage !== "number") { @@ -10756,9 +10815,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Path.Path, path), ); + const autoCompactWindow = yield* resolveAutoCompactWindow( + turnInput.runtimePolicy.cwd, + turnInput.modelSelection, + ); const querySession = yield* openQuery(turnInput, nativeThreadId); const context: ActiveClaudeTurnContext = { input: turnInput, + autoCompactWindow, queryPolicy: querySession.queryPolicy, nativeThreadId, nativeTurnId,