authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-28 20:27:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 20:27:02-07:00
logc131f289268d407bb0d8540dc33ba6ddb88f46ed
tree72bf3dc9d342bb2b4d5b6932f823846418add77b
parent38dcd7a404d24c2fa514cc82c1a5c4fd6a9d4a7a
signature Signed by PGP key B5690EEEBB952194

fix(server): stop querying Claude context usage after turns (#8610)


2 files changed, 222 insertions(+), 121 deletions(-)

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+178-61
......@@ -1791,92 +1791,209 @@ describe("ClaudeAdapterLive", () => {
17911791 );
17921792 });
17931793
1794 it.effect("keeps a resumed replacement session during slow stop cleanup", () => {
1795 const queries: FakeClaudeQuery[] = [];
1796 let signalUsageStarted: () => void = () => undefined;
1797 const usageStarted = new Promise<void>((resolve) => {
1798 signalUsageStarted = resolve;
1794 it.effect("completes with result usage without querying current context usage", () => {
1795 const harness = makeHarness();
1796 let getContextUsageCalls = 0;
1797 Object.assign(harness.query, {
1798 getContextUsage: async () => {
1799 getContextUsageCalls += 1;
1800 return {
1801 totalTokens: 999,
1802 maxTokens: 200000,
1803 isAutoCompactEnabled: true,
1804 };
1805 },
17991806 });
1800 const layer = Layer.effect(
1801 ClaudeAdapter,
1802 Effect.gen(function* () {
1803 const claudeConfig = decodeClaudeSettings({});
1804 return yield* makeClaudeAdapter(claudeConfig, {
1805 createQuery: () => {
1806 const query = new FakeClaudeQuery();
1807 if (queries.length === 0) {
1808 Object.assign(query, {
1809 getContextUsage: async () => {
1810 signalUsageStarted();
1811 return await new Promise<never>(() => undefined);
1812 },
1813 });
1814 }
1815 queries.push(query);
1816 return query;
1817 },
1818 });
1819 }),
1820 ).pipe(
1821 Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")),
1822 Layer.provideMerge(ServerSettingsService.layerTest()),
1823 Layer.provideMerge(NodeServices.layer),
1824 );
1825
18261807 return Effect.gen(function* () {
18271808 const adapter = yield* ClaudeAdapter;
1828 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe(
1809 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe(
18291810 Stream.runCollect,
18301811 Effect.forkChild,
18311812 );
1832 const firstSession = yield* adapter.startSession({
1813 yield* adapter.startSession({
18331814 threadId: THREAD_ID,
18341815 provider: ProviderDriverKind.make("claudeAgent"),
18351816 runtimeMode: "full-access",
18361817 });
18371818 yield* adapter.sendTurn({
1838 threadId: firstSession.threadId,
1819 threadId: THREAD_ID,
18391820 input: "hello",
18401821 attachments: [],
18411822 });
18421823
1843 const interruptFiber = yield* adapter
1844 .interruptTurn(firstSession.threadId)
1845 .pipe(Effect.forkChild);
1846 yield* Effect.promise(() => usageStarted);
1847 assert.equal(queries[0]?.closeCalls, 1);
1824 harness.query.emit({
1825 type: "assistant",
1826 session_id: "sdk-session-result-usage",
1827 uuid: "assistant-result-usage-1",
1828 parent_tool_use_id: null,
1829 message: {
1830 id: "assistant-message-result-usage-1",
1831 role: "assistant",
1832 content: [],
1833 usage: {
1834 input_tokens: 80,
1835 output_tokens: 20,
1836 },
1837 },
1838 } as unknown as SDKMessage);
1839 harness.query.emit({
1840 type: "assistant",
1841 session_id: "sdk-session-result-usage",
1842 uuid: "assistant-result-usage-2",
1843 parent_tool_use_id: null,
1844 message: {
1845 id: "assistant-message-result-usage-2",
1846 role: "assistant",
1847 content: [],
1848 usage: {
1849 input_tokens: 180,
1850 output_tokens: 20,
1851 },
1852 },
1853 } as unknown as SDKMessage);
1854 harness.query.emit({
1855 type: "assistant",
1856 session_id: "sdk-session-result-usage",
1857 uuid: "assistant-result-usage-3",
1858 parent_tool_use_id: null,
1859 message: {
1860 id: "assistant-message-result-usage-3",
1861 role: "assistant",
1862 content: [],
1863 },
1864 } as unknown as SDKMessage);
1865 harness.query.emit({
1866 type: "result",
1867 subtype: "success",
1868 is_error: false,
1869 duration_ms: 1234,
1870 duration_api_ms: 1200,
1871 num_turns: 1,
1872 result: "done",
1873 stop_reason: "end_turn",
1874 session_id: "sdk-session-result-usage",
1875 usage: {
1876 input_tokens: 400,
1877 output_tokens: 50,
1878 },
1879 modelUsage: {
1880 "claude-opus-4-6": {
1881 contextWindow: 200000,
1882 maxOutputTokens: 64000,
1883 },
1884 },
1885 } as unknown as SDKMessage);
18481886
1849 const replacement = yield* adapter.startSession({
1887 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1888 assert.equal(getContextUsageCalls, 0);
1889 const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated");
1890 assert.equal(usageEvent?.type, "thread.token-usage.updated");
1891 if (usageEvent?.type === "thread.token-usage.updated") {
1892 assert.deepEqual(usageEvent.payload.usage, {
1893 usedTokens: 200,
1894 lastUsedTokens: 200,
1895 totalProcessedTokens: 450,
1896 inputTokens: 180,
1897 outputTokens: 20,
1898 maxTokens: 200000,
1899 });
1900 }
1901 assert.equal(
1902 runtimeEvents.find((event) => event.type === "turn.completed")?.type,
1903 "turn.completed",
1904 );
1905 }).pipe(
1906 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1907 Effect.provide(harness.layer),
1908 );
1909 });
1910
1911 it.effect("preserves compacted usage when completion follows an older assistant frame", () => {
1912 const harness = makeHarness();
1913 return Effect.gen(function* () {
1914 const adapter = yield* ClaudeAdapter;
1915 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe(
1916 Stream.runCollect,
1917 Effect.forkChild,
1918 );
1919
1920 yield* adapter.startSession({
18501921 threadId: THREAD_ID,
18511922 provider: ProviderDriverKind.make("claudeAgent"),
18521923 runtimeMode: "full-access",
1853 resumeCursor: firstSession.resumeCursor,
18541924 });
1855 yield* TestClock.adjust("1 second");
1856 yield* Fiber.join(interruptFiber);
1925 yield* adapter.sendTurn({
1926 threadId: THREAD_ID,
1927 input: "hello",
1928 attachments: [],
1929 });
1930 harness.query.emit({
1931 type: "assistant",
1932 session_id: "sdk-session-compacted-usage",
1933 uuid: "assistant-compacted-usage",
1934 parent_tool_use_id: null,
1935 message: {
1936 id: "assistant-message-compacted-usage",
1937 role: "assistant",
1938 content: [],
1939 usage: {
1940 input_tokens: 180,
1941 output_tokens: 20,
1942 },
1943 },
1944 } as unknown as SDKMessage);
1945 harness.query.emit({
1946 type: "system",
1947 subtype: "compact_boundary",
1948 compact_metadata: {
1949 pre_tokens: 200,
1950 post_tokens: 40,
1951 },
1952 session_id: "sdk-session-compacted-usage",
1953 uuid: "compact-boundary-usage",
1954 } as unknown as SDKMessage);
1955 harness.query.emit({
1956 type: "result",
1957 subtype: "success",
1958 is_error: false,
1959 duration_ms: 1234,
1960 duration_api_ms: 1200,
1961 num_turns: 2,
1962 result: "done",
1963 stop_reason: "end_turn",
1964 session_id: "sdk-session-compacted-usage",
1965 usage: {
1966 input_tokens: 400,
1967 output_tokens: 50,
1968 },
1969 modelUsage: {
1970 "claude-opus-4-6": {
1971 contextWindow: 200000,
1972 maxOutputTokens: 64000,
1973 },
1974 },
1975 } as unknown as SDKMessage);
18571976
1858 const activeSessions = yield* adapter.listSessions();
18591977 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1860 assert.equal(queries.length, 2);
1861 assert.equal(queries[1]?.closeCalls, 0);
1862 assert.equal(activeSessions.length, 1);
1863 assert.deepEqual(activeSessions[0]?.resumeCursor, replacement.resumeCursor);
1864 assert.deepEqual(
1865 runtimeEvents
1866 .filter((event) => event.type.startsWith("session."))
1867 .map((event) => event.type),
1868 [
1869 "session.started",
1870 "session.configured",
1871 "session.state.changed",
1872 "session.started",
1873 "session.configured",
1874 "session.state.changed",
1875 ],
1978 const finalUsageEvent = runtimeEvents.findLast(
1979 (event) => event.type === "thread.token-usage.updated",
1980 );
1981 assert.equal(finalUsageEvent?.type, "thread.token-usage.updated");
1982 if (finalUsageEvent?.type === "thread.token-usage.updated") {
1983 assert.deepEqual(finalUsageEvent.payload.usage, {
1984 usedTokens: 40,
1985 lastUsedTokens: 200,
1986 totalProcessedTokens: 450,
1987 maxTokens: 200000,
1988 });
1989 }
1990 assert.equal(
1991 runtimeEvents.find((event) => event.type === "turn.completed")?.type,
1992 "turn.completed",
18761993 );
18771994 }).pipe(
18781995 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1879 Effect.provide(layer),
1996 Effect.provide(harness.layer),
18801997 );
18811998 });
18821999
apps/server/src/provider/Layers/ClaudeAdapter.ts+44-60
......@@ -14,7 +14,6 @@ import {
1414 type PermissionResult,
1515 type PermissionUpdate,
1616 type SDKMessage,
17 type SDKControlGetContextUsageResponse,
1817 type SDKResultMessage,
1918 type SettingSource,
2019 type SDKUserMessage,
......@@ -69,7 +68,6 @@ import * as Effect from "effect/Effect";
6968import * as Exit from "effect/Exit";
7069import * as FileSystem from "effect/FileSystem";
7170import * as Fiber from "effect/Fiber";
72import * as Option from "effect/Option";
7371import * as Path from "effect/Path";
7472import * as Queue from "effect/Queue";
7573import * as Ref from "effect/Ref";
......@@ -145,6 +143,8 @@ interface ClaudeTurnState {
145143 readonly assistantTextBlocks: Map<number, AssistantTextBlockState>;
146144 readonly assistantTextBlockOrder: Array<AssistantTextBlockState>;
147145 readonly capturedProposedPlanKeys: Set<string>;
146 latestAssistantUsage: unknown | undefined;
147 compactedSinceLatestAssistantUsage: boolean;
148148 nextSyntheticAssistantBlockIndex: number;
149149}
150150
......@@ -321,7 +321,6 @@ interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {
321321 readonly setModel: (model?: string) => Promise<void>;
322322 readonly setPermissionMode: (mode: PermissionMode) => Promise<void>;
323323 readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>;
324 readonly getContextUsage?: () => Promise<SDKControlGetContextUsageResponse>;
325324 readonly close: () => void;
326325}
327326
......@@ -608,20 +607,6 @@ function normalizeClaudeActiveTokenUsage(
608607 });
609608}
610609
611function normalizeClaudeContextUsageApiSnapshot(
612 value: SDKControlGetContextUsageResponse,
613 totalProcessedTokens?: number,
614): ThreadTokenUsageSnapshot | undefined {
615 const autoCompactThreshold = finitePositiveInteger(value.autoCompactThreshold);
616 return makeClaudeTokenUsageSnapshot({
617 activeTokens: value.totalTokens,
618 contextWindow: value.maxTokens,
619 ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}),
620 compactsAutomatically: value.isAutoCompactEnabled,
621 ...(autoCompactThreshold !== undefined ? { autoCompactThreshold } : {}),
622 });
623}
624
625610function compactBoundaryTokenUsageSnapshot(
626611 message: Record<string, unknown>,
627612 contextWindow?: number,
......@@ -2133,29 +2118,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
21332118 });
21342119 });
21352120
2136 const queryCurrentContextUsage = Effect.fn("queryCurrentContextUsage")(function* (
2137 context: ClaudeSessionContext,
2138 totalProcessedTokens?: number,
2139 ) {
2140 if (!context.query.getContextUsage) {
2141 return undefined;
2142 }
2143
2144 const usage = yield* Effect.promise(async () => {
2145 try {
2146 return await context.query.getContextUsage?.();
2147 } catch {
2148 return undefined;
2149 }
2150 }).pipe(Effect.timeoutOption("1 second"));
2151 if (Option.isNone(usage) || !usage.value) {
2152 return undefined;
2153 }
2154
2155 context.lastKnownContextWindow = usage.value.maxTokens;
2156 return normalizeClaudeContextUsageApiSnapshot(usage.value, totalProcessedTokens);
2157 });
2158
21592121 const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* (
21602122 context: ClaudeSessionContext,
21612123 input: {
......@@ -2256,10 +2218,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
22562218 context.lastKnownTotalProcessedTokens = accumulatedTotalProcessedTokens;
22572219 }
22582220
2259 const contextUsageSnapshot = yield* queryCurrentContextUsage(
2260 context,
2261 accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens,
2262 );
2221 // Avoid getContextUsage because its token-count fallback can make extra model requests.
22632222 const resultUsageRecord =
22642223 result?.usage && typeof result.usage === "object" && !Array.isArray(result.usage)
22652224 ? (result.usage as Record<string, unknown>)
......@@ -2281,24 +2240,31 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
22812240 accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens,
22822241 )
22832242 : undefined;
2243 const latestAssistantSnapshot = normalizeClaudeActiveTokenUsage(
2244 context.turnState?.latestAssistantUsage,
2245 maxTokens,
2246 accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens,
2247 );
22842248 const lastGoodUsage = context.lastKnownTokenUsage;
22852249 const usageSnapshot: ThreadTokenUsageSnapshot | undefined =
2286 contextUsageSnapshot ??
2287 (resultTotalOnly && lastGoodUsage
2288 ? {
2289 ...lastGoodUsage,
2290 ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0
2291 ? { maxTokens }
2292 : {}),
2293 ...(typeof accumulatedTotalProcessedTokens === "number" &&
2294 Number.isFinite(accumulatedTotalProcessedTokens) &&
2295 accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens
2296 ? {
2297 totalProcessedTokens: accumulatedTotalProcessedTokens,
2298 }
2299 : {}),
2300 }
2301 : resultIterationSnapshot) ??
2250 latestAssistantSnapshot ??
2251 (context.turnState?.compactedSinceLatestAssistantUsage
2252 ? undefined
2253 : resultTotalOnly && lastGoodUsage
2254 ? {
2255 ...lastGoodUsage,
2256 ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0
2257 ? { maxTokens }
2258 : {}),
2259 ...(typeof accumulatedTotalProcessedTokens === "number" &&
2260 Number.isFinite(accumulatedTotalProcessedTokens) &&
2261 accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens
2262 ? {
2263 totalProcessedTokens: accumulatedTotalProcessedTokens,
2264 }
2265 : {}),
2266 }
2267 : resultIterationSnapshot) ??
23022268 (lastGoodUsage
23032269 ? {
23042270 ...lastGoodUsage,
......@@ -2950,6 +2916,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
29502916 assistantTextBlocks: new Map(),
29512917 assistantTextBlockOrder: [],
29522918 capturedProposedPlanKeys: new Set(),
2919 latestAssistantUsage: undefined,
2920 compactedSinceLatestAssistantUsage: false,
29532921 nextSyntheticAssistantBlockIndex: -1,
29542922 };
29552923 context.session = {
......@@ -3010,6 +2978,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
30102978
30112979 if (context.turnState) {
30122980 context.turnState.items.push(message.message);
2981 if (
2982 normalizeClaudeActiveTokenUsage(
2983 message.message.usage,
2984 context.lastKnownContextWindow,
2985 context.lastKnownTotalProcessedTokens,
2986 )
2987 ) {
2988 context.turnState.latestAssistantUsage = message.message.usage;
2989 context.turnState.compactedSinceLatestAssistantUsage = false;
2990 }
30132991 yield* backfillAssistantTextBlocksFromSnapshot(context, message);
30142992 }
30152993
......@@ -3174,6 +3152,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
31743152 });
31753153 return;
31763154 case "compact_boundary":
3155 if (context.turnState) {
3156 context.turnState.latestAssistantUsage = undefined;
3157 context.turnState.compactedSinceLatestAssistantUsage = true;
3158 }
31773159 yield* emitThreadTokenUsage(
31783160 context,
31793161 compactBoundaryTokenUsageSnapshot(
......@@ -4576,6 +4558,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
45764558 assistantTextBlocks: new Map(),
45774559 assistantTextBlockOrder: [],
45784560 capturedProposedPlanKeys: new Set(),
4561 latestAssistantUsage: undefined,
4562 compactedSinceLatestAssistantUsage: false,
45794563 nextSyntheticAssistantBlockIndex: -1,
45804564 };
45814565