Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
88 changes: 87 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,14 @@ function makeContextUsageResponse(input: {
readonly maxTokens: number;
readonly rawMaxTokens?: number;
readonly isAutoCompactEnabled?: boolean;
readonly categories?: SDKControlGetContextUsageResponse["categories"];
}): SDKControlGetContextUsageResponse {
return {
categories: [],
// A category-less response is treated as a partially started process and
// ignored by the adapter, so a usable fixture needs at least one category.
categories: input.categories ?? [
{ name: "Messages", tokens: input.totalTokens, color: "#000000" },
],
totalTokens: input.totalTokens,
maxTokens: input.maxTokens,
rawMaxTokens: input.rawMaxTokens ?? input.maxTokens,
Expand Down Expand Up @@ -5122,6 +5127,66 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect(
"ignores a category-less context usage report instead of emitting a bare snapshot",
() => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe(
Stream.runCollect,
Effect.forkChild,
);

yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});

yield* adapter.sendTurn({
threadId: THREAD_ID,
input: "hello",
attachments: [],
});

// A freshly resumed SDK process reports a token estimate before it has
// rebuilt its context breakdown. Emitting that would replace the last
// rich snapshot with a bare one, so no usage event may be produced.
harness.query.setContextUsageResponse(
makeContextUsageResponse({
totalTokens: 93169,
maxTokens: 1000000,
categories: [],
}),
);
harness.query.emit({
type: "result",
subtype: "success",
is_error: false,
duration_ms: 100,
duration_api_ms: 90,
num_turns: 1,
result: "done",
stop_reason: "end_turn",
session_id: "sdk-session-bare-context",
} as unknown as SDKMessage);
harness.query.finish();

const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
assert.deepEqual(
runtimeEvents.filter((event) => event.type === "thread.token-usage.updated"),
[],
);
assert.equal(harness.query.getContextUsageCalls.length, 1);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
},
);

it.effect("emits Claude context window on result completion usage snapshots", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down Expand Up @@ -5187,6 +5252,7 @@ describe("ClaudeAdapterLive", () => {
cachedInputTokens: 21144,
outputTokens: 679,
maxTokens: 200000,
contextCategories: [{ name: "Messages", tokens: 24542 }],
lastInputTokens: 23863,
lastCachedInputTokens: 21144,
lastOutputTokens: 679,
Expand Down Expand Up @@ -5274,6 +5340,7 @@ describe("ClaudeAdapterLive", () => {
usedTokens: 22000,
lastUsedTokens: 22000,
maxTokens: 200000,
contextCategories: [{ name: "Messages", tokens: 22000 }],
compactsAutomatically: true,
},
});
Expand All @@ -5285,6 +5352,7 @@ describe("ClaudeAdapterLive", () => {
cachedInputTokens: 20,
outputTokens: 80,
maxTokens: 200000,
contextCategories: [{ name: "Messages", tokens: 24000 }],
lastInputTokens: 120,
lastCachedInputTokens: 20,
lastOutputTokens: 80,
Expand Down Expand Up @@ -5332,14 +5400,25 @@ describe("ClaudeAdapterLive", () => {
attachments: [],
});

// Both snapshots carry an equal-but-not-identical category array: the
// dedupe has to compare the entries, not the array reference.
const categories = [
{ name: "System prompt", tokens: 3000, color: "#111111" },
{ name: "MCP tools", tokens: 700, color: "#444444", isDeferred: true },
{ name: "System tools (deferred)", tokens: 5000, color: "#555555" },
{ name: "Messages", tokens: 19000, color: "#222222" },
{ name: "Free space", tokens: 178000, color: "#333333" },
] satisfies SDKControlGetContextUsageResponse["categories"];
harness.query.setContextUsageResponses([
makeContextUsageResponse({
totalTokens: 22000,
maxTokens: 200000,
categories: categories.map((category) => ({ ...category })),
}),
makeContextUsageResponse({
totalTokens: 22000,
maxTokens: 200000,
categories: categories.map((category) => ({ ...category })),
}),
]);

Expand Down Expand Up @@ -5377,6 +5456,12 @@ describe("ClaudeAdapterLive", () => {
usedTokens: 22000,
lastUsedTokens: 22000,
maxTokens: 200000,
// Free space is derived client-side and deferred categories are not
// part of the used total, so both are filtered out here.
contextCategories: [
{ name: "System prompt", tokens: 3000 },
{ name: "Messages", tokens: 19000 },
],
compactsAutomatically: true,
},
});
Expand Down Expand Up @@ -5755,6 +5840,7 @@ describe("ClaudeAdapterLive", () => {
lastOutputTokens: 70,
lastReasoningOutputTokens: 50,
maxTokens: 1000000,
contextCategories: [{ name: "Messages", tokens: 80 }],
compactsAutomatically: true,
},
});
Expand Down
76 changes: 75 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,49 @@ function readClaudeUsageTotals(value: unknown): ClaudeUsageTotals | undefined {
};
}

/** Free space is derived client-side from maxTokens - usedTokens, so keeping the
* SDK's own free-space category would double-count it in the breakdown. */
function isClaudeFreeSpaceCategory(name: string): boolean {
return name.trim().toLowerCase() === "free space";
}

/** The SDK marks deferred categories with `isDeferred`, but has also been seen
* tagging the name itself ("MCP tools (deferred)"); match either. */
function isClaudeDeferredCategory(
category: SDKControlGetContextUsageResponse["categories"][number],
): boolean {
return category.isDeferred === true || category.name.trim().toLowerCase().endsWith("(deferred)");
}

/** Keeps the SDK's category order: legend colors are assigned by index, so
* reordering would make them flicker between updates. */
function normalizeClaudeContextCategories(
value: SDKControlGetContextUsageResponse["categories"] | undefined,
): ThreadTokenUsageSnapshot["contextCategories"] {
if (!Array.isArray(value)) {
return undefined;
}

const categories = value.flatMap((category) => {
const name = typeof category?.name === "string" ? category.name.trim() : "";
const tokens = asPositiveFiniteInteger(category?.tokens);
// Deferred categories (tool schemas not yet loaded) are excluded from the
// SDK's own used-token total, so listing them would make the breakdown sum
// past the reported usage.
if (
name.length === 0 ||
tokens === undefined ||
isClaudeDeferredCategory(category) ||
isClaudeFreeSpaceCategory(name)
) {
return [];
}
return [{ name, tokens }];
});

return categories.length > 0 ? categories : undefined;
}

function normalizeClaudeContextUsage(
value: SDKControlGetContextUsageResponse | undefined,
): ThreadTokenUsageSnapshot | undefined {
Expand All @@ -791,15 +834,27 @@ function normalizeClaudeContextUsage(

const maxTokens =
asPositiveFiniteInteger(value.maxTokens) ?? asPositiveFiniteInteger(value.rawMaxTokens);
const contextCategories = normalizeClaudeContextCategories(value.categories);
// A fully initialized Claude session always reports at least a system-prompt
// and messages category. A response without any is a partially started
// process (e.g. queried during session resume) whose token estimate would
// replace the last known rich snapshot with a bare one — treat it as
// unavailable and let the caller fall back instead.
if (contextCategories === undefined) {
return undefined;
}

return {
usedTokens,
lastUsedTokens: usedTokens,
...(maxTokens !== undefined ? { maxTokens } : {}),
contextCategories,
compactsAutomatically: value.isAutoCompactEnabled,
};
}

/** Scalar keys only: `contextCategories` is an array, so it needs the
* element-wise comparison below instead of an identity check. */
const THREAD_TOKEN_USAGE_SNAPSHOT_KEYS = [
"usedTokens",
"totalProcessedTokens",
Expand All @@ -818,14 +873,33 @@ const THREAD_TOKEN_USAGE_SNAPSHOT_KEYS = [
"compactsAutomatically",
] as const satisfies ReadonlyArray<keyof ThreadTokenUsageSnapshot>;

function areClaudeContextCategoriesEqual(
left: ThreadTokenUsageSnapshot["contextCategories"],
right: ThreadTokenUsageSnapshot["contextCategories"],
): boolean {
if (left === undefined || right === undefined) {
return left === right;
}
if (left.length !== right.length) {
return false;
}
return left.every((category, index) => {
const other = right[index];
return other !== undefined && category.name === other.name && category.tokens === other.tokens;
});
}

function areThreadTokenUsageSnapshotsEqual(
left: ThreadTokenUsageSnapshot | undefined,
right: ThreadTokenUsageSnapshot | undefined,
): boolean {
if (!left || !right) {
return false;
}
return THREAD_TOKEN_USAGE_SNAPSHOT_KEYS.every((key) => left[key] === right[key]);
return (
THREAD_TOKEN_USAGE_SNAPSHOT_KEYS.every((key) => left[key] === right[key]) &&
areClaudeContextCategoriesEqual(left.contextCategories, right.contextCategories)
);
}

function normalizeClaudeCompactBoundaryUsage(
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2430,12 +2430,14 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
return;
}

// Cumulative fields track the session-wide `total`; the `last*` fields and
// usedTokens (the current context size) track the latest turn.
assert.deepEqual(firstEvent.value.payload.usage, {
usedTokens: 126,
totalProcessedTokens: 11_839,
maxTokens: 258_400,
inputTokens: 120,
cachedInputTokens: 0,
inputTokens: 11_833,
cachedInputTokens: 3456,
outputTokens: 6,
reasoningOutputTokens: 0,
lastUsedTokens: 126,
Expand Down
25 changes: 15 additions & 10 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,10 +654,17 @@ function normalizeCodexTokenUsage(
}

const maxTokens = usage.modelContextWindow ?? undefined;
const inputTokens = usage.last.inputTokens;
const cachedInputTokens = usage.last.cachedInputTokens;
const outputTokens = usage.last.outputTokens;
const reasoningOutputTokens = usage.last.reasoningOutputTokens;
// Cumulative fields come from `total` (session-wide, matching the Claude
// adapter's semantics); the `last*` fields stay on the latest turn. usedTokens
// remains `last.totalTokens` because that is the current context size.
const inputTokens = usage.total.inputTokens;
const cachedInputTokens = usage.total.cachedInputTokens;
const outputTokens = usage.total.outputTokens;
const reasoningOutputTokens = usage.total.reasoningOutputTokens;
const lastInputTokens = usage.last.inputTokens;
const lastCachedInputTokens = usage.last.cachedInputTokens;
const lastOutputTokens = usage.last.outputTokens;
const lastReasoningOutputTokens = usage.last.reasoningOutputTokens;

return {
usedTokens,
Expand All @@ -670,12 +677,10 @@ function normalizeCodexTokenUsage(
...(outputTokens !== undefined ? { outputTokens } : {}),
...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
...(usedTokens !== undefined ? { lastUsedTokens: usedTokens } : {}),
...(inputTokens !== undefined ? { lastInputTokens: inputTokens } : {}),
...(cachedInputTokens !== undefined ? { lastCachedInputTokens: cachedInputTokens } : {}),
...(outputTokens !== undefined ? { lastOutputTokens: outputTokens } : {}),
...(reasoningOutputTokens !== undefined
? { lastReasoningOutputTokens: reasoningOutputTokens }
: {}),
...(lastInputTokens !== undefined ? { lastInputTokens } : {}),
...(lastCachedInputTokens !== undefined ? { lastCachedInputTokens } : {}),
...(lastOutputTokens !== undefined ? { lastOutputTokens } : {}),
...(lastReasoningOutputTokens !== undefined ? { lastReasoningOutputTokens } : {}),
compactsAutomatically: true,
};
}
Expand Down
Loading
Loading