From cc1155ba6d311476006b559c84f77365514e60a9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 24 Aug 2026 15:51:00 -0700 Subject: [PATCH 01/12] feat(claude): compact old threads before they burn through usage --- .../src/provider/Layers/ClaudeAdapter.test.ts | 75 ++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 85 +++++++++++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 8 +- .../ProviderInstanceRegistryLive.test.ts | 1 + .../provider/Layers/ProviderRegistry.test.ts | 8 ++ apps/server/src/serverSettings.test.ts | 2 + apps/web/src/components/chat/ChatComposer.tsx | 80 ++++++++++++++++- .../chat/ContextWindowMeter.logic.test.ts | 52 ++++++++++++ .../chat/ContextWindowMeter.logic.ts | 29 +++++++ .../components/chat/ContextWindowMeter.tsx | 19 ++++- .../settings/ProviderSettingsForm.test.ts | 12 +++ apps/web/src/lib/contextWindow.test.ts | 2 + apps/web/src/lib/contextWindow.ts | 1 + docs/user/providers-claude.md | 11 +++ packages/contracts/src/providerRuntime.ts | 1 + packages/contracts/src/settings.test.ts | 22 +++++ packages/contracts/src/settings.ts | 15 +++- 17 files changed, 418 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index a45eae6faf3e..d87c7a68e01a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -413,6 +413,25 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("passes the configured auto-compaction window to Claude", () => { + const harness = makeHarness({ claudeConfig: { autoCompactWindow: "300000" } }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const options = harness.getLastCreateQueryInput()?.options; + assert.deepEqual(options?.settings, { autoCompactWindow: 300000 }); + assert.deepEqual(options?.supportedDialogKinds, ["resume_return"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("forwards claude effort levels into query options", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -4400,6 +4419,62 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("routes Claude resume compaction through the shared user-input UI", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: { resume: "550e8400-e29b-41d4-a716-446655440000" }, + runtimeMode: "full-access", + }); + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const onUserDialog = harness.getLastCreateQueryInput()?.options.onUserDialog; + assert.equal(typeof onUserDialog, "function"); + if (!onUserDialog) return; + + const dialogPromise = onUserDialog( + { + dialogKind: "resume_return", + payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 }, + }, + { signal: new AbortController().signal }, + ); + + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some" || requested.value.type !== "user-input.requested") return; + const question = requested.value.payload.questions[0]; + assert.equal(question?.header, "Resume session"); + assert.match(question?.question ?? "", /2h 25m/); + assert.match(question?.question ?? "", /275,123 tokens/); + assert.deepEqual( + question?.options.map((option) => option.label), + ["Compact and continue", "Keep full history", "Don't ask again"], + ); + if (!question || !requested.value.requestId) return; + + yield* adapter.respondToUserInput( + session.threadId, + ApprovalRequestId.make(requested.value.requestId), + { [question.id]: "Compact and continue" }, + ); + + const resolved = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolved._tag, "Some"); + if (resolved._tag === "Some") assert.equal(resolved.value.type, "user-input.resolved"); + assert.deepEqual(yield* Effect.promise(() => dialogPromise), { + behavior: "completed", + result: "compact", + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d42049ebefc3..f62c8146d9a7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -543,6 +543,7 @@ function makeClaudeTokenUsageSnapshot(input: { readonly totalProcessedTokens?: number; readonly lastUsedTokens?: number; readonly compactsAutomatically?: boolean; + readonly autoCompactThreshold?: number; }): ThreadTokenUsageSnapshot | undefined { const activeTokens = finiteNonNegativeInteger(input.activeTokens); if (activeTokens === undefined || activeTokens <= 0) { @@ -570,6 +571,9 @@ function makeClaudeTokenUsageSnapshot(input: { ...(input.compactsAutomatically !== undefined ? { compactsAutomatically: input.compactsAutomatically } : {}), + ...(input.autoCompactThreshold !== undefined + ? { autoCompactThreshold: input.autoCompactThreshold } + : {}), }; } @@ -604,11 +608,13 @@ function normalizeClaudeContextUsageApiSnapshot( value: SDKControlGetContextUsageResponse, totalProcessedTokens?: number, ): ThreadTokenUsageSnapshot | undefined { + const autoCompactThreshold = finitePositiveInteger(value.autoCompactThreshold); return makeClaudeTokenUsageSnapshot({ activeTokens: value.totalTokens, contextWindow: value.maxTokens, ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}), compactsAutomatically: value.isAutoCompactEnabled, + ...(autoCompactThreshold !== undefined ? { autoCompactThreshold } : {}), }); } @@ -4001,6 +4007,76 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } satisfies PermissionResult; }); + const handleResumeDialog = Effect.fn("handleResumeDialog")(function* ( + request: Parameters>[0], + callbackOptions: Parameters>[1], + ) { + if (request.dialogKind !== "resume_return") { + return { behavior: "cancelled" as const }; + } + + const context = yield* Ref.get(contextRef); + if (!context) { + return { behavior: "cancelled" as const }; + } + + const estimatedTokens = finiteNonNegativeInteger(request.payload.estimatedTokens) ?? 0; + const ageMinutes = finiteNonNegativeInteger(request.payload.sessionAgeMinutes) ?? 0; + const ageLabel = + ageMinutes >= 60 + ? `${Math.floor(ageMinutes / 60)}h ${ageMinutes % 60}m` + : `${ageMinutes}m`; + const question = `This session is ${ageLabel} old and uses ${estimatedTokens.toLocaleString("en-US")} tokens. Compact it before continuing?`; + const result = yield* handleAskUserQuestion( + context, + { + questions: [ + { + header: "Resume session", + question, + options: [ + { + label: "Compact and continue", + description: "Resume with a summary and use fewer tokens.", + }, + { + label: "Keep full history", + description: "Resume without changing the conversation.", + }, + { + label: "Don't ask again", + description: "Keep full history and skip future resume prompts.", + }, + ], + multiSelect: false, + }, + ], + }, + { + signal: callbackOptions.signal, + ...(request.toolUseID ? { toolUseID: request.toolUseID } : {}), + }, + ); + + if (result.behavior !== "allow") { + return { behavior: "cancelled" as const }; + } + + const answers = result.updatedInput.answers; + const selection = + answers && typeof answers === "object" && !Array.isArray(answers) + ? (answers as Record)[question] + : undefined; + const action = + selection === "Compact and continue" + ? "compact" + : selection === "Don't ask again" + ? "never" + : "continue"; + + return { behavior: "completed" as const, result: action }; + }); + const canUseToolEffect = Effect.fn("canUseTool")(function* ( toolName: Parameters[0], toolInput: Parameters[1], @@ -4161,6 +4237,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); + const onUserDialog: NonNullable = ( + request, + callbackOptions, + ) => runPromise(handleResumeDialog(request, callbackOptions)); const claudeBinaryPath = claudeSdkExecutablePath; const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; @@ -4196,6 +4276,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), ...(ultracode ? { ultracode: true } : {}), + ...(claudeSettings.autoCompactWindow + ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) } + : {}), }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); // The attachments dir grant lets the agent Read/copy pasted images at @@ -4228,6 +4311,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], env: claudeEnvironment, additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 806f7e19b905..f7bb7827d47d 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -927,7 +927,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); - const slashCommands = capabilities?.slashCommands ?? []; + const slashCommands = [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, + ...(capabilities?.slashCommands ?? []), + ]; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); if (!capabilities) { diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index a429367bfeb0..b6531a69c176 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -106,6 +106,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => homePath: "", customModels: [], launchArgs: "", + autoCompactWindow: "", ...overrides, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index f7ae95d8a927..a27d9a5c4841 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2154,6 +2154,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "review", description: "Review a pull request", @@ -2197,6 +2201,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "ui", description: "Explore and refine UI", diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 35ef5e976223..554f41eb87ac 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -190,6 +190,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { homePath: "", customModels: ["claude-custom"], launchArgs: "", + autoCompactWindow: "", }); assert.deepEqual( next.textGenerationModelSelection, @@ -581,6 +582,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { homePath: "", customModels: [], launchArgs: "", + autoCompactWindow: "", }); assert.deepEqual(next.providers.opencode, { // OpenCode is disabled by default; this update only touches paths. diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 1ec58e0de702..2042737b69cc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -122,7 +122,12 @@ import { renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; -import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; +import { + resolveContextWindowModelDisplayName, + shouldOfferResumeCompaction, +} from "./ContextWindowMeter.logic"; +import { formatContextWindowTokens } from "~/lib/contextWindow"; +import { useNowMinute } from "../../hooks/useNowMinute"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; @@ -457,6 +462,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onCompactContext?: (() => void) | undefined; }) { return ( <> @@ -464,6 +470,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( ) : null} {props.isPreparingWorktree ? ( @@ -1008,6 +1016,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), [activeThreadModelSelection, modelOptionsByInstance], ); + const nowMinute = useNowMinute(); + const resumeCompactionNoticeKey = + activeThreadId && activeContextWindow + ? `${activeThreadId}:${activeContextWindow.updatedAt}` + : null; + const [dismissedResumeCompactionNoticeKey, setDismissedResumeCompactionNoticeKey] = useState< + string | null + >(null); + const showResumeCompactionNotice = + phase !== "running" && + pendingUserInputs.length === 0 && + resumeCompactionNoticeKey !== null && + dismissedResumeCompactionNoticeKey !== resumeCompactionNoticeKey && + shouldOfferResumeCompaction({ + provider: selectedProvider, + usedTokens: activeContextWindow?.usedTokens, + updatedAt: activeContextWindow?.updatedAt, + now: `${nowMinute}:00.000Z`, + }); // ------------------------------------------------------------------ // Composer-local state @@ -1982,6 +2009,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) shouldBlurMobileComposerOnSubmit, ], ); + const compactThreadContext = useCallback(() => { + if (phase === "running" || isSendBusy || isConnecting || !activeThreadId) { + return; + } + + if (resumeCompactionNoticeKey) { + setDismissedResumeCompactionNoticeKey(resumeCompactionNoticeKey); + } + promptRef.current = "/compact"; + setComposerDraftPrompt(composerDraftTarget, "/compact"); + onSend(); + }, [ + activeThreadId, + composerDraftTarget, + isConnecting, + isSendBusy, + onSend, + phase, + promptRef, + resumeCompactionNoticeKey, + setComposerDraftPrompt, + ]); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); @@ -2899,6 +2948,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) className={cn("mx-auto w-full min-w-0 max-w-3xl", hasShoulderTab && "pt-7")} data-chat-composer-form="true" > + {showResumeCompactionNotice && activeContextWindow ? ( +
+
+
Resume with less context
+
+ {formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from an older + session +
+
+
+ + +
+
+ ) : null} {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (
diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts index 012d9130ac62..179a356bee77 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { formatContextWindowCompactionMessage, resolveContextWindowModelDisplayName, + shouldOfferResumeCompaction, } from "./ContextWindowMeter.logic"; describe("resolveContextWindowModelDisplayName", () => { @@ -55,4 +56,55 @@ describe("formatContextWindowCompactionMessage", () => { "Context compacts automatically when needed.", ); }); + + it("shows the configured auto-compaction threshold", () => { + expect(formatContextWindowCompactionMessage("Claude Sonnet 5", 300_000)).toBe( + "Compacts automatically at 300,000 tokens.", + ); + }); +}); + +describe("shouldOfferResumeCompaction", () => { + const now = "2026-08-24T12:00:00.000Z"; + + it("matches Claude's old-session age and context thresholds", () => { + expect( + shouldOfferResumeCompaction({ + provider: "claudeAgent", + usedTokens: 100_000, + updatedAt: "2026-08-24T10:50:00.000Z", + now, + }), + ).toBe(true); + }); + + it("does not prompt for recent or smaller sessions", () => { + expect( + shouldOfferResumeCompaction({ + provider: "claudeAgent", + usedTokens: 99_999, + updatedAt: "2026-08-24T10:00:00.000Z", + now, + }), + ).toBe(false); + expect( + shouldOfferResumeCompaction({ + provider: "claudeAgent", + usedTokens: 200_000, + updatedAt: "2026-08-24T10:51:00.000Z", + now, + }), + ).toBe(false); + }); + + it("does not show Claude's resume prompt for another provider", () => { + expect( + shouldOfferResumeCompaction({ + provider: "codex", + usedTokens: 300_000, + updatedAt: "2026-08-24T09:00:00.000Z", + now, + }), + ).toBe(false); + }); }); diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index c87170ffe610..1a0a98f76393 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -1,6 +1,31 @@ import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; +export const CLAUDE_RESUME_COMPACTION_MINUTES = 70; +export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; + +export function shouldOfferResumeCompaction(input: { + readonly provider: string | null | undefined; + readonly usedTokens: number | null | undefined; + readonly updatedAt: string | null | undefined; + readonly now: string; +}): boolean { + if ( + input.provider !== "claudeAgent" || + (input.usedTokens ?? 0) < CLAUDE_RESUME_COMPACTION_TOKENS + ) { + return false; + } + + const updatedAt = Date.parse(input.updatedAt ?? ""); + const now = Date.parse(input.now); + return ( + Number.isFinite(updatedAt) && + Number.isFinite(now) && + now - updatedAt >= CLAUDE_RESUME_COMPACTION_MINUTES * 60_000 + ); +} + export function resolveContextWindowModelDisplayName( selection: ModelSelection | null | undefined, modelOptionsByInstance: ReadonlyMap>, @@ -18,7 +43,11 @@ export function resolveContextWindowModelDisplayName( export function formatContextWindowCompactionMessage( modelDisplayName: string | null | undefined, + autoCompactThreshold?: number | null, ): string { + if (typeof autoCompactThreshold === "number" && autoCompactThreshold > 0) { + return `Compacts automatically at ${autoCompactThreshold.toLocaleString("en-US")} tokens.`; + } return modelDisplayName ? `Context for ${modelDisplayName} compacts automatically when needed.` : "Context compacts automatically when needed."; diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index 6943684b1f58..c67b90695102 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -2,6 +2,7 @@ import { Button } from "../ui/button"; import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; +import { Minimize2Icon } from "lucide-react"; function formatPercentage(value: number | null): string | null { if (value === null || !Number.isFinite(value)) { @@ -16,8 +17,10 @@ function formatPercentage(value: number | null): string | null { export function ContextWindowMeter(props: { usage: ContextWindowSnapshot; modelDisplayName?: string | null; + onCompact?: (() => void) | undefined; + compactDisabled?: boolean | undefined; }) { - const { usage, modelDisplayName } = props; + const { usage, modelDisplayName, onCompact, compactDisabled } = props; const usedPercentage = formatPercentage(usage.usedPercentage); const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); const radius = 9.75; @@ -128,9 +131,21 @@ export function ContextWindowMeter(props: { ) : null} {usage.compactsAutomatically ? (
- {formatContextWindowCompactionMessage(modelDisplayName)} + {formatContextWindowCompactionMessage(modelDisplayName, usage.autoCompactThreshold)}
) : null} + {onCompact ? ( + + ) : null} diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index ea8712a87eb5..af460824c171 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -37,6 +37,18 @@ describe("ProviderSettingsForm helpers", () => { }); }); + it("shows the auto-compaction threshold for Claude providers", () => { + const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")]; + expect(claude).toBeDefined(); + + expect(deriveProviderSettingsFields(claude!).map((field) => field.key)).toEqual([ + "binaryPath", + "homePath", + "autoCompactWindow", + "launchArgs", + ]); + }); + it("preserves unknown config keys while omitting empty configurable fields", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index c3226884a31d..b87c0664403e 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -26,6 +26,7 @@ describe("contextWindow", () => { usedTokens: 14_000, maxTokens: 258_000, compactsAutomatically: true, + autoCompactThreshold: 200_000, }), ]); @@ -34,6 +35,7 @@ describe("contextWindow", () => { expect(snapshot?.totalProcessedTokens).toBeNull(); expect(snapshot?.maxTokens).toBe(258_000); expect(snapshot?.compactsAutomatically).toBe(true); + expect(snapshot?.autoCompactThreshold).toBe(200_000); }); it("ignores malformed payloads", () => { diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f9..3ba24cb2c2b4 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -88,6 +88,7 @@ export function deriveLatestContextWindowSnapshot( toolUses: asFiniteNumber(payload?.toolUses), durationMs: asFiniteNumber(payload?.durationMs), compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false, + autoCompactThreshold: asFiniteNumber(payload?.autoCompactThreshold), updatedAt: activity.createdAt, }; } diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index f9699388b7db..17b171fa4825 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -34,6 +34,17 @@ When you set this field, T3 Code points Claude Code at that directory with the `CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and the rest of your environment stay as they are. +## Reduce Context Usage + +In Settings, open your Claude provider and set **Auto-compact after** to a token count between +`100000` and `1000000`. For example, `300000` keeps the full model context window available but +summarizes older messages once the conversation reaches about 300,000 tokens. Leave the field +empty to keep Claude Code's default behavior. + +When you return to an older Claude thread with a large context, T3 Code offers to compact the +conversation before you continue. You can also select **Compact context** from the context meter +or enter `/compact` in the message composer. + ## Where Claude Skills Are Loaded T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index a734c797b17d..e813c6ce4a35 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -324,6 +324,7 @@ export const ThreadTokenUsageSnapshot = Schema.Struct({ toolUses: Schema.optional(NonNegativeInt), durationMs: Schema.optional(NonNegativeInt), compactsAutomatically: Schema.optional(Schema.Boolean), + autoCompactThreshold: Schema.optional(PositiveInt), }); export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 55023bcc48e7..1db5a93f900b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -5,6 +5,7 @@ import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, ClientSettingsPatch, + ClaudeSettings, DEFAULT_SERVER_SETTINGS, defaultEnabledForDriver, resolveProviderInstanceEnabled, @@ -17,6 +18,27 @@ const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); + +describe("ClaudeSettings auto-compaction", () => { + it("uses Claude's default threshold when no override is configured", () => { + expect(decodeClaudeSettings({}).autoCompactWindow).toBe(""); + }); + + it.each(["100000", "300000", "1000000"])( + "accepts a supported auto-compaction threshold: %s", + (value) => { + expect(decodeClaudeSettings({ autoCompactWindow: value }).autoCompactWindow).toBe(value); + }, + ); + + it.each(["99999", "1000001", "300k", "invalid"])( + "rejects an unsupported auto-compaction threshold: %s", + (value) => { + expect(() => decodeClaudeSettings({ autoCompactWindow: value })).toThrow(); + }, + ); +}); describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 80e03b8c879e..3a9cffe735cc 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -410,9 +410,21 @@ export const ClaudeSettings = makeProviderSettingsSchema( }, }), ), + autoCompactWindow: TrimmedString.check(Schema.isPattern(/^(?:|[1-9]\d{5}|1000000)$/)).pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Auto-compact after", + description: + "Compact after 100,000 to 1,000,000 tokens. Leave empty to use Claude's default.", + providerSettingsForm: { + placeholder: "e.g. 300000", + clearWhenEmpty: "omit", + }, + }), + ), }, { - order: ["binaryPath", "homePath", "launchArgs"], + order: ["binaryPath", "homePath", "autoCompactWindow", "launchArgs"], }, ); export type ClaudeSettings = typeof ClaudeSettings.Type; @@ -797,6 +809,7 @@ const ClaudeSettingsPatch = Schema.Struct({ homePath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), launchArgs: Schema.optionalKey(TrimmedString), + autoCompactWindow: Schema.optionalKey(TrimmedString), }); const CursorSettingsPatch = Schema.Struct({ From 1b2c76c039dc06e0c70c31894b32ca43d0b903ee Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 24 Aug 2026 17:55:21 -0700 Subject: [PATCH 02/12] fix(web): preserve drafts and use the shared compaction banner --- apps/web/src/components/ChatView.tsx | 129 +++++++++++++++++- apps/web/src/components/chat/ChatComposer.tsx | 92 ++++--------- .../chat/ComposerBannerStack.test.tsx | 28 ++++ .../chat/ContextWindowMeter.logic.test.ts | 40 ++++++ .../chat/ContextWindowMeter.logic.ts | 19 +++ 5 files changed, 242 insertions(+), 66 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cb1cf698535a..e157e6a79f1e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -82,6 +82,7 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; @@ -179,6 +180,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + Minimize2Icon, PaperclipIcon, WifiOffIcon, } from "lucide-react"; @@ -303,6 +305,11 @@ import { threadChangeRequestSnapshotsAtom, } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { + hasDismissedResumeCompaction, + shouldOfferResumeCompaction, +} from "./chat/ContextWindowMeter.logic"; +import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, @@ -1364,6 +1371,18 @@ function ChatViewContent(props: ChatViewProps) { const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); + const composerHasUnsentContent = useComposerDraftStore((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + return Boolean( + draft && + (draft.prompt.trim().length > 0 || + draft.images.length > 0 || + draft.terminalContexts.length > 0 || + draft.elementContexts.length > 0 || + draft.previewAnnotations.length > 0 || + draft.reviewComments.length > 0), + ); + }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const setComposerDraftTerminalContexts = useComposerDraftStore( @@ -2296,6 +2315,10 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; + const activeContextWindow = useMemo( + () => deriveLatestContextWindowSnapshot(threadActivities), + [threadActivities], + ); const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the @@ -2768,6 +2791,25 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = + useLocalStorage( + `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, + false, + Schema.Boolean, + ); + const nativeResumeCompactionDismissed = useMemo( + () => hasDismissedResumeCompaction(threadActivities), + [threadActivities], + ); + useEffect(() => { + if (nativeResumeCompactionDismissed && !resumeCompactionPermanentlyDismissed) { + setResumeCompactionPermanentlyDismissed(true); + } + }, [ + nativeResumeCompactionDismissed, + resumeCompactionPermanentlyDismissed, + setResumeCompactionPermanentlyDismissed, + ]); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -4725,6 +4767,85 @@ function ChatViewContent(props: ChatViewProps) { isUnsnoozing, isUnsettling, ]); + const [dismissedResumeCompactionKey, setDismissedResumeCompactionKey] = useState( + null, + ); + const resumeCompactionKey = + activeThread && activeContextWindow + ? `${activeThread.id}:${activeContextWindow.updatedAt}` + : null; + const compactDisabled = + !activeThread || + !isServerThread || + selectedProvider !== "claudeAgent" || + phase === "running" || + isSendBusy || + isConnecting || + threadDetailLoading || + isPreparingWorktree || + activeEnvironmentUnavailable || + feedbackUploading || + pendingApprovals.length > 0 || + pendingUserInputs.length > 0 || + showPlanFollowUpPrompt || + composerHasUnsentContent; + const resumeCompactionBannerItem = useMemo(() => { + if ( + !activeThread || + !activeContextWindow || + resumeCompactionKey === null || + dismissedResumeCompactionKey === resumeCompactionKey || + resumeCompactionPermanentlyDismissed || + nativeResumeCompactionDismissed || + pendingUserInputs.length > 0 || + phase === "running" || + !shouldOfferResumeCompaction({ + provider: selectedProvider, + usedTokens: activeContextWindow.usedTokens, + updatedAt: activeContextWindow.updatedAt, + now: `${nowMinute}:00.000Z`, + }) + ) { + return null; + } + + const dismiss = () => setDismissedResumeCompactionKey(resumeCompactionKey); + return { + id: `resume-compaction:${resumeCompactionKey}`, + variant: "info", + icon: , + title: "Resume with less context", + description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from an older session`, + actions: ( + + ), + dismissLabel: "Keep full history", + onDismiss: dismiss, + }; + }, [ + activeContextWindow, + activeThread, + compactDisabled, + composerRef, + dismissedResumeCompactionKey, + nativeResumeCompactionDismissed, + nowMinute, + pendingUserInputs.length, + phase, + resumeCompactionKey, + resumeCompactionPermanentlyDismissed, + selectedProvider, + ]); const handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); @@ -4739,6 +4860,8 @@ function ChatViewContent(props: ChatViewProps) { const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item)); const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; + const resumeCompactionItems = + resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { @@ -4746,6 +4869,7 @@ function ChatViewContent(props: ChatViewProps) { ...urgentSystemItems, ...backgroundLivenessItems, ...calmSystemItems, + ...resumeCompactionItems, ...wokeThreadItems, ...parkedThreadItems, ]; @@ -4754,6 +4878,7 @@ function ChatViewContent(props: ChatViewProps) { ...urgentSystemItems, ...backgroundLivenessItems, ...calmSystemItems, + ...resumeCompactionItems, ...wokeThreadItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, @@ -4803,6 +4928,7 @@ function ChatViewContent(props: ChatViewProps) { isRestoringThreadBranch, localCheckoutBranchMismatch, parkedThreadBannerItem, + resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, wokeThreadBannerItem, @@ -6854,7 +6980,8 @@ function ChatViewContent(props: ChatViewProps) { activeProject?.defaultModelSelection } activeThreadModelSelection={activeThread?.modelSelection} - activeThreadActivities={activeThread?.activities} + activeContextWindow={activeContextWindow} + compactDisabled={compactDisabled} resolvedTheme={resolvedTheme} settings={settings} keybindings={keybindings} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2042737b69cc..2249717848d4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -122,12 +122,7 @@ import { renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; -import { - resolveContextWindowModelDisplayName, - shouldOfferResumeCompaction, -} from "./ContextWindowMeter.logic"; -import { formatContextWindowTokens } from "~/lib/contextWindow"; -import { useNowMinute } from "../../hooks/useNowMinute"; +import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; @@ -267,7 +262,7 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings"; import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; -import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; +import type { ContextWindowSnapshot } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, @@ -439,7 +434,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; - activeContextWindow: ReturnType; + activeContextWindow: ContextWindowSnapshot | null; activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { @@ -463,6 +458,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onInterrupt: () => void; onImplementPlanInNewThread: () => void; onCompactContext?: (() => void) | undefined; + compactDisabled: boolean; }) { return ( <> @@ -471,7 +467,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( usage={props.activeContextWindow} modelDisplayName={props.activeThreadModelDisplayName} onCompact={props.onCompactContext} - compactDisabled={props.isRunning || props.isSendBusy || props.isConnecting} + compactDisabled={props.compactDisabled} /> ) : null} {props.isPreparingWorktree ? ( @@ -511,6 +507,7 @@ export interface ChatComposerHandle { openModelPicker: () => void; toggleModelPicker: () => void; isModelPickerOpen: () => boolean; + compactContext: () => void; readSnapshot: () => { value: string; cursor: number; @@ -613,7 +610,8 @@ export interface ChatComposerProps { activeThreadModelSelection: ModelSelection | null | undefined; // Context window - activeThreadActivities: Thread["activities"] | undefined; + activeContextWindow: ContextWindowSnapshot | null; + compactDisabled: boolean; // Misc resolvedTheme: "light" | "dark"; @@ -705,7 +703,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerStatuses, activeProjectDefaultModelSelection, activeThreadModelSelection, - activeThreadActivities, + activeContextWindow, + compactDisabled, resolvedTheme, settings, keybindings, @@ -1008,33 +1007,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Context window // ------------------------------------------------------------------ - const activeContextWindow = useMemo( - () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), - [activeThreadActivities], - ); const activeThreadModelDisplayName = useMemo( () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), [activeThreadModelSelection, modelOptionsByInstance], ); - const nowMinute = useNowMinute(); - const resumeCompactionNoticeKey = - activeThreadId && activeContextWindow - ? `${activeThreadId}:${activeContextWindow.updatedAt}` - : null; - const [dismissedResumeCompactionNoticeKey, setDismissedResumeCompactionNoticeKey] = useState< - string | null - >(null); - const showResumeCompactionNotice = - phase !== "running" && - pendingUserInputs.length === 0 && - resumeCompactionNoticeKey !== null && - dismissedResumeCompactionNoticeKey !== resumeCompactionNoticeKey && - shouldOfferResumeCompaction({ - provider: selectedProvider, - usedTokens: activeContextWindow?.usedTokens, - updatedAt: activeContextWindow?.updatedAt, - now: `${nowMinute}:00.000Z`, - }); // ------------------------------------------------------------------ // Composer-local state @@ -2010,25 +1986,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ], ); const compactThreadContext = useCallback(() => { - if (phase === "running" || isSendBusy || isConnecting || !activeThreadId) { + if ( + compactDisabled || + composerSendState.hasSendableContent || + activePendingApproval !== null || + pendingUserInputs.length > 0 || + phase === "running" || + isSendBusy || + isConnecting || + !activeThreadId + ) { return; } - if (resumeCompactionNoticeKey) { - setDismissedResumeCompactionNoticeKey(resumeCompactionNoticeKey); - } promptRef.current = "/compact"; setComposerDraftPrompt(composerDraftTarget, "/compact"); onSend(); }, [ + activePendingApproval, activeThreadId, + compactDisabled, composerDraftTarget, + composerSendState.hasSendableContent, isConnecting, isSendBusy, onSend, + pendingUserInputs.length, phase, promptRef, - resumeCompactionNoticeKey, setComposerDraftPrompt, ]); const expandMobileComposer = useCallback(() => { @@ -2803,6 +2788,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toggleModelPicker: () => { setIsComposerModelPickerOpen((open) => !open); }, + compactContext: compactThreadContext, isModelPickerOpen: () => isComposerModelPickerOpen, readSnapshot: () => { return readComposerSnapshot(); @@ -2914,6 +2900,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedPromptEffort, selectedProvider, selectedProviderModels, + compactThreadContext, ], ); @@ -2948,32 +2935,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) className={cn("mx-auto w-full min-w-0 max-w-3xl", hasShoulderTab && "pt-7")} data-chat-composer-form="true" > - {showResumeCompactionNotice && activeContextWindow ? ( -
-
-
Resume with less context
-
- {formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from an older - session -
-
-
- - -
-
- ) : null} {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (
{ expect(markup).toContain("branch-surface"); expect(markup).toContain("branch-actions"); }); + + it("renders a disabled compaction action on the shared accessible banner surface", () => { + const markup = renderToStaticMarkup( +
) : null} {onCompact ? ( - + <> + + {compactDisabled && compactDisabledReason ? ( +
+ {compactDisabledReason} +
+ ) : null} + ) : null} From c3e795a666dc29fe25cbd51aa8d51cddcf41983a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 24 Aug 2026 22:08:29 -0700 Subject: [PATCH 08/12] fix(claude): preserve compact commands and continuation groups --- .../src/provider/Layers/ClaudeAdapter.test.ts | 33 +++++++++ apps/web/src/components/ChatView.tsx | 36 +++++---- .../chat/ContextWindowMeter.logic.test.ts | 73 ++++++++++++++++++- .../chat/ContextWindowMeter.logic.ts | 28 +++++++ packages/shared/src/model.test.ts | 16 ++++ packages/shared/src/model.ts | 2 +- 6 files changed, 173 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d87c7a68e01a..bb8ff4deb6bd 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -749,6 +749,39 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps compact commands intact when ultrathink is selected", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const modelSelection = createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "ultrathink" }], + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/compact", + attachments: [], + modelSelection, + }); + + const promptText = yield* Effect.promise(() => + readFirstPromptText(harness.getLastCreateQueryInput()), + ); + assert.equal(promptText, "/compact"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("embeds image attachments in Claude user messages", () => { const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-")); const harness = makeHarness({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5f8249c56b57..b2ef131fbc70 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -202,7 +202,6 @@ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, - resolveSelectableProviderInstanceEntry, } from "../providerInstances"; import { useClientSettings, @@ -312,6 +311,7 @@ import { } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { + hasAvailableClaudeCompactionProvider, hasDismissedResumeCompaction, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; @@ -2779,19 +2779,29 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; - const compactionProviderAvailable = useMemo(() => { - const claudeProviders = applyProviderInstanceSettings( - deriveProviderInstanceEntries(providerStatuses), + const compactionProviderAvailable = useMemo( + () => + hasAvailableClaudeCompactionProvider({ + providers: applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + ), + instanceId: activeProviderInstanceId, + lockedInstanceId: lockedProvider + ? (activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + null) + : null, + }), + [ + activeProviderInstanceId, + activeThread?.modelSelection.instanceId, + activeThread?.session?.providerInstanceId, + lockedProvider, + providerStatuses, settings, - ).filter((provider) => provider.driverKind === "claudeAgent"); - - return ( - resolveSelectableProviderInstanceEntry( - claudeProviders, - activeProviderInstanceId ?? undefined, - ) !== undefined - ); - }, [activeProviderInstanceId, providerStatuses, settings]); + ], + ); const activeProviderStatus = useMemo(() => { if (activeProviderInstanceId) { return ( diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts index d84c7e4e3d0e..032076c0b74b 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts @@ -1,12 +1,83 @@ -import { ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import { deriveProviderInstanceEntries } from "../../providerInstances"; import { formatContextWindowCompactionMessage, + hasAvailableClaudeCompactionProvider, hasDismissedResumeCompaction, resolveContextWindowModelDisplayName, shouldOfferResumeCompaction, } from "./ContextWindowMeter.logic"; +function claudeProvider(input: { + instanceId: string; + continuationGroupKey: string; + enabled?: boolean; +}): ServerProvider { + return { + instanceId: ProviderInstanceId.make(input.instanceId), + driver: ProviderDriverKind.make("claudeAgent"), + continuation: { groupKey: input.continuationGroupKey }, + enabled: input.enabled ?? true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-24T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; +} + +describe("hasAvailableClaudeCompactionProvider", () => { + const originalInstanceId = ProviderInstanceId.make("claude_original"); + + it("rejects a fallback in a different locked continuation group", () => { + const providers = deriveProviderInstanceEntries([ + claudeProvider({ + instanceId: originalInstanceId, + continuationGroupKey: "claude:home:/original", + enabled: false, + }), + claudeProvider({ + instanceId: "claude_other", + continuationGroupKey: "claude:home:/other", + }), + ]); + + expect( + hasAvailableClaudeCompactionProvider({ + providers, + instanceId: originalInstanceId, + lockedInstanceId: originalInstanceId, + }), + ).toBe(false); + }); + + it("accepts an enabled fallback in the locked continuation group", () => { + const providers = deriveProviderInstanceEntries([ + claudeProvider({ + instanceId: originalInstanceId, + continuationGroupKey: "claude:home:/original", + enabled: false, + }), + claudeProvider({ + instanceId: "claude_fallback", + continuationGroupKey: "claude:home:/original", + }), + ]); + + expect( + hasAvailableClaudeCompactionProvider({ + providers, + instanceId: originalInstanceId, + lockedInstanceId: originalInstanceId, + }), + ).toBe(true); + }); +}); + describe("resolveContextWindowModelDisplayName", () => { it("uses the selected model from the exact provider instance", () => { const primaryInstanceId = ProviderInstanceId.make("codex"); diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index 9793c7240153..149a45b564de 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -1,4 +1,8 @@ import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import { + resolveSelectableProviderInstanceEntry, + type ProviderInstanceEntry, +} from "../../providerInstances"; import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; export const CLAUDE_RESUME_COMPACTION_MINUTES = 70; @@ -7,6 +11,30 @@ export const CLAUDE_RESUME_COMPACTION_NEVER_ANSWER = "Don't ask again"; const CLAUDE_RESUME_COMPACTION_QUESTION_PATTERN = /^This session is (?:\d+h \d+m|\d+m) old and uses \d{1,3}(?:,\d{3})* tokens\. Compact it before continuing\?$/u; +export function hasAvailableClaudeCompactionProvider(input: { + readonly providers: ReadonlyArray; + readonly instanceId: ProviderInstanceId | null; + readonly lockedInstanceId: ProviderInstanceId | null; +}): boolean { + const claudeProviders = input.providers.filter( + (provider) => provider.driverKind === "claudeAgent", + ); + const lockedContinuationGroupKey = input.lockedInstanceId + ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId) + ?.continuationGroupKey + : undefined; + const compatibleProviders = lockedContinuationGroupKey + ? claudeProviders.filter( + (provider) => provider.continuationGroupKey === lockedContinuationGroupKey, + ) + : claudeProviders; + + return ( + resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !== + undefined + ); +} + export function hasDismissedResumeCompaction( activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, ): boolean { diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index b67c45744734..ea38f440196e 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; import { + applyClaudePromptEffortPrefix, buildProviderOptionSelectionsFromDescriptors, createModelCapabilities, createModelSelection, @@ -155,3 +156,18 @@ describe("model slug normalization", () => { expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); }); }); + +describe("applyClaudePromptEffortPrefix", () => { + it("keeps compact commands intact when ultrathink is selected", () => { + expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact"); + expect(applyClaudePromptEffortPrefix(" /compact keep recent errors ", "ultrathink")).toBe( + "/compact keep recent errors", + ); + }); + + it("still adds the ultrathink prefix to ordinary prompts", () => { + expect(applyClaudePromptEffortPrefix("Investigate this failure", "ultrathink")).toBe( + "Ultrathink:\nInvestigate this failure", + ); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index bdc0c0cc8efb..470c848199b4 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -362,7 +362,7 @@ export function applyClaudePromptEffortPrefix( if (!trimmed) { return trimmed; } - if (effort !== "ultrathink") { + if (effort !== "ultrathink" || /^\/compact(?:\s|$)/u.test(trimmed)) { return trimmed; } if (trimmed.startsWith("Ultrathink:")) { From baa36c030906e35d3c4da719654d727c18e30a8e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 24 Aug 2026 23:18:59 -0700 Subject: [PATCH 09/12] fix(claude): preserve all slash commands with ultrathink --- packages/shared/src/model.test.ts | 5 ++++- packages/shared/src/model.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index ea38f440196e..cb7aeb19bb83 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -158,11 +158,14 @@ describe("model slug normalization", () => { }); describe("applyClaudePromptEffortPrefix", () => { - it("keeps compact commands intact when ultrathink is selected", () => { + it("keeps slash commands intact when ultrathink is selected", () => { expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact"); expect(applyClaudePromptEffortPrefix(" /compact keep recent errors ", "ultrathink")).toBe( "/compact keep recent errors", ); + expect(applyClaudePromptEffortPrefix(" /review src/model.ts ", "ultrathink")).toBe( + "/review src/model.ts", + ); }); it("still adds the ultrathink prefix to ordinary prompts", () => { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 470c848199b4..b7ba3b3183d2 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -362,7 +362,7 @@ export function applyClaudePromptEffortPrefix( if (!trimmed) { return trimmed; } - if (effort !== "ultrathink" || /^\/compact(?:\s|$)/u.test(trimmed)) { + if (effort !== "ultrathink" || trimmed.startsWith("/")) { return trimmed; } if (trimmed.startsWith("Ultrathink:")) { From d8370862ead5851e14649eed0ffd2e0f03a15682 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 25 Aug 2026 00:14:58 -0700 Subject: [PATCH 10/12] fix(claude): share resume compaction copy and harden compact entry points The web client detects the resume dialog's "Don't ask again" answer by matching the question text, but the copy lived only in the adapter, so a reword would silently break the detection. The question template and the never-answer label now live in @t3tools/shared/claudeCompaction with a test that formats and matches the same string. Also: exempt every slash command from the ultrathink prefix instead of only /compact, settle resume dialogs whose abort landed before the listener was registered, toast instead of silently ignoring a compact while a pasted image compresses, clear a stranded /compact draft when a dispatch is blocked, track banner dismissals per thread instead of in one slot, and scope the compaction docs to the clients that have each control. Co-Authored-By: Claude Fable 5 --- .../src/provider/Layers/ClaudeAdapter.ts | 33 ++++++++++++----- apps/web/src/components/ChatView.tsx | 16 +++++--- apps/web/src/components/chat/ChatComposer.tsx | 22 ++++++++++- .../chat/ContextWindowMeter.logic.ts | 9 +++-- docs/user/providers-claude.md | 7 ++-- packages/shared/package.json | 4 ++ packages/shared/src/claudeCompaction.test.ts | 37 +++++++++++++++++++ packages/shared/src/claudeCompaction.ts | 26 +++++++++++++ packages/shared/src/model.test.ts | 9 +++++ packages/shared/src/model.ts | 6 ++- 10 files changed, 144 insertions(+), 25 deletions(-) create mode 100644 packages/shared/src/claudeCompaction.test.ts create mode 100644 packages/shared/src/claudeCompaction.ts diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f62c8146d9a7..173b7a2b0355 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -57,6 +57,10 @@ import { getProviderOptionDescriptors, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; +import { + CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + formatClaudeResumeCompactionQuestion, +} from "@t3tools/shared/claudeCompaction"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -3959,6 +3963,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); + // The signal may have aborted during the awaited event emissions + // above, before the listener existed; settle now so the dialog + // cannot hang with a lingering pending question. + if (callbackOptions.signal.aborted) { + yield* settleAsAborted; + } // Block until the user provides answers. const answers = yield* Deferred.await(answersDeferred); @@ -4020,13 +4030,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return { behavior: "cancelled" as const }; } - const estimatedTokens = finiteNonNegativeInteger(request.payload.estimatedTokens) ?? 0; - const ageMinutes = finiteNonNegativeInteger(request.payload.sessionAgeMinutes) ?? 0; - const ageLabel = - ageMinutes >= 60 - ? `${Math.floor(ageMinutes / 60)}h ${ageMinutes % 60}m` - : `${ageMinutes}m`; - const question = `This session is ${ageLabel} old and uses ${estimatedTokens.toLocaleString("en-US")} tokens. Compact it before continuing?`; + // The question copy lives in @t3tools/shared/claudeCompaction because + // the web client recognizes this exact text (and the "never" answer) + // to mirror a permanent dismissal. + const question = formatClaudeResumeCompactionQuestion({ + ageMinutes: finiteNonNegativeInteger(request.payload.sessionAgeMinutes) ?? 0, + estimatedTokens: finiteNonNegativeInteger(request.payload.estimatedTokens) ?? 0, + }); const result = yield* handleAskUserQuestion( context, { @@ -4044,7 +4054,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( description: "Resume without changing the conversation.", }, { - label: "Don't ask again", + label: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, description: "Keep full history and skip future resume prompts.", }, ], @@ -4070,7 +4080,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const action = selection === "Compact and continue" ? "compact" - : selection === "Don't ask again" + : selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER ? "never" : "continue"; @@ -4182,6 +4192,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); + // Same late-listener race as handleAskUserQuestion: the signal may + // have aborted while the request event emissions were awaited. + if (callbackOptions.signal.aborted) { + onAbort(); + } const decision = yield* Deferred.await(decisionDeferred); pendingApprovals.delete(requestId); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b2ef131fbc70..286847fbfcd0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4775,9 +4775,12 @@ function ChatViewContent(props: ChatViewProps) { isUnsnoozing, isUnsettling, ]); - const [dismissedResumeCompactionKey, setDismissedResumeCompactionKey] = useState( - null, - ); + // Session-scoped dismissals, one key per (thread, snapshot). A set rather + // than a single slot so dismissing the banner on one thread does not + // resurface it on another thread dismissed earlier. + const [dismissedResumeCompactionKeys, setDismissedResumeCompactionKeys] = useState< + ReadonlySet + >(new Set()); const resumeCompactionKey = activeThread && activeContextWindow ? `${activeThread.id}:${activeContextWindow.updatedAt}` @@ -4811,7 +4814,7 @@ function ChatViewContent(props: ChatViewProps) { !activeThread || !activeContextWindow || resumeCompactionKey === null || - dismissedResumeCompactionKey === resumeCompactionKey || + dismissedResumeCompactionKeys.has(resumeCompactionKey) || resumeCompactionPermanentlyDismissed || nativeResumeCompactionDismissed || pendingUserInputs.length > 0 || @@ -4826,7 +4829,8 @@ function ChatViewContent(props: ChatViewProps) { return null; } - const dismiss = () => setDismissedResumeCompactionKey(resumeCompactionKey); + const dismiss = () => + setDismissedResumeCompactionKeys((keys) => new Set(keys).add(resumeCompactionKey)); const compactAction = (