From 64cb94558839f92f0f18e6315fd510b3b48b1984 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Nov 2026 05:27:26 +0000 Subject: [PATCH 1/3] test(server): reproduce #29951, /clear leaves the Claude context meter stale After /clear the Claude CLI emits conversation_reset, system/init and a zero-usage result. The adapter emits no tokenUsage for that turn, so the newest provider turn report is still the pre-clear one. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 45d21b5944e24..ffcff5e7793c5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7266,6 +7266,93 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("reports an empty context when /clear resets the conversation", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-before-clear"), + text: "Continue.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + usageSnapshotFrame("msg_before_clear", "00000000-0000-4000-8000-000000000800", { + ...zeroUsage, + input_tokens: 166_000, + output_tokens: 100, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000801", result: "Done." }), + ); + yield* Queue.take(harness.terminalReceipts); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-clear"), + text: "/clear", + attachments: [], + }), + ); + // Claude CLI 2.1.304 answers /clear with these frames and makes no API request. + const frames = [ + claudeSdkFrame({ + type: "conversation_reset", + trigger: "clear", + new_conversation_id: "00000000-0000-4000-8000-000000000802", + uuid: "00000000-0000-4000-8000-000000000803", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "init", + uuid: "00000000-0000-4000-8000-000000000804", + session_id: WAKE_NATIVE_SESSION, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000805", + result: "", + numTurns: 0, + usage: zeroUsage, + }), + ]; + for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "completed"); + + const contextReports = harness.events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage + ? [{ runAttemptId: event.providerTurn.runAttemptId, ...event.providerTurn.tokenUsage }] + : [], + ); + const latest = contextReports.at(-1); + assert.deepEqual( + { runAttemptId: latest?.runAttemptId, usedTokens: latest?.usedTokens }, + { runAttemptId: RunAttemptId.make("attempt-clear"), usedTokens: 0 }, + "the newest context report after /clear must be the cleared turn's empty context", + ); + assert.equal(latest?.maxTokens, contextReports[0]?.maxTokens); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect("shows a hook-dropped Claude prompt once and skips info-level lines", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; From a930b9a07ee43345a473ad5bb91a9f1af9e4abc5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Nov 2026 05:37:01 +0000 Subject: [PATCH 2/3] fix(server): /clear resets the Claude context meter Claude answers /clear with a conversation_reset frame and makes no API request, so the meter kept the pre-clear token count. Report an empty context for the current turn on every conversation_reset, sharing the replaced-context report with the compaction path. Fixes pingdotgg/t3code#29951 Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.ts | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 674f55c524d75..2ac3bb19a6273 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -5135,24 +5135,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - // Usage without input is a placeholder, not a context measurement. - const emitContextUsage = Effect.fnUntraced(function* ( + const emitTokenUsage = Effect.fnUntraced(function* ( context: ActiveClaudeTurnContext, - usage: ClaudeMessageUsage, - requestStarted = false, + tokenUsage: NonNullable, ) { - const now = yield* DateTime.now; - // Only message_start marks the request start; an assistant snapshot - // arrives after generation, so it leaves the cache expiry unknown. - if (requestStarted && context.streamUsage !== null) context.streamUsage.requestedAt = now; - const tokenUsage = claudeProviderTurnTokenUsage( - usage, - context.input.modelSelection, - DateTime.formatIso(now), - adapterOptions.settings.autoCompactWindow, - context.streamUsage?.requestedAt, - ); - if (tokenUsage.inputTokens <= 0) return; yield* emitProviderEvent({ type: "provider_turn.updated", driver: CLAUDE_PROVIDER, @@ -5176,6 +5162,42 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); + // Usage without input is a placeholder, not a context measurement. + const emitContextUsage = Effect.fnUntraced(function* ( + context: ActiveClaudeTurnContext, + usage: ClaudeMessageUsage, + requestStarted = false, + ) { + const now = yield* DateTime.now; + // Only message_start marks the request start; an assistant snapshot + // arrives after generation, so it leaves the cache expiry unknown. + if (requestStarted && context.streamUsage !== null) context.streamUsage.requestedAt = now; + const tokenUsage = claudeProviderTurnTokenUsage( + usage, + context.input.modelSelection, + DateTime.formatIso(now), + adapterOptions.settings.autoCompactWindow, + context.streamUsage?.requestedAt, + ); + if (tokenUsage.inputTokens <= 0) return; + yield* emitTokenUsage(context, tokenUsage); + }); + + // Compaction and /clear replace the context; only its new size is known. + const emitReplacedContextUsage = Effect.fnUntraced(function* ( + context: ActiveClaudeTurnContext, + usedTokens: number, + now: DateTime.Utc, + ) { + const maxTokens = claudeContextWindow(context.input.modelSelection); + yield* emitTokenUsage(context, { + usedTokens, + maxTokens, + ...claudeAutoCompactThreshold(adapterOptions.settings.autoCompactWindow, maxTokens), + updatedAt: DateTime.formatIso(now), + }); + }); + // Claude has no recovered frame; the next assistant message proves recovery. // Runs on every model output frame and is almost always a no-op; only a pending retry gets a span. const completeProviderRetry = Effect.fnUntraced(function* ( @@ -8758,6 +8780,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } } + // Every trigger discards the conversation, so the reset counts whatever it says. + if (message.type === "conversation_reset") { + if (context.streamUsage !== null) context.streamUsage.usage = null; + yield* emitReplacedContextUsage(context, 0, yield* DateTime.now); + return; + } + if ( message.type === "system" && (message.subtype === "compact_boundary" || @@ -8828,36 +8857,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? Math.round(postTokens) : undefined; if (afterTokenCount !== undefined) { - const maxTokens = claudeContextWindow(context.input.modelSelection); - yield* emitProviderEvent({ - type: "provider_turn.updated", - driver: CLAUDE_PROVIDER, - threadId: context.input.threadId, - providerTurn: { - id: context.providerTurnId, - providerThreadId: context.input.providerThread.id, - nodeId: context.input.rootNodeId, - runAttemptId: context.input.attemptId, - nativeTurnRef: { - driver: CLAUDE_PROVIDER, - nativeId: context.nativeTurnId, - strength: "strong", - }, - ordinal: context.providerTurnOrdinal, - status: "running", - startedAt: context.startedAt, - completedAt: null, - tokenUsage: { - usedTokens: afterTokenCount, - maxTokens, - ...claudeAutoCompactThreshold( - adapterOptions.settings.autoCompactWindow, - maxTokens, - ), - updatedAt: DateTime.formatIso(now), - }, - }, - }); + yield* emitReplacedContextUsage(context, afterTokenCount, now); } yield* emitProviderEvent({ type: "turn_item.updated", From 4ee6ad51dd582619b82c0472cd0831ddb6068149 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Nov 2026 05:51:57 +0000 Subject: [PATCH 3/3] fix(server): clarify the conversation_reset comment Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 2ac3bb19a6273..8a96df4ea9219 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -8780,7 +8780,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } } - // Every trigger discards the conversation, so the reset counts whatever it says. + // Every trigger discards the conversation, so reset whatever the trigger is. if (message.type === "conversation_reset") { if (context.streamUsage !== null) context.streamUsage.usage = null; yield* emitReplacedContextUsage(context, 0, yield* DateTime.now);