History

feat(claude): compact old threads before they burn through usage (#8144)

Theo Browne committed 2 months ago

Commit c7222ca1 parent 1baf99125 files · +1105 −32Browse files

Unified · Split

25 files
  1. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +175 −0
  2. apps/server/src/provider/Layers/ClaudeAdapter.ts +100 −0
  3. apps/server/src/provider/Layers/ClaudeProvider.ts +7 −1
  4. apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +1 −0
  5. apps/server/src/provider/Layers/ProviderRegistry.test.ts +8 −0
  6. apps/server/src/serverSettings.test.ts +2 −0
  7. apps/web/src/components/ChatView.tsx +173 −15
  8. apps/web/src/components/chat/ChatComposer.tsx +80 −10
  9. apps/web/src/components/chat/ComposerBannerStack.test.tsx +28 −0
  10. apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +179 −1
  11. apps/web/src/components/chat/ContextWindowMeter.logic.ts +79 −0
  12. apps/web/src/components/chat/ContextWindowMeter.test.tsx +62 −0
  13. apps/web/src/components/chat/ContextWindowMeter.tsx +26 −3
  14. apps/web/src/components/settings/ProviderSettingsForm.test.ts +12 −0
  15. apps/web/src/lib/contextWindow.test.ts +2 −0
  16. apps/web/src/lib/contextWindow.ts +1 −0
  17. docs/user/providers-claude.md +12 −0
  18. packages/contracts/src/providerRuntime.ts +1 −0
  19. packages/contracts/src/settings.test.ts +31 −0
  20. packages/contracts/src/settings.ts +25 −1
  21. packages/shared/package.json +4 −0
  22. packages/shared/src/claudeCompaction.test.ts +35 −0
  23. packages/shared/src/claudeCompaction.ts +26 −0
  24. packages/shared/src/model.test.ts +31 −0
  25. packages/shared/src/model.ts +5 −1
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+175−0
@@ -413,6 +413,25 @@ describe("ClaudeAdapterLive", () => { · 412 unmodified lines
413413 );
414414 });
415415
416+ it.effect("passes the configured auto-compaction window to Claude", () => {
417+ const harness = makeHarness({ claudeConfig: { autoCompactWindow: "300000" } });
418+ return Effect.gen(function* () {
419+ const adapter = yield* ClaudeAdapter;
420+ yield* adapter.startSession({
421+ threadId: THREAD_ID,
422+ provider: ProviderDriverKind.make("claudeAgent"),
423+ runtimeMode: "full-access",
424+ });
425+
426+ const options = harness.getLastCreateQueryInput()?.options;
427+ assert.deepEqual(options?.settings, { autoCompactWindow: 300000 });
428+ assert.deepEqual(options?.supportedDialogKinds, ["resume_return"]);
429+ }).pipe(
430+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
431+ Effect.provide(harness.layer),
432+ );
433+ });
434+
416435 it.effect("forwards claude effort levels into query options", () => {
417436 const harness = makeHarness();
418437 return Effect.gen(function* () {
@@ -730,6 +749,39 @@ describe("ClaudeAdapterLive", () => { · 311 unmodified lines
730749 );
731750 });
732751
752+ it.effect("keeps compact commands intact when ultrathink is selected", () => {
753+ const harness = makeHarness();
754+ return Effect.gen(function* () {
755+ const adapter = yield* ClaudeAdapter;
756+ const modelSelection = createModelSelection(
757+ ProviderInstanceId.make("claudeAgent"),
758+ "claude-sonnet-4-6",
759+ [{ id: "effort", value: "ultrathink" }],
760+ );
761+ const session = yield* adapter.startSession({
762+ threadId: THREAD_ID,
763+ provider: ProviderDriverKind.make("claudeAgent"),
764+ modelSelection,
765+ runtimeMode: "full-access",
766+ });
767+
768+ yield* adapter.sendTurn({
769+ threadId: session.threadId,
770+ input: "/compact",
771+ attachments: [],
772+ modelSelection,
773+ });
774+
775+ const promptText = yield* Effect.promise(() =>
776+ readFirstPromptText(harness.getLastCreateQueryInput()),
777+ );
778+ assert.equal(promptText, "/compact");
779+ }).pipe(
780+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
781+ Effect.provide(harness.layer),
782+ );
783+ });
784+
733785 it.effect("embeds image attachments in Claude user messages", () => {
734786 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-"));
735787 const harness = makeHarness({
@@ -4400,6 +4452,62 @@ describe("ClaudeAdapterLive", () => { · 3664 unmodified lines
44004452 );
44014453 });
44024454
4455+ it.effect("routes Claude resume compaction through the shared user-input UI", () => {
4456+ const harness = makeHarness();
4457+ return Effect.gen(function* () {
4458+ const adapter = yield* ClaudeAdapter;
4459+ const session = yield* adapter.startSession({
4460+ threadId: RESUME_THREAD_ID,
4461+ provider: ProviderDriverKind.make("claudeAgent"),
4462+ resumeCursor: { resume: "550e8400-e29b-41d4-a716-446655440000" },
4463+ runtimeMode: "full-access",
4464+ });
4465+ yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
4466+
4467+ const onUserDialog = harness.getLastCreateQueryInput()?.options.onUserDialog;
4468+ assert.equal(typeof onUserDialog, "function");
4469+ if (!onUserDialog) return;
4470+
4471+ const dialogPromise = onUserDialog(
4472+ {
4473+ dialogKind: "resume_return",
4474+ payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 },
4475+ },
4476+ { signal: new AbortController().signal },
4477+ );
4478+
4479+ const requested = yield* Stream.runHead(adapter.streamEvents);
4480+ assert.equal(requested._tag, "Some");
4481+ if (requested._tag !== "Some" || requested.value.type !== "user-input.requested") return;
4482+ const question = requested.value.payload.questions[0];
4483+ assert.equal(question?.header, "Resume session");
4484+ assert.match(question?.question ?? "", /2h 25m/);
4485+ assert.match(question?.question ?? "", /275,123 tokens/);
4486+ assert.deepEqual(
4487+ question?.options.map((option) => option.label),
4488+ ["Compact and continue", "Keep full history", "Don't ask again"],
4489+ );
4490+ if (!question || !requested.value.requestId) return;
4491+
4492+ yield* adapter.respondToUserInput(
4493+ session.threadId,
4494+ ApprovalRequestId.make(requested.value.requestId),
4495+ { [question.id]: "Compact and continue" },
4496+ );
4497+
4498+ const resolved = yield* Stream.runHead(adapter.streamEvents);
4499+ assert.equal(resolved._tag, "Some");
4500+ if (resolved._tag === "Some") assert.equal(resolved.value.type, "user-input.resolved");
4501+ assert.deepEqual(yield* Effect.promise(() => dialogPromise), {
4502+ behavior: "completed",
4503+ result: "compact",
4504+ });
4505+ }).pipe(
4506+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
4507+ Effect.provide(harness.layer),
4508+ );
4509+ });
4510+
44034511 it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => {
44044512 const harness = makeHarness();
44054513 return Effect.gen(function* () {
@@ -4689,6 +4797,73 @@ describe("ClaudeAdapterLive", () => { · 283 unmodified lines
46894797 );
46904798 });
46914799
4800+ it.effect("denies AskUserQuestion when the signal aborted before the listener registered", () => {
4801+ const harness = makeHarness();
4802+ return Effect.gen(function* () {
4803+ const adapter = yield* ClaudeAdapter;
4804+
4805+ yield* adapter.startSession({
4806+ threadId: THREAD_ID,
4807+ provider: ProviderDriverKind.make("claudeAgent"),
4808+ runtimeMode: "approval-required",
4809+ });
4810+
4811+ yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
4812+
4813+ const canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool;
4814+ assert.equal(typeof canUseTool, "function");
4815+ if (!canUseTool) {
4816+ return;
4817+ }
4818+
4819+ const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 2).pipe(
4820+ Stream.runCollect,
4821+ Effect.forkChild,
4822+ );
4823+
4824+ // Abort before the call so the adapter's listener registration can
4825+ // never observe the abort event, only the recheck can.
4826+ const controller = new AbortController();
4827+ controller.abort();
4828+ const permissionPromise = canUseTool(
4829+ "AskUserQuestion",
4830+ {
4831+ questions: [
4832+ {
4833+ question: "Continue?",
4834+ header: "Continue",
4835+ options: [{ label: "Yes", description: "Proceed" }],
4836+ multiSelect: false,
4837+ },
4838+ ],
4839+ },
4840+ {
4841+ signal: controller.signal,
4842+ toolUseID: "tool-ask-pre-aborted",
4843+ },
4844+ );
4845+
4846+ const permissionResult = yield* Effect.promise(() => permissionPromise);
4847+ assert.deepEqual(permissionResult, {
4848+ behavior: "deny",
4849+ message: "User cancelled tool execution.",
4850+ } satisfies PermissionResult);
4851+
4852+ const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
4853+ assert.deepEqual(
4854+ runtimeEvents.map((event) => event.type),
4855+ ["user-input.requested", "user-input.resolved"],
4856+ );
4857+ const resolvedEvent = runtimeEvents[1];
4858+ if (resolvedEvent?.type === "user-input.resolved") {
4859+ assert.deepEqual(resolvedEvent.payload.answers, {});
4860+ }
4861+ }).pipe(
4862+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
4863+ Effect.provide(harness.layer),
4864+ );
4865+ });
4866+
46924867 it.effect("stopping a session settles pending user-input waits", () => {
46934868 const harness = makeHarness();
46944869 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+100−0
@@ -57,6 +57,10 @@ import { · 56 unmodified lines
5757 getProviderOptionDescriptors,
5858 resolvePromptInjectedEffort,
5959 } from "@t3tools/shared/model";
60+import {
61+ CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
62+ formatClaudeResumeCompactionQuestion,
63+} from "@t3tools/shared/claudeCompaction";
6064 import * as Cause from "effect/Cause";
6165 import * as Crypto from "effect/Crypto";
6266 import * as DateTime from "effect/DateTime";
@@ -543,6 +547,7 @@ function makeClaudeTokenUsageSnapshot(input: { · 480 unmodified lines
543547 readonly totalProcessedTokens?: number;
544548 readonly lastUsedTokens?: number;
545549 readonly compactsAutomatically?: boolean;
550+ readonly autoCompactThreshold?: number;
546551 }): ThreadTokenUsageSnapshot | undefined {
547552 const activeTokens = finiteNonNegativeInteger(input.activeTokens);
548553 if (activeTokens === undefined || activeTokens <= 0) {
@@ -570,6 +575,9 @@ function makeClaudeTokenUsageSnapshot(input: { · 21 unmodified lines
570575 ...(input.compactsAutomatically !== undefined
571576 ? { compactsAutomatically: input.compactsAutomatically }
572577 : {}),
578+ ...(input.autoCompactThreshold !== undefined
579+ ? { autoCompactThreshold: input.autoCompactThreshold }
580+ : {}),
573581 };
574582 }
575583
@@ -604,11 +612,13 @@ function normalizeClaudeContextUsageApiSnapshot( · 28 unmodified lines
604612 value: SDKControlGetContextUsageResponse,
605613 totalProcessedTokens?: number,
606614 ): ThreadTokenUsageSnapshot | undefined {
615+ const autoCompactThreshold = finitePositiveInteger(value.autoCompactThreshold);
607616 return makeClaudeTokenUsageSnapshot({
608617 activeTokens: value.totalTokens,
609618 contextWindow: value.maxTokens,
610619 ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}),
611620 compactsAutomatically: value.isAutoCompactEnabled,
621+ ...(autoCompactThreshold !== undefined ? { autoCompactThreshold } : {}),
612622 });
613623 }
614624
@@ -3953,6 +3963,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 3338 unmodified lines
39533963 callbackOptions.signal.addEventListener("abort", onAbort, {
39543964 once: true,
39553965 });
3966+ // The signal may have aborted during the awaited event emissions
3967+ // above, before the listener existed; settle now so the dialog
3968+ // cannot hang with a lingering pending question.
3969+ if (callbackOptions.signal.aborted) {
3970+ yield* settleAsAborted;
3971+ }
39563972
39573973 // Block until the user provides answers.
39583974 const answers = yield* Deferred.await(answersDeferred);
@@ -4001,6 +4017,76 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 42 unmodified lines
40014017 } satisfies PermissionResult;
40024018 });
40034019
4020+ const handleResumeDialog = Effect.fn("handleResumeDialog")(function* (
4021+ request: Parameters<NonNullable<ClaudeQueryOptions["onUserDialog"]>>[0],
4022+ callbackOptions: Parameters<NonNullable<ClaudeQueryOptions["onUserDialog"]>>[1],
4023+ ) {
4024+ if (request.dialogKind !== "resume_return") {
4025+ return { behavior: "cancelled" as const };
4026+ }
4027+
4028+ const context = yield* Ref.get(contextRef);
4029+ if (!context) {
4030+ return { behavior: "cancelled" as const };
4031+ }
4032+
4033+ // The question copy lives in @t3tools/shared/claudeCompaction because
4034+ // the web client recognizes this exact text (and the "never" answer)
4035+ // to mirror a permanent dismissal.
4036+ const question = formatClaudeResumeCompactionQuestion({
4037+ ageMinutes: finiteNonNegativeInteger(request.payload.sessionAgeMinutes) ?? 0,
4038+ estimatedTokens: finiteNonNegativeInteger(request.payload.estimatedTokens) ?? 0,
4039+ });
4040+ const result = yield* handleAskUserQuestion(
4041+ context,
4042+ {
4043+ questions: [
4044+ {
4045+ header: "Resume session",
4046+ question,
4047+ options: [
4048+ {
4049+ label: "Compact and continue",
4050+ description: "Resume with a summary and use fewer tokens.",
4051+ },
4052+ {
4053+ label: "Keep full history",
4054+ description: "Resume without changing the conversation.",
4055+ },
4056+ {
4057+ label: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
4058+ description: "Keep full history and skip future resume prompts.",
4059+ },
4060+ ],
4061+ multiSelect: false,
4062+ },
4063+ ],
4064+ },
4065+ {
4066+ signal: callbackOptions.signal,
4067+ ...(request.toolUseID ? { toolUseID: request.toolUseID } : {}),
4068+ },
4069+ );
4070+
4071+ if (result.behavior !== "allow") {
4072+ return { behavior: "cancelled" as const };
4073+ }
4074+
4075+ const answers = result.updatedInput.answers;
4076+ const selection =
4077+ answers && typeof answers === "object" && !Array.isArray(answers)
4078+ ? (answers as Record<string, unknown>)[question]
4079+ : undefined;
4080+ const action =
4081+ selection === "Compact and continue"
4082+ ? "compact"
4083+ : selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER
4084+ ? "never"
4085+ : "continue";
4086+
4087+ return { behavior: "completed" as const, result: action };
4088+ });
4089+
40044090 const canUseToolEffect = Effect.fn("canUseTool")(function* (
40054091 toolName: Parameters<CanUseTool>[0],
40064092 toolInput: Parameters<CanUseTool>[1],
@@ -4106,6 +4192,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 99 unmodified lines
41064192 callbackOptions.signal.addEventListener("abort", onAbort, {
41074193 once: true,
41084194 });
4195+ // Same late-listener race as handleAskUserQuestion: the signal may
4196+ // have aborted while the request event emissions were awaited.
4197+ if (callbackOptions.signal.aborted) {
4198+ onAbort();
4199+ }
41094200
41104201 const decision = yield* Deferred.await(decisionDeferred);
41114202 pendingApprovals.delete(requestId);
@@ -4161,6 +4252,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 49 unmodified lines
41614252
41624253 const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
41634254 runPromise(canUseToolEffect(toolName, toolInput, callbackOptions));
4255+ const onUserDialog: NonNullable<ClaudeQueryOptions["onUserDialog"]> = (
4256+ request,
4257+ callbackOptions,
4258+ ) => runPromise(handleResumeDialog(request, callbackOptions));
41644259
41654260 const claudeBinaryPath = claudeSdkExecutablePath;
41664261 const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
@@ -4196,6 +4291,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 29 unmodified lines
41964291 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
41974292 ...(fastMode ? { fastMode: true } : {}),
41984293 ...(ultracode ? { ultracode: true } : {}),
4294+ ...(claudeSettings.autoCompactWindow
4295+ ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) }
4296+ : {}),
41994297 };
42004298 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
42014299 // The attachments dir grant lets the agent Read/copy pasted images at
@@ -4228,6 +4326,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 26 unmodified lines
42284326 ...(newSessionId ? { sessionId: newSessionId } : {}),
42294327 includePartialMessages: true,
42304328 canUseTool,
4329+ onUserDialog,
4330+ supportedDialogKinds: ["resume_return"],
42314331 env: claudeEnvironment,
42324332 additionalDirectories,
42334333 ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
apps/server/src/provider/Layers/ClaudeProvider.ts+7−1
@@ -927,7 +927,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( · 926 unmodified lines
927927 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
928928 : undefined;
929929 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);
930− const slashCommands = capabilities?.slashCommands ?? [];
930+ const slashCommands = [
931+ {
932+ name: "compact",
933+ description: "Summarize the conversation and reduce context usage",
934+ },
935+ ...(capabilities?.slashCommands ?? []),
936+ ];
931937 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
932938
933939 if (!capabilities) {
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+1−0
@@ -106,6 +106,7 @@ const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings => · 105 unmodified lines
106106 homePath: "",
107107 customModels: [],
108108 launchArgs: "",
109+ autoCompactWindow: "",
109110 ...overrides,
110111 });
111112
apps/server/src/provider/Layers/ProviderRegistry.test.ts+8−0
@@ -2151,6 +2151,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 2150 unmodified lines
21512151 );
21522152
21532153 assert.deepStrictEqual(status.slashCommands, [
2154+ {
2155+ name: "compact",
2156+ description: "Summarize the conversation and reduce context usage",
2157+ },
21542158 {
21552159 name: "review",
21562160 description: "Review a pull request",
@@ -2194,6 +2198,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 37 unmodified lines
21942198 );
21952199
21962200 assert.deepStrictEqual(status.slashCommands, [
2201+ {
2202+ name: "compact",
2203+ description: "Summarize the conversation and reduce context usage",
2204+ },
21972205 {
21982206 name: "ui",
21992207 description: "Explore and refine UI",
apps/server/src/serverSettings.test.ts+2−0
@@ -190,6 +190,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 189 unmodified lines
190190 homePath: "",
191191 customModels: ["claude-custom"],
192192 launchArgs: "",
193+ autoCompactWindow: "",
193194 });
194195 assert.deepEqual(
195196 next.textGenerationModelSelection,
@@ -581,6 +582,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 385 unmodified lines
581582 homePath: "",
582583 customModels: [],
583584 launchArgs: "",
585+ autoCompactWindow: "",
584586 });
585587 assert.deepEqual(next.providers.opencode, {
586588 // OpenCode is disabled by default; this update only touches paths.
apps/web/src/components/ChatView.tsx+173−15
@@ -82,6 +82,7 @@ import { · 81 unmodified lines
8282 type AtomCommandResult,
8383 } from "@t3tools/client-runtime/state/runtime";
8484 import * as Cause from "effect/Cause";
85+import * as Schema from "effect/Schema";
8586 import { AsyncResult } from "effect/unstable/reactivity";
8687 import { isElectron } from "../env";
8788 import { readLocalApi } from "../localApi";
@@ -179,6 +180,7 @@ import { · 91 unmodified lines
179180 CheckCircle2Icon,
180181 ChevronDownIcon,
181182 GitBranchIcon,
183+ Minimize2Icon,
182184 PaperclipIcon,
183185 WifiOffIcon,
184186 } from "lucide-react";
@@ -196,7 +198,11 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; · 11 unmodified lines
196198 import { useBrowserHistoryStore } from "~/browserHistoryStore";
197199 import { registerFaviconProjectForThread } from "~/browserFaviconStore";
198200 import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
199−import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
201+import {
202+ applyProviderInstanceSettings,
203+ deriveProviderInstanceEntries,
204+ NO_PROVIDER_MODEL_SELECTION,
205+} from "../providerInstances";
200206 import {
201207 useClientSettings,
202208 useClientSettingsHydrated,
@@ -223,6 +229,7 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRo · 20 unmodified lines
223229 import {
224230 beginBackgroundDraftSubmissionByRef,
225231 clearBackgroundDraftSubmissionByRef,
232+ composerDraftHasUserContent,
226233 type ComposerImageAttachment,
227234 type DraftThreadEnvMode,
228235 finalizePromotedDraftThreadByRef,
@@ -305,6 +312,12 @@ import { · 76 unmodified lines
305312 useLinkedThreadPullRequest,
306313 } from "./ThreadStatusIndicators";
307314 import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
315+import {
316+ hasAvailableClaudeCompactionProvider,
317+ hasDismissedResumeCompaction,
318+ shouldOfferResumeCompaction,
319+} from "./chat/ContextWindowMeter.logic";
320+import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";
308321 import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
309322 import {
310323 DRAFT_HERO_TRANSITION_ANIMATION_ID,
@@ -1367,6 +1380,9 @@ function ChatViewContent(props: ChatViewProps) { · 1056 unmodified lines
13671380 const composerActiveProvider = useComposerDraftStore(
13681381 (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,
13691382 );
1383+ const composerHasUnsentContent = useComposerDraftStore((store) =>
1384+ composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)),
1385+ );
13701386 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
13711387 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
13721388 const setComposerDraftTerminalContexts = useComposerDraftStore(
@@ -2299,6 +2315,10 @@ function ChatViewContent(props: ChatViewProps) { · 926 unmodified lines
22992315 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
23002316 const phase = derivePhase(activeThread?.session ?? null);
23012317 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
2318+ const activeContextWindow = useMemo(
2319+ () => deriveLatestContextWindowSnapshot(threadActivities),
2320+ [threadActivities],
2321+ );
23022322 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);
23032323 const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]);
23042324 // Native subagent fold: memoized by activity-list identity, shared by the
@@ -2762,6 +2782,29 @@ function ChatViewContent(props: ChatViewProps) { · 457 unmodified lines
27622782 activeThread?.modelSelection.instanceId ??
27632783 activeProject?.defaultModelSelection?.instanceId ??
27642784 null;
2785+ const compactionProviderAvailable = useMemo(
2786+ () =>
2787+ hasAvailableClaudeCompactionProvider({
2788+ providers: applyProviderInstanceSettings(
2789+ deriveProviderInstanceEntries(providerStatuses),
2790+ settings,
2791+ ),
2792+ instanceId: activeProviderInstanceId,
2793+ lockedInstanceId: lockedProvider
2794+ ? (activeThread?.session?.providerInstanceId ??
2795+ activeThread?.modelSelection.instanceId ??
2796+ null)
2797+ : null,
2798+ }),
2799+ [
2800+ activeProviderInstanceId,
2801+ activeThread?.modelSelection.instanceId,
2802+ activeThread?.session?.providerInstanceId,
2803+ lockedProvider,
2804+ providerStatuses,
2805+ settings,
2806+ ],
2807+ );
27652808 const activeProviderStatus = useMemo(() => {
27662809 if (activeProviderInstanceId) {
27672810 return (
@@ -2771,6 +2814,25 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines
27712814 const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
27722815 return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
27732816 }, [activeProviderInstanceId, providerStatuses, selectedProvider]);
2817+ const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =
2818+ useLocalStorage(
2819+ `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`,
2820+ false,
2821+ Schema.Boolean,
2822+ );
2823+ const nativeResumeCompactionDismissed = useMemo(
2824+ () => hasDismissedResumeCompaction(threadActivities),
2825+ [threadActivities],
2826+ );
2827+ useEffect(() => {
2828+ if (nativeResumeCompactionDismissed && !resumeCompactionPermanentlyDismissed) {
2829+ setResumeCompactionPermanentlyDismissed(true);
2830+ }
2831+ }, [
2832+ nativeResumeCompactionDismissed,
2833+ resumeCompactionPermanentlyDismissed,
2834+ setResumeCompactionPermanentlyDismissed,
2835+ ]);
27742836 const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus);
27752837 const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState<
27762838 string | null
@@ -4515,18 +4577,6 @@ function ChatViewContent(props: ChatViewProps) { · 1738 unmodified lines
45154577 // Dismissal lives in a module-level set (survives remounts); this tick just
45164578 // forces a re-render so the banner leaves immediately.
45174579 const [, setBranchMismatchDismissTick] = useState(0);
4518− const composerHasDraftContent = useComposerDraftStore((store) => {
4519− const draft = store.getComposerDraft(composerDraftTarget);
4520− return Boolean(
4521− draft &&
4522− (draft.prompt.trim().length > 0 ||
4523− draft.images.length > 0 ||
4524− draft.terminalContexts.length > 0 ||
4525− draft.elementContexts.length > 0 ||
4526− draft.previewAnnotations.length > 0 ||
4527− draft.reviewComments.length > 0),
4528− );
4529− });
45304580 const activeBranchMismatchKey = branchMismatchKey(
45314581 activeThread?.id ?? null,
45324582 localCheckoutBranchMismatch,
@@ -4534,7 +4584,7 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line
45344584 const showBranchMismatchBanner = shouldShowBranchMismatchBanner({
45354585 hasMismatch: localCheckoutBranchMismatch !== null,
45364586 isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey),
4537− composerHasContent: composerHasDraftContent,
4587+ composerHasContent: composerHasUnsentContent,
45384588 wasShownForCurrentMismatch:
45394589 revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey,
45404590 });
@@ -4758,6 +4808,107 @@ function ChatViewContent(props: ChatViewProps) { · 217 unmodified lines
47584808 isUnsnoozing,
47594809 isUnsettling,
47604810 ]);
4811+ // Session-scoped dismissals, one key per (thread, snapshot). A set rather
4812+ // than a single slot so dismissing the banner on one thread does not
4813+ // resurface it on another thread dismissed earlier.
4814+ const [dismissedResumeCompactionKeys, setDismissedResumeCompactionKeys] = useState<
4815+ ReadonlySet<string>
4816+ >(new Set());
4817+ const resumeCompactionKey =
4818+ activeThread && activeContextWindow
4819+ ? `${activeThread.id}:${activeContextWindow.updatedAt}`
4820+ : null;
4821+ const compactDisabled =
4822+ !activeThread ||
4823+ !activeProject ||
4824+ !isServerThread ||
4825+ selectedProvider !== "claudeAgent" ||
4826+ !compactionProviderAvailable ||
4827+ isWorking ||
4828+ threadDetailLoading ||
4829+ isPreparingWorktree ||
4830+ activeEnvironmentUnavailable ||
4831+ feedbackUploading ||
4832+ pendingApprovals.length > 0 ||
4833+ pendingUserInputs.length > 0 ||
4834+ showPlanFollowUpPrompt ||
4835+ composerHasUnsentContent;
4836+ const compactDisabledReason = compactDisabled
4837+ ? composerHasUnsentContent
4838+ ? "Send or clear your draft before compacting"
4839+ : !activeProject
4840+ ? "Choose a project before compacting"
4841+ : !compactionProviderAvailable
4842+ ? "Enable a Claude provider before compacting"
4843+ : "Compacting is unavailable right now"
4844+ : null;
4845+ const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
4846+ if (
4847+ !activeThread ||
4848+ !activeContextWindow ||
4849+ resumeCompactionKey === null ||
4850+ dismissedResumeCompactionKeys.has(resumeCompactionKey) ||
4851+ resumeCompactionPermanentlyDismissed ||
4852+ nativeResumeCompactionDismissed ||
4853+ pendingUserInputs.length > 0 ||
4854+ phase === "running" ||
4855+ !shouldOfferResumeCompaction({
4856+ provider: selectedProvider,
4857+ usedTokens: activeContextWindow.usedTokens,
4858+ updatedAt: activeContextWindow.updatedAt,
4859+ now: `${nowMinute}:00.000Z`,
4860+ })
4861+ ) {
4862+ return null;
4863+ }
4864+
4865+ const dismiss = () =>
4866+ setDismissedResumeCompactionKeys((keys) => new Set(keys).add(resumeCompactionKey));
4867+ const compactAction = (
4868+ <Button
4869+ size="xs"
4870+ variant="outline"
4871+ disabled={compactDisabled}
4872+ onClick={() => {
4873+ if (compactDisabled) return;
4874+ composerRef.current?.compactContext();
4875+ }}
4876+ >
4877+ Compact
4878+ </Button>
4879+ );
4880+ return {
4881+ id: `resume-compaction:${resumeCompactionKey}`,
4882+ variant: "info",
4883+ icon: <Minimize2Icon />,
4884+ title: "Resume with less context",
4885+ description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from an older session`,
4886+ actions: compactDisabledReason ? (
4887+ <Tooltip>
4888+ <TooltipTrigger render={<span className="inline-flex">{compactAction}</span>} />
4889+ <TooltipPopup side="top">{compactDisabledReason}</TooltipPopup>
4890+ </Tooltip>
4891+ ) : (
4892+ compactAction
4893+ ),
4894+ dismissLabel: "Keep full history",
4895+ onDismiss: dismiss,
4896+ };
4897+ }, [
4898+ activeContextWindow,
4899+ activeThread,
4900+ compactDisabled,
4901+ compactDisabledReason,
4902+ composerRef,
4903+ dismissedResumeCompactionKeys,
4904+ nativeResumeCompactionDismissed,
4905+ nowMinute,
4906+ pendingUserInputs.length,
4907+ phase,
4908+ resumeCompactionKey,
4909+ resumeCompactionPermanentlyDismissed,
4910+ selectedProvider,
4911+ ]);
47614912 const handleRestoreThreadBranch = useCallback(() => {
47624913 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
47634914 setBranchRestoreConfirmOpen(true);
@@ -4772,6 +4923,8 @@ function ChatViewContent(props: ChatViewProps) { · 8 unmodified lines
47724923 const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item));
47734924 const backgroundLivenessItems =
47744925 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
4926+ const resumeCompactionItems =
4927+ resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem];
47754928 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
47764929 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
47774930 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
@@ -4779,6 +4932,7 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line
47794932 ...urgentSystemItems,
47804933 ...backgroundLivenessItems,
47814934 ...calmSystemItems,
4935+ ...resumeCompactionItems,
47824936 ...wokeThreadItems,
47834937 ...parkedThreadItems,
47844938 ];
@@ -4787,6 +4941,7 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines
47874941 ...urgentSystemItems,
47884942 ...backgroundLivenessItems,
47894943 ...calmSystemItems,
4944+ ...resumeCompactionItems,
47904945 ...wokeThreadItems,
47914946 {
47924947 id: `branch-mismatch:${activeBranchMismatchKey}`,
@@ -4836,6 +4991,7 @@ function ChatViewContent(props: ChatViewProps) { · 43 unmodified lines
48364991 isRestoringThreadBranch,
48374992 localCheckoutBranchMismatch,
48384993 parkedThreadBannerItem,
4994+ resumeCompactionBannerItem,
48394995 showBranchMismatchBanner,
48404996 systemComposerBannerItems,
48414997 wokeThreadBannerItem,
@@ -6916,7 +7072,9 @@ function ChatViewContent(props: ChatViewProps) { · 2074 unmodified lines
69167072 activeProject?.defaultModelSelection
69177073 }
69187074 activeThreadModelSelection={activeThread?.modelSelection}
6919− activeThreadActivities={activeThread?.activities}
7075+ activeContextWindow={activeContextWindow}
7076+ compactDisabled={compactDisabled}
7077+ compactDisabledReason={compactDisabledReason}
69207078 resolvedTheme={resolvedTheme}
69217079 settings={settings}
69227080 keybindings={keybindings}
apps/web/src/components/chat/ChatComposer.tsx+80−10
@@ -266,7 +266,7 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings"; · 265 unmodified lines
266266 import type { SessionPhase, Thread } from "../../types";
267267 import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
268268 import type { PendingApproval, PendingUserInput } from "../../session-logic";
269−import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
269+import type { ContextWindowSnapshot } from "../../lib/contextWindow";
270270 import {
271271 formatProviderSkillDisplayName,
272272 getProviderSlashCommandsForSlashMenu,
@@ -438,7 +438,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop · 165 unmodified lines
438438
439439 const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
440440 compact: boolean;
441− activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>;
441+ activeContextWindow: ContextWindowSnapshot | null;
442442 activeThreadModelDisplayName: string | null;
443443 isPreparingWorktree: boolean;
444444 pendingAction: {
@@ -461,6 +461,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 16 unmodified lines
461461 onPreviousPendingQuestion: () => void;
462462 onInterrupt: () => void;
463463 onImplementPlanInNewThread: () => void;
464+ onCompactContext?: (() => void) | undefined;
465+ compactDisabled: boolean;
466+ compactDisabledReason: string | null;
464467 }) {
465468 return (
466469 <>
@@ -468,6 +471,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 1 unmodified line
468471 <ContextWindowMeter
469472 usage={props.activeContextWindow}
470473 modelDisplayName={props.activeThreadModelDisplayName}
474+ onCompact={props.onCompactContext}
475+ compactDisabled={props.compactDisabled}
476+ compactDisabledReason={props.compactDisabledReason}
471477 />
472478 ) : null}
473479 {props.isPreparingWorktree ? (
@@ -507,6 +513,7 @@ export interface ChatComposerHandle { · 33 unmodified lines
507513 openModelPicker: () => void;
508514 toggleModelPicker: () => void;
509515 isModelPickerOpen: () => boolean;
516+ compactContext: () => void;
510517 readSnapshot: () => {
511518 value: string;
512519 cursor: number;
@@ -609,7 +616,9 @@ export interface ChatComposerProps { · 96 unmodified lines
609616 activeThreadModelSelection: ModelSelection | null | undefined;
610617
611618 // Context window
612− activeThreadActivities: Thread["activities"] | undefined;
619+ activeContextWindow: ContextWindowSnapshot | null;
620+ compactDisabled: boolean;
621+ compactDisabledReason: string | null;
613622
614623 // Misc
615624 resolvedTheme: "light" | "dark";
@@ -701,7 +710,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 85 unmodified lines
701710 providerStatuses,
702711 activeProjectDefaultModelSelection,
703712 activeThreadModelSelection,
704− activeThreadActivities,
713+ activeContextWindow,
714+ compactDisabled,
715+ compactDisabledReason,
705716 resolvedTheme,
706717 settings,
707718 keybindings,
@@ -914,6 +925,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 206 unmodified lines
914925 [providerInstanceEntries, selectedInstanceId],
915926 );
916927 const noProviderAvailable = selectedProviderEntry === undefined;
928+ const resolvedCompactDisabledReason =
929+ compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null);
917930 // The driver kind follows the instance that will actually run the turn,
918931 // which can differ from the persisted selection when that selection is
919932 // disabled.
@@ -1004,10 +1017,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 84 unmodified lines
10041017 // ------------------------------------------------------------------
10051018 // Context window
10061019 // ------------------------------------------------------------------
1007− const activeContextWindow = useMemo(
1008− () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []),
1009− [activeThreadActivities],
1010− );
10111020 const activeThreadModelDisplayName = useMemo(
10121021 () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance),
10131022 [activeThreadModelSelection, modelOptionsByInstance],
@@ -1073,8 +1082,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 59 unmodified lines
10731082 /**
10741083 * Count of pasted images still being compressed, per thread. Reserved
10751084 * against the attachment limit so concurrent pastes can't overshoot it,
1076− * and checked by `submitComposer` so a send can't race an image into the
1077− * next draft.
1085+ * and checked before sending or compacting so an image cannot move into
1086+ * the next draft.
10781087 */
10791088 const pendingImageCompressionsRef = useRef<Map<ThreadId, number>>(new Map());
10801089
@@ -1986,6 +1995,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 905 unmodified lines
19861995 shouldBlurMobileComposerOnSubmit,
19871996 ],
19881997 );
1998+ const compactThreadContext = useCallback(() => {
1999+ if (
2000+ compactDisabled ||
2001+ noProviderAvailable ||
2002+ composerSendState.hasSendableContent ||
2003+ activePendingApproval !== null ||
2004+ pendingUserInputs.length > 0 ||
2005+ phase === "running" ||
2006+ isSendBusy ||
2007+ isConnecting ||
2008+ !activeThreadId
2009+ ) {
2010+ return;
2011+ }
2012+ // The compact buttons cannot see the compression counter (it lives in
2013+ // a ref), so they render enabled during a paste; toast instead of
2014+ // silently ignoring the click.
2015+ if ((pendingImageCompressionsRef.current.get(activeThreadId) ?? 0) > 0) {
2016+ toastManager.add({
2017+ type: "info",
2018+ title: "Still compressing a pasted image.",
2019+ description: "Compact again once its thumbnail appears.",
2020+ });
2021+ return;
2022+ }
2023+
2024+ promptRef.current = "/compact";
2025+ setComposerDraftPrompt(composerDraftTarget, "/compact");
2026+ submitComposer();
2027+ // A blocked dispatch (busy send ref, provider preflight rejection)
2028+ // would leave the injected "/compact" behind as if the user typed it.
2029+ // Clearing here is safe even when the send did dispatch: the send
2030+ // snapshots its prompt synchronously and clears the draft itself.
2031+ if (promptRef.current === "/compact") {
2032+ promptRef.current = "";
2033+ setComposerDraftPrompt(composerDraftTarget, "");
2034+ }
2035+ }, [
2036+ activePendingApproval,
2037+ activeThreadId,
2038+ compactDisabled,
2039+ composerDraftTarget,
2040+ composerSendState.hasSendableContent,
2041+ isConnecting,
2042+ isSendBusy,
2043+ noProviderAvailable,
2044+ pendingUserInputs.length,
2045+ phase,
2046+ promptRef,
2047+ setComposerDraftPrompt,
2048+ submitComposer,
2049+ ]);
19892050 const expandMobileComposer = useCallback(() => {
19902051 if (composerBlurFrameRef.current !== null) {
19912052 window.cancelAnimationFrame(composerBlurFrameRef.current);
@@ -2764,6 +2825,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 772 unmodified lines
27642825 toggleModelPicker: () => {
27652826 setIsComposerModelPickerOpen((open) => !open);
27662827 },
2828+ compactContext: compactThreadContext,
27672829 isModelPickerOpen: () => isComposerModelPickerOpen,
27682830 readSnapshot: () => {
27692831 return readComposerSnapshot();
@@ -2875,6 +2937,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 105 unmodified lines
28752937 selectedPromptEffort,
28762938 selectedProvider,
28772939 selectedProviderModels,
2940+ compactThreadContext,
28782941 ],
28792942 );
28802943
@@ -3517,6 +3580,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 636 unmodified lines
35173580 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
35183581 onInterrupt={handleInterruptPrimaryAction}
35193582 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3583+ compactDisabled={
3584+ compactDisabled || noProviderAvailable || isSendBusy || isConnecting
3585+ }
3586+ compactDisabledReason={resolvedCompactDisabledReason}
3587+ {...(selectedProvider === "claudeAgent"
3588+ ? { onCompactContext: compactThreadContext }
3589+ : {})}
35203590 />
35213591 </div>
35223592 </div>
apps/web/src/components/chat/ComposerBannerStack.test.tsx+28−0
@@ -77,4 +77,32 @@ describe("ComposerBannerStack", () => { · 76 unmodified lines
7777 expect(markup).toContain("branch-surface");
7878 expect(markup).toContain("branch-actions");
7979 });
80+
81+ it("renders a disabled compaction action on the shared accessible banner surface", () => {
82+ const markup = renderToStaticMarkup(
83+ <ComposerBannerStack
84+ items={[
85+ {
86+ id: "resume-compaction",
87+ variant: "info",
88+ icon: <span aria-hidden="true">!</span>,
89+ title: "Resume with less context",
90+ description: "250k tokens from an older session",
91+ actions: (
92+ <button type="button" disabled>
93+ Compact
94+ </button>
95+ ),
96+ dismissLabel: "Keep full history",
97+ onDismiss: () => {},
98+ },
99+ ]}
100+ />,
101+ );
102+
103+ expect(markup).toContain('role="alert"');
104+ expect(markup).toContain("chat-composer-drawer-attached");
105+ expect(markup).toContain('disabled=""');
106+ expect(markup).toContain('aria-label="Keep full history"');
107+ });
80108 });
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+179−1
@@ -1,10 +1,83 @@
1−import { ProviderInstanceId } from "@t3tools/contracts";
1+import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
22 import { describe, expect, it } from "vite-plus/test";
3+import { deriveProviderInstanceEntries } from "../../providerInstances";
34 import {
45 formatContextWindowCompactionMessage,
6+ hasAvailableClaudeCompactionProvider,
7+ hasDismissedResumeCompaction,
58 resolveContextWindowModelDisplayName,
9+ shouldOfferResumeCompaction,
610 } from "./ContextWindowMeter.logic";
711
12+function claudeProvider(input: {
13+ instanceId: string;
14+ continuationGroupKey: string;
15+ enabled?: boolean;
16+}): ServerProvider {
17+ return {
18+ instanceId: ProviderInstanceId.make(input.instanceId),
19+ driver: ProviderDriverKind.make("claudeAgent"),
20+ continuation: { groupKey: input.continuationGroupKey },
21+ enabled: input.enabled ?? true,
22+ installed: true,
23+ version: null,
24+ status: "ready",
25+ auth: { status: "authenticated" },
26+ checkedAt: "2026-08-24T12:00:00.000Z",
27+ models: [],
28+ slashCommands: [],
29+ skills: [],
30+ };
31+}
32+
33+describe("hasAvailableClaudeCompactionProvider", () => {
34+ const originalInstanceId = ProviderInstanceId.make("claude_original");
35+
36+ it("rejects a fallback in a different locked continuation group", () => {
37+ const providers = deriveProviderInstanceEntries([
38+ claudeProvider({
39+ instanceId: originalInstanceId,
40+ continuationGroupKey: "claude:home:/original",
41+ enabled: false,
42+ }),
43+ claudeProvider({
44+ instanceId: "claude_other",
45+ continuationGroupKey: "claude:home:/other",
46+ }),
47+ ]);
48+
49+ expect(
50+ hasAvailableClaudeCompactionProvider({
51+ providers,
52+ instanceId: originalInstanceId,
53+ lockedInstanceId: originalInstanceId,
54+ }),
55+ ).toBe(false);
56+ });
57+
58+ it("accepts an enabled fallback in the locked continuation group", () => {
59+ const providers = deriveProviderInstanceEntries([
60+ claudeProvider({
61+ instanceId: originalInstanceId,
62+ continuationGroupKey: "claude:home:/original",
63+ enabled: false,
64+ }),
65+ claudeProvider({
66+ instanceId: "claude_fallback",
67+ continuationGroupKey: "claude:home:/original",
68+ }),
69+ ]);
70+
71+ expect(
72+ hasAvailableClaudeCompactionProvider({
73+ providers,
74+ instanceId: originalInstanceId,
75+ lockedInstanceId: originalInstanceId,
76+ }),
77+ ).toBe(true);
78+ });
79+});
80+
881 describe("resolveContextWindowModelDisplayName", () => {
982 it("uses the selected model from the exact provider instance", () => {
1083 const primaryInstanceId = ProviderInstanceId.make("codex");
@@ -55,4 +128,109 @@ describe("formatContextWindowCompactionMessage", () => { · 44 unmodified lines
55128 "Context compacts automatically when needed.",
56129 );
57130 });
131+
132+ it("shows the configured auto-compaction threshold", () => {
133+ expect(formatContextWindowCompactionMessage("Claude Sonnet 5", 300_000)).toBe(
134+ "Compacts automatically at 300,000 tokens.",
135+ );
136+ });
137+});
138+
139+describe("shouldOfferResumeCompaction", () => {
140+ const now = "2026-08-24T12:00:00.000Z";
141+
142+ it("matches Claude's old-session age and context thresholds", () => {
143+ expect(
144+ shouldOfferResumeCompaction({
145+ provider: "claudeAgent",
146+ usedTokens: 100_000,
147+ updatedAt: "2026-08-24T10:50:00.000Z",
148+ now,
149+ }),
150+ ).toBe(true);
151+ });
152+
153+ it("does not prompt for recent or smaller sessions", () => {
154+ expect(
155+ shouldOfferResumeCompaction({
156+ provider: "claudeAgent",
157+ usedTokens: 99_999,
158+ updatedAt: "2026-08-24T10:00:00.000Z",
159+ now,
160+ }),
161+ ).toBe(false);
162+ expect(
163+ shouldOfferResumeCompaction({
164+ provider: "claudeAgent",
165+ usedTokens: 200_000,
166+ updatedAt: "2026-08-24T10:51:00.000Z",
167+ now,
168+ }),
169+ ).toBe(false);
170+ });
171+
172+ it("does not show Claude's resume prompt for another provider", () => {
173+ expect(
174+ shouldOfferResumeCompaction({
175+ provider: "codex",
176+ usedTokens: 300_000,
177+ updatedAt: "2026-08-24T09:00:00.000Z",
178+ now,
179+ }),
180+ ).toBe(false);
181+ });
182+});
183+
184+describe("hasDismissedResumeCompaction", () => {
185+ it("recognizes the native resume dialog's permanent dismissal", () => {
186+ expect(
187+ hasDismissedResumeCompaction([
188+ {
189+ kind: "user-input.resolved",
190+ payload: {
191+ answers: {
192+ "This session is 2h 0m old and uses 250,000 tokens. Compact it before continuing?":
193+ "Don't ask again",
194+ },
195+ },
196+ },
197+ ]),
198+ ).toBe(true);
199+ });
200+
201+ it("ignores the same answer on an unrelated question", () => {
202+ expect(
203+ hasDismissedResumeCompaction([
204+ {
205+ kind: "user-input.resolved",
206+ payload: { answers: { "Show this setup reminder?": "Don't ask again" } },
207+ },
208+ ]),
209+ ).toBe(false);
210+ });
211+
212+ it("ignores unrelated questions that end with Claude's compaction prompt", () => {
213+ expect(
214+ hasDismissedResumeCompaction([
215+ {
216+ kind: "user-input.resolved",
217+ payload: {
218+ answers: {
219+ "The build cache is large. Compact it before continuing?": "Don't ask again",
220+ },
221+ },
222+ },
223+ ]),
224+ ).toBe(false);
225+ });
226+
227+ it("ignores pending questions and malformed resolved payloads", () => {
228+ expect(
229+ hasDismissedResumeCompaction([
230+ { kind: "user-input.requested", payload: { answers: { question: "Don't ask again" } } },
231+ { kind: "user-input.resolved", payload: null },
232+ { kind: "user-input.resolved", payload: { answers: ["Don't ask again"] } },
233+ ]),
234+ ).toBe(false);
235+ });
58236 });
apps/web/src/components/chat/ContextWindowMeter.logic.ts+79−0
@@ -1,6 +1,81 @@
11 import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts";
2+import {
3+ CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
4+ isClaudeResumeCompactionQuestion,
5+} from "@t3tools/shared/claudeCompaction";
6+import {
7+ resolveSelectableProviderInstanceEntry,
8+ type ProviderInstanceEntry,
9+} from "../../providerInstances";
210 import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils";
311
12+export const CLAUDE_RESUME_COMPACTION_MINUTES = 70;
13+export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000;
14+
15+export function hasAvailableClaudeCompactionProvider(input: {
16+ readonly providers: ReadonlyArray<ProviderInstanceEntry>;
17+ readonly instanceId: ProviderInstanceId | null;
18+ readonly lockedInstanceId: ProviderInstanceId | null;
19+}): boolean {
20+ const claudeProviders = input.providers.filter(
21+ (provider) => provider.driverKind === "claudeAgent",
22+ );
23+ const lockedContinuationGroupKey = input.lockedInstanceId
24+ ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId)
25+ ?.continuationGroupKey
26+ : undefined;
27+ const compatibleProviders = lockedContinuationGroupKey
28+ ? claudeProviders.filter(
29+ (provider) => provider.continuationGroupKey === lockedContinuationGroupKey,
30+ )
31+ : claudeProviders;
32+
33+ return (
34+ resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !==
35+ undefined
36+ );
37+}
38+
39+export function hasDismissedResumeCompaction(
40+ activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>,
41+): boolean {
42+ return activities.some((activity) => {
43+ if (activity.kind !== "user-input.resolved") return false;
44+ const payload = activity.payload;
45+ if (!payload || typeof payload !== "object") return false;
46+ const answers = (payload as { readonly answers?: unknown }).answers;
47+ if (!answers || typeof answers !== "object" || Array.isArray(answers)) return false;
48+
49+ return Object.entries(answers).some(
50+ ([question, answer]) =>
51+ isClaudeResumeCompactionQuestion(question) &&
52+ answer === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
53+ );
54+ });
55+}
56+
57+export function shouldOfferResumeCompaction(input: {
58+ readonly provider: string | null | undefined;
59+ readonly usedTokens: number | null | undefined;
60+ readonly updatedAt: string | null | undefined;
61+ readonly now: string;
62+}): boolean {
63+ if (
64+ input.provider !== "claudeAgent" ||
65+ (input.usedTokens ?? 0) < CLAUDE_RESUME_COMPACTION_TOKENS
66+ ) {
67+ return false;
68+ }
69+
70+ const updatedAt = Date.parse(input.updatedAt ?? "");
71+ const now = Date.parse(input.now);
72+ return (
73+ Number.isFinite(updatedAt) &&
74+ Number.isFinite(now) &&
75+ now - updatedAt >= CLAUDE_RESUME_COMPACTION_MINUTES * 60_000
76+ );
77+}
78+
479 export function resolveContextWindowModelDisplayName(
580 selection: ModelSelection | null | undefined,
681 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>,
@@ -18,7 +93,11 @@ export function resolveContextWindowModelDisplayName( · 11 unmodified lines
1893
1994 export function formatContextWindowCompactionMessage(
2095 modelDisplayName: string | null | undefined,
96+ autoCompactThreshold?: number | null,
2197 ): string {
98+ if (typeof autoCompactThreshold === "number" && autoCompactThreshold > 0) {
99+ return `Compacts automatically at ${autoCompactThreshold.toLocaleString("en-US")} tokens.`;
100+ }
22101 return modelDisplayName
23102 ? `Context for ${modelDisplayName} compacts automatically when needed.`
24103 : "Context compacts automatically when needed.";
apps/web/src/components/chat/ContextWindowMeter.test.tsx+62−0
@@ -0,0 +1,62 @@
1+import { EventId, TurnId } from "@t3tools/contracts";
2+import type { ReactNode } from "react";
3+import { renderToStaticMarkup } from "react-dom/server";
4+import { describe, expect, it, vi } from "vite-plus/test";
5+
6+import { deriveLatestContextWindowSnapshot } from "~/lib/contextWindow";
7+import { ContextWindowMeter } from "./ContextWindowMeter";
8+
9+vi.mock("../ui/popover", () => ({
10+ Popover: ({ children }: { children: ReactNode }) => children,
11+ PopoverPopup: ({ children }: { children: ReactNode }) => children,
12+ PopoverTrigger: ({ closeDelay, render }: { closeDelay: number; render: ReactNode }) => (
13+ <div data-close-delay={closeDelay}>{render}</div>
14+ ),
15+}));
16+
17+const usage = deriveLatestContextWindowSnapshot([
18+ {
19+ id: EventId.make("activity-1"),
20+ tone: "info",
21+ kind: "context-window.updated",
22+ summary: "Context updated",
23+ payload: { usedTokens: 100_000, maxTokens: 1_000_000 },
24+ turnId: TurnId.make("turn-1"),
25+ createdAt: "2026-08-24T12:00:00.000Z",
26+ },
27+]);
28+
29+if (!usage) {
30+ throw new Error("The context window test fixture did not produce a snapshot.");
31+}
32+
33+describe("ContextWindowMeter", () => {
34+ it("keeps the hover popover open while the pointer moves to the compact button", () => {
35+ const markup = renderToStaticMarkup(<ContextWindowMeter usage={usage} onCompact={() => {}} />);
36+
37+ expect(markup).toContain('data-close-delay="150"');
38+ expect(markup).toContain("Compact context");
39+ });
40+
41+ it("closes an informational hover popover without delay", () => {
42+ const markup = renderToStaticMarkup(<ContextWindowMeter usage={usage} />);
43+
44+ expect(markup).toContain('data-close-delay="0"');
45+ expect(markup).not.toContain("Compact context");
46+ });
47+
48+ it("explains why the compact action is disabled", () => {
49+ const markup = renderToStaticMarkup(
50+ <ContextWindowMeter
51+ usage={usage}
52+ onCompact={() => {}}
53+ compactDisabled
54+ compactDisabledReason="Send or clear your draft before compacting"
55+ />,
56+ );
57+
58+ expect(markup).toContain('disabled=""');
59+ expect(markup).toContain(">Send or clear your draft before compacting<");
60+ expect(markup).not.toContain('aria-label="Send or clear your draft before compacting"');
61+ });
62+});
apps/web/src/components/chat/ContextWindowMeter.tsx+26−3
@@ -2,6 +2,7 @@ import { Button } from "../ui/button"; · 1 unmodified line
22 import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow";
33 import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
44 import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic";
5+import { Minimize2Icon } from "lucide-react";
56
67 function formatPercentage(value: number | null): string | null {
78 if (value === null || !Number.isFinite(value)) {
@@ -16,8 +17,11 @@ function formatPercentage(value: number | null): string | null { · 8 unmodified lines
1617 export function ContextWindowMeter(props: {
1718 usage: ContextWindowSnapshot;
1819 modelDisplayName?: string | null;
20+ onCompact?: (() => void) | undefined;
21+ compactDisabled?: boolean | undefined;
22+ compactDisabledReason?: string | null | undefined;
1923 }) {
20− const { usage, modelDisplayName } = props;
24+ const { usage, modelDisplayName, onCompact, compactDisabled, compactDisabledReason } = props;
2125 const usedPercentage = formatPercentage(usage.usedPercentage);
2226 const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0));
2327 const radius = 9.75;
@@ -35,7 +39,7 @@ export function ContextWindowMeter(props: { · 11 unmodified lines
3539 <PopoverTrigger
3640 openOnHover
3741 delay={150}
38− closeDelay={0}
42+ closeDelay={onCompact ? 150 : 0}
3943 render={
4044 <Button
4145 size="icon-sm"
@@ -128,9 +132,28 @@ export function ContextWindowMeter(props: { · 86 unmodified lines
128132 ) : null}
129133 {usage.compactsAutomatically ? (
130134 <div className="mt-1 text-pretty text-secondary-label text-[11px] font-medium">
131− {formatContextWindowCompactionMessage(modelDisplayName)}
135+ {formatContextWindowCompactionMessage(modelDisplayName, usage.autoCompactThreshold)}
132136 </div>
133137 ) : null}
138+ {onCompact ? (
139+ <>
140+ <Button
141+ size="xs"
142+ variant="outline"
143+ className="mt-1 w-full justify-center"
144+ disabled={compactDisabled}
145+ onClick={onCompact}
146+ >
147+ <Minimize2Icon aria-hidden="true" />
148+ Compact context
149+ </Button>
150+ {compactDisabled && compactDisabledReason ? (
151+ <div className="text-pretty text-secondary-label text-[11px]">
152+ {compactDisabledReason}
153+ </div>
154+ ) : null}
155+ </>
156+ ) : null}
134157 </div>
135158 </PopoverPopup>
136159 </Popover>
apps/web/src/components/settings/ProviderSettingsForm.test.ts+12−0
@@ -37,6 +37,18 @@ describe("ProviderSettingsForm helpers", () => { · 36 unmodified lines
3737 });
3838 });
3939
40+ it("shows the auto-compaction threshold for Claude providers", () => {
41+ const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")];
42+ expect(claude).toBeDefined();
43+
44+ expect(deriveProviderSettingsFields(claude!).map((field) => field.key)).toEqual([
45+ "binaryPath",
46+ "homePath",
47+ "autoCompactWindow",
48+ "launchArgs",
49+ ]);
50+ });
51+
4052 it("preserves unknown config keys while omitting empty configurable fields", () => {
4153 const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")];
4254 expect(opencode).toBeDefined();
apps/web/src/lib/contextWindow.test.ts+2−0
@@ -26,6 +26,7 @@ describe("contextWindow", () => { · 25 unmodified lines
2626 usedTokens: 14_000,
2727 maxTokens: 258_000,
2828 compactsAutomatically: true,
29+ autoCompactThreshold: 200_000,
2930 }),
3031 ]);
3132
@@ -34,6 +35,7 @@ describe("contextWindow", () => { · 2 unmodified lines
3435 expect(snapshot?.totalProcessedTokens).toBeNull();
3536 expect(snapshot?.maxTokens).toBe(258_000);
3637 expect(snapshot?.compactsAutomatically).toBe(true);
38+ expect(snapshot?.autoCompactThreshold).toBe(200_000);
3739 });
3840
3941 it("ignores malformed payloads", () => {
apps/web/src/lib/contextWindow.ts+1−0
@@ -88,6 +88,7 @@ export function deriveLatestContextWindowSnapshot( · 87 unmodified lines
8888 toolUses: asFiniteNumber(payload?.toolUses),
8989 durationMs: asFiniteNumber(payload?.durationMs),
9090 compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false,
91+ autoCompactThreshold: asFiniteNumber(payload?.autoCompactThreshold),
9192 updatedAt: activity.createdAt,
9293 };
9394 }
docs/user/providers-claude.md+12−0
@@ -34,6 +34,18 @@ When you set this field, T3 Code points Claude Code at that directory with the · 33 unmodified lines
3434 `CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and
3535 the rest of your environment stay as they are.
3636
37+## Reduce Context Usage
38+
39+In Settings, open your Claude provider and set **Auto-compact after** to a token count between
40+`100000` and `1000000`. For example, `300000` compacts the conversation into a summary once it
41+reaches about 300,000 tokens, without changing the model's context window. Leave the field
42+empty to keep Claude Code's default behavior.
43+
44+On web and desktop, when you return to an older Claude thread with a large context, T3 Code
45+offers to compact the conversation before you continue. You can also select **Compact context**
46+from the context meter. On every client, you can enter `/compact` in the message composer, and
47+Claude can show its own resume prompt when you continue an old session.
48+
3749 ## Where Claude Skills Are Loaded
3850
3951 T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then
packages/contracts/src/providerRuntime.ts+1−0
@@ -324,6 +324,7 @@ export const ThreadTokenUsageSnapshot = Schema.Struct({ · 323 unmodified lines
324324 toolUses: Schema.optional(NonNegativeInt),
325325 durationMs: Schema.optional(NonNegativeInt),
326326 compactsAutomatically: Schema.optional(Schema.Boolean),
327+ autoCompactThreshold: Schema.optional(PositiveInt),
327328 });
328329 export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type;
329330
packages/contracts/src/settings.test.ts+31−0
@@ -5,6 +5,7 @@ import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; · 4 unmodified lines
55 import {
66 ClientSettingsSchema,
77 ClientSettingsPatch,
8+ ClaudeSettings,
89 DEFAULT_SERVER_SETTINGS,
910 defaultEnabledForDriver,
1011 resolveProviderInstanceEnabled,
@@ -17,6 +18,36 @@ const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); · 6 unmodified lines
1718 const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
1819 const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
1920 const encodeServerSettings = Schema.encodeSync(ServerSettings);
21+const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
22+
23+describe("ClaudeSettings auto-compaction", () => {
24+ it("uses Claude's default threshold when no override is configured", () => {
25+ expect(decodeClaudeSettings({}).autoCompactWindow).toBe("");
26+ });
27+
28+ it.each(["100000", "300000", "1000000"])(
29+ "accepts a supported auto-compaction threshold: %s",
30+ (value) => {
31+ expect(decodeClaudeSettings({ autoCompactWindow: value }).autoCompactWindow).toBe(value);
32+ },
33+ );
34+
35+ it.each(["99999", "1000001", "300k", "invalid"])(
36+ "rejects an unsupported auto-compaction threshold: %s",
37+ (value) => {
38+ expect(() => decodeClaudeSettings({ autoCompactWindow: value })).toThrow();
39+ },
40+ );
41+
42+ it("rejects an unsupported threshold at the settings patch boundary", () => {
43+ expect(() =>
44+ decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300k" } } }),
45+ ).toThrow();
46+ expect(
47+ decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300000" } } }),
48+ ).toBeDefined();
49+ });
50+});
2051
2152 describe("ClientSettings word wrap", () => {
2253 it("defaults word wrap on", () => {
packages/contracts/src/settings.ts+25−1
@@ -373,6 +373,11 @@ export const CodexSettings = makeProviderSettingsSchema( · 372 unmodified lines
373373 );
374374 export type CodexSettings = typeof CodexSettings.Type;
375375
376+// Empty, or an integer from 100,000 to 1,000,000. Shared by the full
377+// Claude settings schema and its patch so an out-of-range value fails at
378+// the update that introduced it.
379+const CLAUDE_AUTO_COMPACT_WINDOW_PATTERN = /^(?:|[1-9]\d{5}|1000000)$/;
380+
376381 export const ClaudeSettings = makeProviderSettingsSchema(
377382 {
378383 enabled: Schema.Boolean.pipe(
@@ -410,9 +415,23 @@ export const ClaudeSettings = makeProviderSettingsSchema( · 31 unmodified lines
410415 },
411416 }),
412417 ),
418+ autoCompactWindow: TrimmedString.check(
419+ Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN),
420+ ).pipe(
421+ Schema.withDecodingDefault(Effect.succeed("")),
422+ Schema.annotateKey({
423+ title: "Auto-compact after",
424+ description:
425+ "Compact after 100,000 to 1,000,000 tokens. Leave empty to use Claude's default.",
426+ providerSettingsForm: {
427+ placeholder: "e.g. 300000",
428+ clearWhenEmpty: "omit",
429+ },
430+ }),
431+ ),
413432 },
414433 {
415− order: ["binaryPath", "homePath", "launchArgs"],
434+ order: ["binaryPath", "homePath", "autoCompactWindow", "launchArgs"],
416435 },
417436 );
418437 export type ClaudeSettings = typeof ClaudeSettings.Type;
@@ -797,6 +816,11 @@ const ClaudeSettingsPatch = Schema.Struct({ · 378 unmodified lines
797816 homePath: Schema.optionalKey(TrimmedString),
798817 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
799818 launchArgs: Schema.optionalKey(TrimmedString),
819+ // Validated at the patch boundary so a typo fails the one update with a
820+ // schema error instead of a generic whole-settings failure.
821+ autoCompactWindow: Schema.optionalKey(
822+ TrimmedString.check(Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN)),
823+ ),
800824 });
801825
802826 const CursorSettingsPatch = Schema.Struct({
packages/shared/package.json+4−0
@@ -226,6 +226,10 @@ · 225 unmodified lines
226226 "./usageFormat": {
227227 "types": "./src/usageFormat.ts",
228228 "import": "./src/usageFormat.ts"
229+ },
230+ "./claudeCompaction": {
231+ "types": "./src/claudeCompaction.ts",
232+ "import": "./src/claudeCompaction.ts"
229233 }
230234 },
231235 "scripts": {
packages/shared/src/claudeCompaction.test.ts+35−0
@@ -0,0 +1,35 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import {
4+ formatClaudeResumeCompactionQuestion,
5+ isClaudeResumeCompactionQuestion,
6+} from "./claudeCompaction.ts";
7+
8+describe("claude resume compaction copy", () => {
9+ // The matcher must recognize every question the formatter can produce.
10+ // This is the drift guard: rewording one side fails here.
11+ it.each([
12+ { ageMinutes: 145, estimatedTokens: 275_123 },
13+ { ageMinutes: 70, estimatedTokens: 100_000 },
14+ { ageMinutes: 59, estimatedTokens: 1_234_567 },
15+ { ageMinutes: 0, estimatedTokens: 0 },
16+ ])("matches its own formatted question (%o)", (input) => {
17+ const question = formatClaudeResumeCompactionQuestion(input);
18+ expect(isClaudeResumeCompactionQuestion(question)).toBe(true);
19+ });
20+
21+ it("formats ages above and below one hour", () => {
22+ expect(
23+ formatClaudeResumeCompactionQuestion({ ageMinutes: 145, estimatedTokens: 275_123 }),
24+ ).toBe("This session is 2h 25m old and uses 275,123 tokens. Compact it before continuing?");
25+ expect(formatClaudeResumeCompactionQuestion({ ageMinutes: 45, estimatedTokens: 1_000 })).toBe(
26+ "This session is 45m old and uses 1,000 tokens. Compact it before continuing?",
27+ );
28+ });
29+
30+ it("does not match unrelated questions", () => {
31+ expect(
32+ isClaudeResumeCompactionQuestion("The build cache is large. Compact it before continuing?"),
33+ ).toBe(false);
34+ });
35+});
packages/shared/src/claudeCompaction.ts+26−0
@@ -0,0 +1,26 @@
1+/**
2+ * Copy for Claude's resume compaction dialog, shared by the server adapter
3+ * (which asks the question) and the web client (which recognizes the
4+ * question and its "never" answer in resolved user-input activities to
5+ * mirror the dismissal). Both sides must agree on these strings, so they
6+ * live here: reword the question or the answer label in this file only.
7+ */
8+export const CLAUDE_RESUME_COMPACTION_NEVER_ANSWER = "Don't ask again";
9+
10+export function formatClaudeResumeCompactionQuestion(input: {
11+ readonly ageMinutes: number;
12+ readonly estimatedTokens: number;
13+}): string {
14+ const ageLabel =
15+ input.ageMinutes >= 60
16+ ? `${Math.floor(input.ageMinutes / 60)}h ${input.ageMinutes % 60}m`
17+ : `${input.ageMinutes}m`;
18+ return `This session is ${ageLabel} old and uses ${input.estimatedTokens.toLocaleString("en-US")} tokens. Compact it before continuing?`;
19+}
20+
21+const CLAUDE_RESUME_COMPACTION_QUESTION_PATTERN =
22+ /^This session is (?:\d+h \d+m|\d+m) old and uses \d{1,3}(?:,\d{3})* tokens\. Compact it before continuing\?$/u;
23+
24+export function isClaudeResumeCompactionQuestion(question: string): boolean {
25+ return CLAUDE_RESUME_COMPACTION_QUESTION_PATTERN.test(question);
26+}
packages/shared/src/model.test.ts+31−0
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; · 1 unmodified line
22 import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
33
44 import {
5+ applyClaudePromptEffortPrefix,
56 buildProviderOptionSelectionsFromDescriptors,
67 createModelCapabilities,
78 createModelSelection,
@@ -155,3 +156,33 @@ describe("model slug normalization", () => { · 147 unmodified lines
155156 expect(normalizeCustomModelSlug(" opus ")).toBe("opus");
156157 });
157158 });
159+
160+describe("applyClaudePromptEffortPrefix", () => {
161+ it("keeps slash commands intact when ultrathink is selected", () => {
162+ expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact");
163+ expect(applyClaudePromptEffortPrefix(" /compact keep recent errors ", "ultrathink")).toBe(
164+ "/compact keep recent errors",
165+ );
166+ expect(applyClaudePromptEffortPrefix(" /review src/model.ts ", "ultrathink")).toBe(
167+ "/review src/model.ts",
168+ );
169+ expect(applyClaudePromptEffortPrefix("/security-review", "ultrathink")).toBe(
170+ "/security-review",
171+ );
172+ expect(applyClaudePromptEffortPrefix("/plugin:skill run", "ultrathink")).toBe(
173+ "/plugin:skill run",
174+ );
175+ expect(applyClaudePromptEffortPrefix("/deploy.prod to staging", "ultrathink")).toBe(
176+ "/deploy.prod to staging",
177+ );
178+ });
179+
180+ it("still adds the ultrathink prefix to ordinary prompts", () => {
181+ expect(applyClaudePromptEffortPrefix("Investigate this failure", "ultrathink")).toBe(
182+ "Ultrathink:\nInvestigate this failure",
183+ );
184+ expect(applyClaudePromptEffortPrefix("/home/theo/app.ts crashed on load", "ultrathink")).toBe(
185+ "Ultrathink:\n/home/theo/app.ts crashed on load",
186+ );
187+ });
188+});
packages/shared/src/model.ts+5−1
@@ -362,7 +362,11 @@ export function applyClaudePromptEffortPrefix( · 361 unmodified lines
362362 if (!trimmed) {
363363 return trimmed;
364364 }
365− if (effort !== "ultrathink") {
365+ // Prefixing a slash command turns it into plain prose, so Claude never
366+ // runs it. Command names come from arbitrary file names ("/deploy.prod",
367+ // "/plugin:skill"), so accept any first token without a second slash;
368+ // absolute paths like "/home/theo/app.ts" keep the prefix.
369+ if (effort !== "ultrathink" || /^\/[^\s/]+(?:\s|$)/u.test(trimmed)) {
366370 return trimmed;
367371 }
368372 if (trimmed.startsWith("Ultrathink:")) {