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", () => {...@@ -1791,92 +1791,209 @@ describe("ClaudeAdapterLive", () => {
1791 );1791 );
1792 });1792 });
17931793
1794 it.effect("keeps a resumed replacement session during slow stop cleanup", () => {1794 it.effect("completes with result usage without querying current context usage", () => {
1795 const queries: FakeClaudeQuery[] = [];1795 const harness = makeHarness();
1796 let signalUsageStarted: () => void = () => undefined;1796 let getContextUsageCalls = 0;
1797 const usageStarted = new Promise<void>((resolve) => {1797 Object.assign(harness.query, {
1798 signalUsageStarted = resolve;1798 getContextUsage: async () => {
1799 getContextUsageCalls += 1;
1800 return {
1801 totalTokens: 999,
1802 maxTokens: 200000,
1803 isAutoCompactEnabled: true,
1804 };
1805 },
1799 });1806 });
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
1826 return Effect.gen(function* () {1807 return Effect.gen(function* () {
1827 const adapter = yield* ClaudeAdapter;1808 const adapter = yield* ClaudeAdapter;
1828 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe(1809 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe(
1829 Stream.runCollect,1810 Stream.runCollect,
1830 Effect.forkChild,1811 Effect.forkChild,
1831 );1812 );
1832 const firstSession = yield* adapter.startSession({1813 yield* adapter.startSession({
1833 threadId: THREAD_ID,1814 threadId: THREAD_ID,
1834 provider: ProviderDriverKind.make("claudeAgent"),1815 provider: ProviderDriverKind.make("claudeAgent"),
1835 runtimeMode: "full-access",1816 runtimeMode: "full-access",
1836 });1817 });
1837 yield* adapter.sendTurn({1818 yield* adapter.sendTurn({
1838 threadId: firstSession.threadId,1819 threadId: THREAD_ID,
1839 input: "hello",1820 input: "hello",
1840 attachments: [],1821 attachments: [],
1841 });1822 });
18421823
1843 const interruptFiber = yield* adapter1824 harness.query.emit({
1844 .interruptTurn(firstSession.threadId)1825 type: "assistant",
1845 .pipe(Effect.forkChild);1826 session_id: "sdk-session-result-usage",
1846 yield* Effect.promise(() => usageStarted);1827 uuid: "assistant-result-usage-1",
1847 assert.equal(queries[0]?.closeCalls, 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({
1850 threadId: THREAD_ID,1921 threadId: THREAD_ID,
1851 provider: ProviderDriverKind.make("claudeAgent"),1922 provider: ProviderDriverKind.make("claudeAgent"),
1852 runtimeMode: "full-access",1923 runtimeMode: "full-access",
1853 resumeCursor: firstSession.resumeCursor,
1854 });1924 });
1855 yield* TestClock.adjust("1 second");1925 yield* adapter.sendTurn({
1856 yield* Fiber.join(interruptFiber);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();
1859 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));1977 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1860 assert.equal(queries.length, 2);1978 const finalUsageEvent = runtimeEvents.findLast(
1861 assert.equal(queries[1]?.closeCalls, 0);1979 (event) => event.type === "thread.token-usage.updated",
1862 assert.equal(activeSessions.length, 1);1980 );
1863 assert.deepEqual(activeSessions[0]?.resumeCursor, replacement.resumeCursor);1981 assert.equal(finalUsageEvent?.type, "thread.token-usage.updated");
1864 assert.deepEqual(1982 if (finalUsageEvent?.type === "thread.token-usage.updated") {
1865 runtimeEvents1983 assert.deepEqual(finalUsageEvent.payload.usage, {
1866 .filter((event) => event.type.startsWith("session."))1984 usedTokens: 40,
1867 .map((event) => event.type),1985 lastUsedTokens: 200,
1868 [1986 totalProcessedTokens: 450,
1869 "session.started",1987 maxTokens: 200000,
1870 "session.configured",1988 });
1871 "session.state.changed",1989 }
1872 "session.started",1990 assert.equal(
1873 "session.configured",1991 runtimeEvents.find((event) => event.type === "turn.completed")?.type,
1874 "session.state.changed",1992 "turn.completed",
1875 ],
1876 );1993 );
1877 }).pipe(1994 }).pipe(
1878 Effect.provideService(Random.Random, makeDeterministicRandomService()),1995 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1879 Effect.provide(layer),1996 Effect.provide(harness.layer),
1880 );1997 );
1881 });1998 });
18821999
apps/server/src/provider/Layers/ClaudeAdapter.ts+44-60
...@@ -14,7 +14,6 @@ import {...@@ -14,7 +14,6 @@ import {
14 type PermissionResult,14 type PermissionResult,
15 type PermissionUpdate,15 type PermissionUpdate,
16 type SDKMessage,16 type SDKMessage,
17 type SDKControlGetContextUsageResponse,
18 type SDKResultMessage,17 type SDKResultMessage,
19 type SettingSource,18 type SettingSource,
20 type SDKUserMessage,19 type SDKUserMessage,
...@@ -69,7 +68,6 @@ import * as Effect from "effect/Effect";...@@ -69,7 +68,6 @@ import * as Effect from "effect/Effect";
69import * as Exit from "effect/Exit";68import * as Exit from "effect/Exit";
70import * as FileSystem from "effect/FileSystem";69import * as FileSystem from "effect/FileSystem";
71import * as Fiber from "effect/Fiber";70import * as Fiber from "effect/Fiber";
72import * as Option from "effect/Option";
73import * as Path from "effect/Path";71import * as Path from "effect/Path";
74import * as Queue from "effect/Queue";72import * as Queue from "effect/Queue";
75import * as Ref from "effect/Ref";73import * as Ref from "effect/Ref";
...@@ -145,6 +143,8 @@ interface ClaudeTurnState {...@@ -145,6 +143,8 @@ interface ClaudeTurnState {
145 readonly assistantTextBlocks: Map<number, AssistantTextBlockState>;143 readonly assistantTextBlocks: Map<number, AssistantTextBlockState>;
146 readonly assistantTextBlockOrder: Array<AssistantTextBlockState>;144 readonly assistantTextBlockOrder: Array<AssistantTextBlockState>;
147 readonly capturedProposedPlanKeys: Set<string>;145 readonly capturedProposedPlanKeys: Set<string>;
146 latestAssistantUsage: unknown | undefined;
147 compactedSinceLatestAssistantUsage: boolean;
148 nextSyntheticAssistantBlockIndex: number;148 nextSyntheticAssistantBlockIndex: number;
149}149}
150150
...@@ -321,7 +321,6 @@ interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {...@@ -321,7 +321,6 @@ interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {
321 readonly setModel: (model?: string) => Promise<void>;321 readonly setModel: (model?: string) => Promise<void>;
322 readonly setPermissionMode: (mode: PermissionMode) => Promise<void>;322 readonly setPermissionMode: (mode: PermissionMode) => Promise<void>;
323 readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>;323 readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>;
324 readonly getContextUsage?: () => Promise<SDKControlGetContextUsageResponse>;
325 readonly close: () => void;324 readonly close: () => void;
326}325}
327326
...@@ -608,20 +607,6 @@ function normalizeClaudeActiveTokenUsage(...@@ -608,20 +607,6 @@ function normalizeClaudeActiveTokenUsage(
608 });607 });
609}608}
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
625function compactBoundaryTokenUsageSnapshot(610function compactBoundaryTokenUsageSnapshot(
626 message: Record<string, unknown>,611 message: Record<string, unknown>,
627 contextWindow?: number,612 contextWindow?: number,
...@@ -2133,29 +2118,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2133,29 +2118,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2133 });2118 });
2134 });2119 });
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
2159 const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* (2121 const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* (
2160 context: ClaudeSessionContext,2122 context: ClaudeSessionContext,
2161 input: {2123 input: {
...@@ -2256,10 +2218,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2256,10 +2218,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2256 context.lastKnownTotalProcessedTokens = accumulatedTotalProcessedTokens;2218 context.lastKnownTotalProcessedTokens = accumulatedTotalProcessedTokens;
2257 }2219 }
22582220
2259 const contextUsageSnapshot = yield* queryCurrentContextUsage(2221 // Avoid getContextUsage because its token-count fallback can make extra model requests.
2260 context,
2261 accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens,
2262 );
2263 const resultUsageRecord =2222 const resultUsageRecord =
2264 result?.usage && typeof result.usage === "object" && !Array.isArray(result.usage)2223 result?.usage && typeof result.usage === "object" && !Array.isArray(result.usage)
2265 ? (result.usage as Record<string, unknown>)2224 ? (result.usage as Record<string, unknown>)
...@@ -2281,24 +2240,31 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2281,24 +2240,31 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2281 accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens,2240 accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens,
2282 )2241 )
2283 : undefined;2242 : undefined;
2243 const latestAssistantSnapshot = normalizeClaudeActiveTokenUsage(
2244 context.turnState?.latestAssistantUsage,
2245 maxTokens,
2246 accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens,
2247 );
2284 const lastGoodUsage = context.lastKnownTokenUsage;2248 const lastGoodUsage = context.lastKnownTokenUsage;
2285 const usageSnapshot: ThreadTokenUsageSnapshot | undefined =2249 const usageSnapshot: ThreadTokenUsageSnapshot | undefined =
2286 contextUsageSnapshot ??2250 latestAssistantSnapshot ??
2287 (resultTotalOnly && lastGoodUsage2251 (context.turnState?.compactedSinceLatestAssistantUsage
2288 ? {2252 ? undefined
2289 ...lastGoodUsage,2253 : resultTotalOnly && lastGoodUsage
2290 ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 02254 ? {
2291 ? { maxTokens }2255 ...lastGoodUsage,
2292 : {}),2256 ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0
2293 ...(typeof accumulatedTotalProcessedTokens === "number" &&2257 ? { maxTokens }
2294 Number.isFinite(accumulatedTotalProcessedTokens) &&2258 : {}),
2295 accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens2259 ...(typeof accumulatedTotalProcessedTokens === "number" &&
2296 ? {2260 Number.isFinite(accumulatedTotalProcessedTokens) &&
2297 totalProcessedTokens: accumulatedTotalProcessedTokens,2261 accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens
2298 }2262 ? {
2299 : {}),2263 totalProcessedTokens: accumulatedTotalProcessedTokens,
2300 }2264 }
2301 : resultIterationSnapshot) ??2265 : {}),
2266 }
2267 : resultIterationSnapshot) ??
2302 (lastGoodUsage2268 (lastGoodUsage
2303 ? {2269 ? {
2304 ...lastGoodUsage,2270 ...lastGoodUsage,
...@@ -2950,6 +2916,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2950,6 +2916,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2950 assistantTextBlocks: new Map(),2916 assistantTextBlocks: new Map(),
2951 assistantTextBlockOrder: [],2917 assistantTextBlockOrder: [],
2952 capturedProposedPlanKeys: new Set(),2918 capturedProposedPlanKeys: new Set(),
2919 latestAssistantUsage: undefined,
2920 compactedSinceLatestAssistantUsage: false,
2953 nextSyntheticAssistantBlockIndex: -1,2921 nextSyntheticAssistantBlockIndex: -1,
2954 };2922 };
2955 context.session = {2923 context.session = {
...@@ -3010,6 +2978,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3010,6 +2978,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
30102978
3011 if (context.turnState) {2979 if (context.turnState) {
3012 context.turnState.items.push(message.message);2980 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 }
3013 yield* backfillAssistantTextBlocksFromSnapshot(context, message);2991 yield* backfillAssistantTextBlocksFromSnapshot(context, message);
3014 }2992 }
30152993
...@@ -3174,6 +3152,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3174,6 +3152,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3174 });3152 });
3175 return;3153 return;
3176 case "compact_boundary":3154 case "compact_boundary":
3155 if (context.turnState) {
3156 context.turnState.latestAssistantUsage = undefined;
3157 context.turnState.compactedSinceLatestAssistantUsage = true;
3158 }
3177 yield* emitThreadTokenUsage(3159 yield* emitThreadTokenUsage(
3178 context,3160 context,
3179 compactBoundaryTokenUsageSnapshot(3161 compactBoundaryTokenUsageSnapshot(
...@@ -4576,6 +4558,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4576,6 +4558,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4576 assistantTextBlocks: new Map(),4558 assistantTextBlocks: new Map(),
4577 assistantTextBlockOrder: [],4559 assistantTextBlockOrder: [],
4578 capturedProposedPlanKeys: new Set(),4560 capturedProposedPlanKeys: new Set(),
4561 latestAssistantUsage: undefined,
4562 compactedSinceLatestAssistantUsage: false,
4579 nextSyntheticAssistantBlockIndex: -1,4563 nextSyntheticAssistantBlockIndex: -1,
4580 };4564 };
45814565