Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 109 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 @@ -6353,18 +6353,117 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect.each([
it.effect.each<{
autoCompactWindow: string;
model: string;
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 },
{ 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,
},
{
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 $autoCompactWindow for $model",
({ autoCompactWindow, model, threshold }) =>
"ignores stale snapshots after compaction and reports threshold $threshold for $model",
({
autoCompactWindow,
model,
threshold,
userSettings = "{}",
projectSettings,
launchArgs = "",
environment,
}) =>
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 },
...(environment === undefined ? {} : { environment }),
});
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
Expand All @@ -6375,6 +6474,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
136 changes: 111 additions & 25 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,11 +233,37 @@ 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
: 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: 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 }
: {};
}

Expand All @@ -263,7 +289,7 @@ export function claudeProviderTurnTokenUsage(
},
modelSelection: ModelSelection,
updatedAt: string,
autoCompactWindow = "",
autoCompactWindow?: number,
requestedAt?: DateTime.Utc,
) {
const inputTokens = claudeMessageInputTokens(usage);
Expand Down Expand Up @@ -2624,6 +2650,20 @@ 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) }),
),
),
});
const decodeClaudeAutoCompactSettings = Schema.decodeUnknownOption(ClaudeAutoCompactSettings);
const decodeClaudeAutoCompactSettingsFile = Schema.decodeUnknownEffect(
fromLenientJson(ClaudeAutoCompactSettings),
);
const decodeClaudeEnvSettings = Schema.decodeUnknownEffect(
fromLenientJson(
Schema.StructWithRest(
Expand Down Expand Up @@ -3966,6 +4006,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 @@ -4577,32 +4618,75 @@ 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);
},
);

// 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.<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 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)));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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;
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const userInvocableSkills = (cwd: string | null) =>
discoverClaudeSkills(
adapterOptions.settings,
Expand Down Expand Up @@ -4984,7 +5068,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 @@ -8570,10 +8654,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 @@ -10367,8 +10448,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
Loading
Loading