Skip to content
Open
68 changes: 62 additions & 6 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,7 +739,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");
Expand Down Expand Up @@ -771,7 +771,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" });
Expand Down Expand Up @@ -6513,18 +6513,70 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect.each([
it.effect.each<{
autoCompactWindow: string;
model: string;
threshold: number | undefined;
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 },
// /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,
},
{
autoCompactWindow: "",
model: "claude-opus-5-5",
userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}',
projectSettings: '{"autoCompactWindow":300000}',
threshold: 267_000,
},
{
autoCompactWindow: "",
model: "claude-opus-5-5",
userSettings: '{"autoCompactWindow":300000}',
projectSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":"auto"}}}',
threshold: undefined,
},
{
autoCompactWindow: "250000",
model: "claude-opus-5-5",
userSettings: '{"modelSettings":{"claude-opus-5-5":{"autoCompactWindow":400000}}}',
threshold: 217_000,
},
{
autoCompactWindow: "",
model: "claude-opus-5-5",
userSettings: '{"autoCompactWindow":150000.5}',
threshold: undefined,
},
])(
"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 }) =>
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({
Expand All @@ -6535,6 +6587,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 };
Expand Down
101 changes: 76 additions & 25 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,14 @@ 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 }
function claudeAutoCompactThreshold(
autoCompactWindow: number | undefined,
maxTokens: number | null,
) {
return autoCompactWindow !== undefined &&
autoCompactWindow > 0 &&
(maxTokens === null || autoCompactWindow < maxTokens)
? { autoCompactThreshold: autoCompactWindow - CLAUDE_AUTO_COMPACT_RESERVE_TOKENS }
: {};
}

Expand All @@ -267,7 +271,7 @@ export function claudeProviderTurnTokenUsage(
},
modelSelection: ModelSelection,
updatedAt: string,
autoCompactWindow = "",
autoCompactWindow?: number,
requestedAt?: DateTime.Utc,
) {
const inputTokens = claudeMessageInputTokens(usage);
Expand Down Expand Up @@ -2661,6 +2665,20 @@ const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect(
),
),
);
const ClaudeAutoCompactWindowSetting = Schema.Union([Schema.Int, 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(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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(
Expand Down Expand Up @@ -4003,6 +4021,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.
Expand Down Expand Up @@ -4620,32 +4639,62 @@ 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")];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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);
}
return claudeToolSearchOverride(adapterOptions.environment.ENABLE_TOOL_SEARCH);
},
);

// T3's setting is sent as flag settings, so it beats every settings file. In one file,
// modelSettings.<model>.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 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;
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const userInvocableSkills = (cwd: string | null) =>
discoverClaudeSkills(
adapterOptions.settings,
Expand Down Expand Up @@ -5026,7 +5075,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;
Expand Down Expand Up @@ -8615,10 +8664,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),
},
},
Expand Down Expand Up @@ -10432,8 +10478,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,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const context: ActiveClaudeTurnContext = {
input: turnInput,
autoCompactWindow,
queryPolicy: querySession.queryPolicy,
nativeThreadId,
nativeTurnId,
Expand Down
10 changes: 6 additions & 4 deletions apps/web/src/lib/contextWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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"),
Expand All @@ -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",
});
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/lib/contextWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down
Loading