From bff834ab8335a737c0df52e6ad2ae4b93b1685eb Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 31 Oct 2026 01:05:54 +0000 Subject: [PATCH 1/4] fix(mobile): sends into stale Claude threads compact first, like web "When you return" idle compaction only ran in the web composer, so a mobile send into a large Claude thread past its prompt cache TTL re-read the whole context uncached (#27911). The web context-window derivation and resume-compaction predicates move to @t3tools/client-runtime/state/context-window, with a shouldCompactBeforeSend check both clients' rules now share. The mobile composer records the intent on the outbox message; delivery sends a queued /compact (idleCompaction onReturn, ids derived from the message's so retries replay it) before the message, which queues behind it. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/thread-outbox-model.ts | 3 + apps/mobile/src/state/thread-outbox.test.ts | 2 + .../threadOutboxDelivery.permissions.test.ts | 49 +++ apps/mobile/src/state/threadOutboxDelivery.ts | 33 +- .../src/state/use-thread-composer-state.ts | 26 +- apps/web/src/components/ChatView.logic.ts | 5 +- apps/web/src/components/ChatView.tsx | 10 +- apps/web/src/components/chat/ChatComposer.tsx | 2 +- .../chat/ComposerFooterControls.tsx | 2 +- .../chat/ContextWindowMeter.logic.test.ts | 2 +- .../chat/ContextWindowMeter.logic.ts | 49 --- .../components/chat/ContextWindowMeter.tsx | 3 +- .../components/chat/ThreadDetailsUsage.tsx | 3 +- apps/web/src/lib/contextWindow.test.ts | 7 +- apps/web/src/lib/contextWindow.ts | 176 ----------- docs/user/providers-claude.md | 3 +- packages/client-runtime/package.json | 4 + .../src/state/contextWindow.test.ts | 140 ++++++++ .../client-runtime/src/state/contextWindow.ts | 298 ++++++++++++++++++ 19 files changed, 572 insertions(+), 245 deletions(-) create mode 100644 packages/client-runtime/src/state/contextWindow.test.ts create mode 100644 packages/client-runtime/src/state/contextWindow.ts diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 6c5da0db20991..703d9f8450fdb 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -56,6 +56,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({ attachments: Schema.Array(DraftComposerAttachmentSchema), modelSelection: Schema.optional(ModelSelection), dispatchMode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), + compactBeforeSend: Schema.optional(Schema.Boolean), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), creation: Schema.optional(QueuedThreadCreationSchema), @@ -89,6 +90,8 @@ export interface QueuedThreadMessage { readonly interactionMode?: ProviderInteractionModeType; /** Captured at enqueue because draining may occur long after the tap. */ readonly dispatchMode?: ComposerDispatchMode; + /** Sends "When you return"'s `/compact` ahead of the message, which queues behind it. */ + readonly compactBeforeSend?: boolean; readonly creation?: QueuedThreadCreation; readonly createdAt: string; } diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 8aaf94ffc0df6..dbd621004b0d6 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -329,6 +329,8 @@ describe("thread outbox", () => { }, runtimeMode: "approval-required", interactionMode: "plan", + dispatchMode: "queue", + compactBeforeSend: true, } satisfies QueuedThreadMessage; expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual( diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index 0ea64764e5da2..fdd5c71094161 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -498,3 +498,52 @@ describe("queued task operation access", () => { }, ); }); + +describe("compaction before a queued send", () => { + const compactTurn = { + commandId: "command:compact", + message: { + messageId: "message:compact", + role: "user", + text: "/compact", + attachments: [], + idleCompaction: "onReturn", + }, + dispatchMode: "queue", + }; + + it("queues /compact ahead of the message and resends the same /compact on a retry", async () => { + setOperateGrant("secondary", true); + const queued = message({ compactBeforeSend: true, dispatchMode: "queue" }); + await state.manager.enqueue(queued); + state.start + .mockResolvedValueOnce(AsyncResult.success({})) + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Socket is not connected")))); + await runDrain(); + expect(remaining()).toEqual([queued]); + + // A restarted app delivers the persisted message again. + state.cleanups.splice(0).forEach((cleanup) => cleanup()); + await runDrain(); + expect(state.start.mock.calls.map(([call]) => call.input)).toEqual([ + expect.objectContaining(compactTurn), + expect.objectContaining({ commandId: "command", dispatchMode: "queue" }), + expect.objectContaining(compactTurn), + expect.objectContaining({ commandId: "command", dispatchMode: "queue" }), + ]); + expect(remaining()).toEqual([]); + }); + + it("holds the message when its /compact cannot be sent", async () => { + setOperateGrant("secondary", true); + const queued = message({ compactBeforeSend: true, dispatchMode: "queue" }); + await state.manager.enqueue(queued); + state.start.mockResolvedValueOnce( + AsyncResult.failure(Cause.fail(new Error("Socket is not connected"))), + ); + await runDrain(); + expect(state.start).toHaveBeenCalledOnce(); + expect(state.start.mock.calls[0]?.[0].input).toMatchObject(compactTurn); + expect(remaining()).toEqual([queued]); + }); +}); diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index 4a20db3b9e965..2431f6b060ad7 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -16,9 +16,9 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + MessageId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type EnvironmentId, - type MessageId, } from "@t3tools/contracts"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; @@ -864,6 +864,37 @@ export function createThreadOutboxDelivery(): { settings, currentConfig.providers, ); + if (queuedMessage.compactBeforeSend === true) { + if (!hasAccess()) return true; + // Queued, since steering a running turn with a compaction is rejected. + const compactionResult = await runDeliveryCommand(threadEnvironment.startTurn, { + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "compact"), + creationSource: "mobile", + threadId: queuedMessage.threadId, + message: { + messageId: MessageId.make(`${queuedMessage.messageId}:compact`), + role: "user", + text: "/compact", + attachments: [], + idleCompaction: "onReturn", + }, + modelSelection: sendSettings.modelSelection, + runtimeMode: sendSettings.runtimeMode, + interactionMode: sendSettings.interactionMode, + createdAt: queuedMessage.createdAt, + dispatchMode: "queue", + }, + }); + if (AsyncResult.isFailure(compactionResult) && !hasAccess()) return true; + const compactionFailure = reportCommandFailure(queuedMessage, compactionResult, "start-turn"); + if (compactionFailure?.action === "retry") return false; + if (compactionFailure?.action === "restore") { + return restoreQueuedMessage(persistedMessage, compactionFailure.message); + } + if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) return true; + } if (!hasAccess()) return true; const deliveryResult = await runDeliveryCommand(threadEnvironment.startTurn, { environmentId: queuedMessage.environmentId, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 3926b2f781f5d..dcf148be6d83d 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -3,6 +3,7 @@ import * as Dialog from "../components/ui/Dialog"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { readEnvironmentScope } from "./session"; import { useAtomValue } from "@effect/atom-react"; +import { shouldCompactBeforeSend } from "@t3tools/client-runtime/state/context-window"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; @@ -656,6 +657,25 @@ export function useThreadComposerState() { : "auto"; const metadata = makeQueuedMessageMetadata(); + const compactBeforeSend = + followUpDispatchMode === null && + selectedEnvironmentRuntime?.connectionState === "connected" && + selectedThreadProjection !== null && + serverConfig != null && + provider !== undefined && + shouldCompactBeforeSend({ + projection: selectedThreadProjection.projection, + visibleTurnItems: selectedThreadVisibleTurnItems, + provider, + settings: serverConfig.settings, + text, + threadIdle: + !activeThreadBusy && + !runlessWorkActive && + !isCompacting && + selectedThreadQueuedMessages.length === 0, + now: metadata.createdAt, + }); const messageId = MessageId.make(metadata.messageId); clearThreadComposerError(threadKey); const enqueuePromise = enqueueThreadOutboxMessage({ @@ -672,7 +692,11 @@ export function useThreadComposerState() { provider, draft.interactionMode ?? thread.interactionMode, ), - ...(followUpDispatchMode === null ? {} : { dispatchMode: followUpDispatchMode }), + ...(compactBeforeSend + ? { compactBeforeSend, dispatchMode: "queue" as const } + : followUpDispatchMode === null + ? {} + : { dispatchMode: followUpDispatchMode }), createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 2ba094c23e1ac..77e3c6355431b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -4,6 +4,7 @@ import { deriveThreadRuntime, } from "@t3tools/client-runtime/state/thread-execution"; import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; +import { COMPACT_COMMAND_PATTERN } from "@t3tools/client-runtime/state/context-window"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { latestUnheldRun, @@ -833,10 +834,6 @@ export function shouldShowPlanFollowUpPrompt(input: { ); } -// A typed `/compact`, with or without instructions, is already the compaction. Queueing a -// bare one ahead of it would run an uninstructed compaction first and charge for two. -const COMPACT_COMMAND_PATTERN = /^\/compact(?:\s|$)/i; - const sessionDismissedBranchMismatchKeys = new Set(); export function dismissBranchMismatchForSession(key: string): void { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 63ff8b8463eee..feffd9f5c0eb4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -310,15 +310,13 @@ import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/Compos import { modelFallbackBannerItem } from "./chat/ComposerUsageLimits"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; -import { - hasAvailableCompactionProvider, - hasDismissedResumeCompaction, - shouldOfferResumeCompaction, -} from "./chat/ContextWindowMeter.logic"; +import { hasAvailableCompactionProvider } from "./chat/ContextWindowMeter.logic"; import { deriveLatestContextWindowSnapshot, + hasDismissedResumeCompaction, latestProviderTurnTokenUsage, -} from "../lib/contextWindow"; + shouldOfferResumeCompaction, +} from "@t3tools/client-runtime/state/context-window"; import { MOBILE_COMPOSER_VIEW_TRANSITION_NAME, MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c4963ad73ba3a..63a460d8c7832 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -250,7 +250,7 @@ import { resolveComposerDispatchMode, type ComposerDispatchMode, } from "@t3tools/client-runtime/state/composer-dispatch"; -import type { ContextWindowSnapshot } from "../../lib/contextWindow"; +import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window"; import { resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, diff --git a/apps/web/src/components/chat/ComposerFooterControls.tsx b/apps/web/src/components/chat/ComposerFooterControls.tsx index 46de66194dd0d..e17d86dac8ecd 100644 --- a/apps/web/src/components/chat/ComposerFooterControls.tsx +++ b/apps/web/src/components/chat/ComposerFooterControls.tsx @@ -8,7 +8,7 @@ import { createModelSelection } from "@t3tools/shared/model"; import { BotIcon, PencilRulerIcon } from "lucide-react"; import { type ComponentProps, memo, useId, useRef, useState } from "react"; -import type { ContextWindowSnapshot } from "../../lib/contextWindow"; +import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window"; import { Menu as MenuPrimitive } from "@base-ui/react/menu"; import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts index e8d08570707bf..c9131276bffb5 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts @@ -1,9 +1,9 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { deriveProviderInstanceEntries } from "../../providerInstances"; +import { hasDismissedResumeCompaction } from "@t3tools/client-runtime/state/context-window"; import { hasAvailableCompactionProvider, - hasDismissedResumeCompaction, resolveContextWindowModelDisplayName, shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index 5a0911a10a05f..1eb9668bc3397 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -1,18 +1,10 @@ import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; -import { - CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, - COMPACTION_SUGGESTION_MIN_TOKENS, - isClaudeResumeCompactionQuestion, -} from "@t3tools/shared/claudeCompaction"; import { resolveSelectableProviderInstanceEntry, type ProviderInstanceEntry, } from "../../providerInstances"; import { getDisplayModelName, type ModelEsque } from "./providerIconUtils"; -// Fallback TTL: Claude's 1h cache plus slack. -const CLAUDE_RESUME_COMPACTION_FALLBACK_MS = 70 * 60_000; - export function providerSupportsManualCompaction( provider: ProviderInstanceEntry | null | undefined, ): boolean { @@ -43,47 +35,6 @@ export function hasAvailableCompactionProvider(input: { ); } -export function hasDismissedResumeCompaction( - activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, -): boolean { - return activities.some((activity) => { - if (activity.kind !== "user-input.resolved") return false; - const payload = activity.payload; - if (!payload || typeof payload !== "object") return false; - const answers = (payload as { readonly answers?: unknown }).answers; - if (!answers || typeof answers !== "object" || Array.isArray(answers)) return false; - - return Object.entries(answers).some( - ([question, answer]) => - isClaudeResumeCompactionQuestion(question) && - answer === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, - ); - }); -} - -export function shouldOfferResumeCompaction(input: { - readonly provider: string | null | undefined; - readonly usedTokens: number | null | undefined; - readonly updatedAt: string | null | undefined; - readonly promptCacheTtlMs?: number | undefined; - readonly now: string; -}): boolean { - if ( - input.provider !== "claudeAgent" || - (input.usedTokens ?? 0) < COMPACTION_SUGGESTION_MIN_TOKENS - ) { - return false; - } - - const updatedAt = Date.parse(input.updatedAt ?? ""); - const now = Date.parse(input.now); - return ( - Number.isFinite(updatedAt) && - Number.isFinite(now) && - now - updatedAt >= (input.promptCacheTtlMs ?? CLAUDE_RESUME_COMPACTION_FALLBACK_MS) - ); -} - export function resolveContextWindowModelDisplayName( selection: ModelSelection | null | undefined, modelOptionsByInstance: ReadonlyMap>, diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index e7f4042f53b90..9f2c2dbc91863 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,5 +1,6 @@ import { Button } from "../ui/button"; -import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; +import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window"; +import { formatContextWindowTokens } from "~/lib/contextWindow"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { formatContextWindowCompactionMessage, diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx index e0f5c3afbe845..fa3b0cc04742f 100644 --- a/apps/web/src/components/chat/ThreadDetailsUsage.tsx +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -16,7 +16,8 @@ import type { ReactNode } from "react"; import { usePrimarySettings } from "../../hooks/useSettings"; import { useNowMinute } from "../../hooks/useNowMinute"; -import { type ContextWindowSnapshot, formatContextWindowTokens } from "../../lib/contextWindow"; +import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window"; +import { formatContextWindowTokens } from "../../lib/contextWindow"; import { cn } from "../../lib/utils"; import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index c27ddd9a04656..7d49d24a73fe8 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -11,8 +11,11 @@ import { ThreadId, } from "@t3tools/contracts"; import { makeThreadProjectionFixture } from "../test-fixtures"; -import { shouldOfferResumeCompaction } from "../components/chat/ContextWindowMeter.logic"; -import { deriveLatestContextWindowSnapshot, latestProviderTurnTokenUsage } from "./contextWindow"; +import { + deriveLatestContextWindowSnapshot, + latestProviderTurnTokenUsage, + shouldOfferResumeCompaction, +} from "@t3tools/client-runtime/state/context-window"; const decodeTokenUsage = Schema.decodeSync(OrchestrationV2ProviderTurnTokenUsage); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 8c449249e0330..1cf3eee799bb2 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -1,179 +1,3 @@ -import type { ThreadStructure, ThreadStructureTurnItem } from "@t3tools/client-runtime/state/shell"; -import type { - OrchestrationV2ProviderTurn, - OrchestrationV2ProviderTurnTokenUsage, - OrchestrationV2ProviderThread, - ThreadTokenUsageSnapshot, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; - -function asFiniteNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - -type NullableContextWindowUsage = { - readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key] - ? Exclude | null - : ThreadTokenUsageSnapshot[Key]; -}; - -export type ContextWindowSnapshot = NullableContextWindowUsage & { - readonly remainingTokens: number | null; - readonly usedPercentage: number | null; - readonly remainingPercentage: number | null; - readonly updatedAt: string; -}; - -/** Newest report by timestamp; snapshots order turns by provider thread. */ -export function latestProviderTurnTokenUsage( - turns: ReadonlyArray>, - projection?: Pick | null, - providerThread?: Pick | null, -): OrchestrationV2ProviderTurnTokenUsage | null { - let latest: OrchestrationV2ProviderTurnTokenUsage | null = null; - for (const { tokenUsage } of turns) { - if (tokenUsage === undefined) continue; - // Ties go to the later entry, since live upserts append. - if (latest === null || Date.parse(tokenUsage.updatedAt) >= Date.parse(latest.updatedAt)) { - latest = tokenUsage; - } - } - if (latest !== null || projection == null || providerThread?.contextUsage != null) return latest; - const forkedFrom = projection.thread.forkedFrom; - if (forkedFrom?.type !== "run") return null; - return ( - projection.contextTransfers.find( - (transfer) => - transfer.type === "fork" && - transfer.targetThreadId === projection.thread.id && - transfer.sourceThreadId === forkedFrom.threadId && - transfer.sourcePoint.runId === forkedFrom.runId && - transfer.sourceProviderInstanceId === projection.thread.modelSelection.instanceId && - (transfer.status === "pending" || transfer.resolution?.strategy === "native_fork"), - )?.sourceTokenUsage ?? null - ); -} - -export function deriveLatestContextWindowSnapshot( - entries: ReadonlyArray<{ - readonly item: ThreadStructureTurnItem; - }>, - liveUsage?: OrchestrationV2ProviderTurnTokenUsage | null, - providerThread?: Pick | null, -): ContextWindowSnapshot | null { - if (liveUsage != null) { - const usedTokens = Math.max(0, liveUsage.usedTokens); - const maxTokens = liveUsage.maxTokens ?? null; - const usedPercentage = - maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; - const remainingTokens = - maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; - const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null; - return { - usedTokens, - totalProcessedTokens: null, - maxTokens, - remainingTokens, - usedPercentage, - remainingPercentage, - inputTokens: liveUsage.inputTokens ?? null, - cachedInputTokens: liveUsage.cachedInputTokens ?? null, - outputTokens: liveUsage.outputTokens ?? null, - reasoningOutputTokens: liveUsage.reasoningOutputTokens ?? null, - lastUsedTokens: null, - lastInputTokens: null, - lastCachedInputTokens: null, - lastOutputTokens: null, - lastReasoningOutputTokens: null, - toolUses: null, - durationMs: null, - compactsAutomatically: true, - autoCompactThreshold: liveUsage.autoCompactThreshold ?? null, - cost: null, - updatedAt: liveUsage.updatedAt, - }; - } - const providerUsage = providerThread?.contextUsage; - const providerUsageUpdatedAt = providerThread?.updatedAt; - if ( - providerUsage !== null && - providerUsage !== undefined && - providerUsageUpdatedAt !== undefined - ) { - const maxTokens = asFiniteNumber(providerUsage.maxTokens); - const usedTokens = providerUsage.usedTokens; - const usedPercentage = - maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; - return { - usedTokens, - totalProcessedTokens: asFiniteNumber(providerUsage.totalProcessedTokens), - maxTokens, - remainingTokens: maxTokens === null ? null : Math.max(0, Math.round(maxTokens - usedTokens)), - usedPercentage, - remainingPercentage: usedPercentage === null ? null : Math.max(0, 100 - usedPercentage), - inputTokens: asFiniteNumber(providerUsage.inputTokens), - cachedInputTokens: asFiniteNumber(providerUsage.cachedInputTokens), - outputTokens: asFiniteNumber(providerUsage.outputTokens), - reasoningOutputTokens: asFiniteNumber(providerUsage.reasoningOutputTokens), - lastUsedTokens: asFiniteNumber(providerUsage.lastUsedTokens), - lastInputTokens: asFiniteNumber(providerUsage.lastInputTokens), - lastCachedInputTokens: asFiniteNumber(providerUsage.lastCachedInputTokens), - lastOutputTokens: asFiniteNumber(providerUsage.lastOutputTokens), - lastReasoningOutputTokens: asFiniteNumber(providerUsage.lastReasoningOutputTokens), - toolUses: asFiniteNumber(providerUsage.toolUses), - durationMs: asFiniteNumber(providerUsage.durationMs), - compactsAutomatically: providerUsage.compactsAutomatically ?? null, - cost: providerUsage.cost ?? null, - autoCompactThreshold: providerUsage.autoCompactThreshold ?? null, - updatedAt: DateTime.formatIso(providerUsageUpdatedAt), - }; - } - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (!entry || entry.item.type !== "compaction") { - continue; - } - const payload = entry.item; - const usedTokens = asFiniteNumber(payload.afterTokenCount); - if (usedTokens === null || usedTokens < 0) { - continue; - } - - const maxTokens = null; - const usedPercentage = - maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; - const remainingTokens = - maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; - const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null; - - return { - usedTokens, - totalProcessedTokens: asFiniteNumber(payload.beforeTokenCount), - maxTokens, - remainingTokens, - usedPercentage, - remainingPercentage, - inputTokens: null, - cachedInputTokens: null, - outputTokens: null, - reasoningOutputTokens: null, - lastUsedTokens: null, - lastInputTokens: null, - lastCachedInputTokens: null, - lastOutputTokens: null, - lastReasoningOutputTokens: null, - toolUses: null, - durationMs: null, - compactsAutomatically: true, - autoCompactThreshold: null, - cost: null, - updatedAt: DateTime.formatIso(payload.startedAt ?? payload.updatedAt), - }; - } - - return null; -} - export function formatContextWindowTokens(value: number | null): string { if (value === null || !Number.isFinite(value)) { return "0"; diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 206d82ce32333..5be445aebf926 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -53,7 +53,8 @@ You can also send `/compact` in an existing conversation. Web and desktop offer after Claude's prompt cache expires, a **Compact** chip with the thread's token count shows next to the send button. While it is on, Enter summarizes the history first, then sends your message. Click the chip to switch it to **Full** and keep the full -history for that message. The cache lasts about an hour on a Claude +history for that message. The mobile app shows no chip: a message sent from it always +summarizes first. The cache lasts about an hour on a Claude subscription and 5 minutes with an API key, Bedrock, or Vertex. See [commands and skills](./composer.md#commands-and-skills) for using composer commands. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d1b48e1d76f4f..eadd5f744c24e 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -355,6 +355,10 @@ "types": "./src/state/composerDispatch.ts", "default": "./src/state/composerDispatch.ts" }, + "./state/context-window": { + "types": "./src/state/contextWindow.ts", + "default": "./src/state/contextWindow.ts" + }, "./state/thread-subagents": { "types": "./src/state/threadSubagents.ts", "default": "./src/state/threadSubagents.ts" diff --git a/packages/client-runtime/src/state/contextWindow.test.ts b/packages/client-runtime/src/state/contextWindow.test.ts new file mode 100644 index 0000000000000..85689b7c0c4ac --- /dev/null +++ b/packages/client-runtime/src/state/contextWindow.test.ts @@ -0,0 +1,140 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type OrchestrationV2ThreadProjection, + type ServerSettings, +} from "@t3tools/contracts"; +import { + CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + formatClaudeResumeCompactionQuestion, +} from "@t3tools/shared/claudeCompaction"; +import { describe, expect, it } from "vite-plus/test"; + +import { shouldCompactBeforeSend } from "./contextWindow.ts"; +import { v2Projection } from "./orchestrationV2TestFixtures.ts"; + +const claude = ProviderInstanceId.make("claudeAgent"); +const lastTurnAt = "2026-10-30T14:23:02.000Z"; +const returnedAt = "2026-10-30T23:03:21.000Z"; + +function sendInput( + overrides: { + readonly usedTokens?: number; + readonly promptCacheTtlMs?: number; + readonly now?: string; + readonly settings?: Partial; + readonly driver?: string; + readonly slashCommands?: ReadonlyArray; + readonly dismissed?: boolean; + readonly heldQueue?: boolean; + readonly pendingRequest?: boolean; + readonly onlyCompactMessages?: boolean; + readonly text?: string; + readonly threadIdle?: boolean; + } = {}, +) { + const projection = { + ...v2Projection, + providerTurns: [ + { + tokenUsage: { + usedTokens: overrides.usedTokens ?? 293_611, + updatedAt: lastTurnAt, + ...(overrides.promptCacheTtlMs === undefined + ? {} + : { promptCacheTtlMs: overrides.promptCacheTtlMs }), + }, + }, + ], + runs: + overrides.heldQueue === true ? [{ id: "queued-run", status: "queued", queueHeld: true }] : [], + runtimeRequests: [ + ...(overrides.dismissed === true + ? [ + { + kind: "user_input", + status: "resolved", + answers: { + [formatClaudeResumeCompactionQuestion({ + ageMinutes: 90, + estimatedTokens: 250_000, + })]: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + }, + }, + ] + : []), + ...(overrides.pendingRequest === true ? [{ kind: "approval", status: "pending" }] : []), + ], + } as unknown as OrchestrationV2ThreadProjection; + return { + projection, + visibleTurnItems: [ + { + item: { + type: "user_message", + text: overrides.onlyCompactMessages === true ? "/compact" : "Fix the build", + attachments: [], + }, + }, + ] as never, + provider: { + instanceId: claude, + driver: ProviderDriverKind.make(overrides.driver ?? "claudeAgent"), + slashCommands: (overrides.slashCommands ?? ["compact"]).map((name) => ({ name })), + }, + settings: { ...DEFAULT_SERVER_SETTINGS, ...overrides.settings }, + text: overrides.text ?? "back again", + threadIdle: overrides.threadIdle ?? true, + now: overrides.now ?? returnedAt, + }; +} + +describe("shouldCompactBeforeSend", () => { + it.each([ + { name: "a stale Claude thread over 200K", expected: true }, + { + name: "when the environment compacts before the cache expires", + settings: { idleCompaction: "beforeCacheExpires" }, + expected: true, + }, + { + name: "past a reported TTL shorter than the fallback", + promptCacheTtlMs: 5 * 60_000, + now: "2026-10-30T14:30:00.000Z", + expected: true, + }, + { name: "within the fallback TTL", now: "2026-10-30T15:23:02.000Z", expected: false }, + { name: "under 200K", usedTokens: 150_000, expected: false }, + { name: "with idle compaction off", settings: { idleCompaction: "off" }, expected: false }, + { + name: "with the Claude instance overriding idle compaction to off", + settings: { + idleCompaction: "onReturn", + providerInstances: { + [claude]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { idleCompaction: "off" }, + }, + }, + }, + expected: false, + }, + { name: "on a Codex thread", driver: "codex", expected: false }, + { name: "for a provider without /compact", slashCommands: [], expected: false }, + { + name: "after Claude's resume dialog was answered Don't ask again", + dismissed: true, + expected: false, + }, + { name: "while the queue is held", heldQueue: true, expected: false }, + { name: "while a request waits for an answer", pendingRequest: true, expected: false }, + { name: "while the thread is working", threadIdle: false, expected: false }, + { name: "with no conversation but /compact", onlyCompactMessages: true, expected: false }, + { name: "for a typed /compact", text: "/compact keep the plan", expected: false }, + ] as const)("$name", ({ name: _name, expected, ...overrides }) => { + expect(shouldCompactBeforeSend(sendInput(overrides as Parameters[0]))).toBe( + expected, + ); + }); +}); diff --git a/packages/client-runtime/src/state/contextWindow.ts b/packages/client-runtime/src/state/contextWindow.ts new file mode 100644 index 0000000000000..152e87c0fbbd4 --- /dev/null +++ b/packages/client-runtime/src/state/contextWindow.ts @@ -0,0 +1,298 @@ +import { + resolveIdleCompaction, + type OrchestrationV2ProviderTurn, + type OrchestrationV2ProviderTurnTokenUsage, + type OrchestrationV2ProviderThread, + type OrchestrationV2ThreadProjection, + type ServerProvider, + type ServerSettings, + type ThreadTokenUsageSnapshot, +} from "@t3tools/contracts"; +import { + CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + COMPACTION_SUGGESTION_MIN_TOKENS, + isClaudeResumeCompactionQuestion, +} from "@t3tools/shared/claudeCompaction"; +import * as DateTime from "effect/DateTime"; + +import type { ThreadStructure, ThreadStructureTurnItem } from "./models.ts"; + +// Fallback TTL: Claude's 1h cache plus slack. +const CLAUDE_RESUME_COMPACTION_FALLBACK_MS = 70 * 60_000; + +// A typed `/compact`, with or without instructions, is already the compaction. Queueing a +// bare one ahead of it would run an uninstructed compaction first and charge for two. +export const COMPACT_COMMAND_PATTERN = /^\/compact(?:\s|$)/i; + +function asFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +type NullableContextWindowUsage = { + readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key] + ? Exclude | null + : ThreadTokenUsageSnapshot[Key]; +}; + +export type ContextWindowSnapshot = NullableContextWindowUsage & { + readonly remainingTokens: number | null; + readonly usedPercentage: number | null; + readonly remainingPercentage: number | null; + readonly updatedAt: string; +}; + +/** Newest report by timestamp; snapshots order turns by provider thread. */ +export function latestProviderTurnTokenUsage( + turns: ReadonlyArray>, + projection?: Pick | null, + providerThread?: Pick | null, +): OrchestrationV2ProviderTurnTokenUsage | null { + let latest: OrchestrationV2ProviderTurnTokenUsage | null = null; + for (const { tokenUsage } of turns) { + if (tokenUsage === undefined) continue; + // Ties go to the later entry, since live upserts append. + if (latest === null || Date.parse(tokenUsage.updatedAt) >= Date.parse(latest.updatedAt)) { + latest = tokenUsage; + } + } + if (latest !== null || projection == null || providerThread?.contextUsage != null) return latest; + const forkedFrom = projection.thread.forkedFrom; + if (forkedFrom?.type !== "run") return null; + return ( + projection.contextTransfers.find( + (transfer) => + transfer.type === "fork" && + transfer.targetThreadId === projection.thread.id && + transfer.sourceThreadId === forkedFrom.threadId && + transfer.sourcePoint.runId === forkedFrom.runId && + transfer.sourceProviderInstanceId === projection.thread.modelSelection.instanceId && + (transfer.status === "pending" || transfer.resolution?.strategy === "native_fork"), + )?.sourceTokenUsage ?? null + ); +} + +export function deriveLatestContextWindowSnapshot( + entries: ReadonlyArray<{ + readonly item: ThreadStructureTurnItem; + }>, + liveUsage?: OrchestrationV2ProviderTurnTokenUsage | null, + providerThread?: Pick | null, +): ContextWindowSnapshot | null { + if (liveUsage != null) { + const usedTokens = Math.max(0, liveUsage.usedTokens); + const maxTokens = liveUsage.maxTokens ?? null; + const usedPercentage = + maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; + const remainingTokens = + maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; + const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null; + return { + usedTokens, + totalProcessedTokens: null, + maxTokens, + remainingTokens, + usedPercentage, + remainingPercentage, + inputTokens: liveUsage.inputTokens ?? null, + cachedInputTokens: liveUsage.cachedInputTokens ?? null, + outputTokens: liveUsage.outputTokens ?? null, + reasoningOutputTokens: liveUsage.reasoningOutputTokens ?? null, + lastUsedTokens: null, + lastInputTokens: null, + lastCachedInputTokens: null, + lastOutputTokens: null, + lastReasoningOutputTokens: null, + toolUses: null, + durationMs: null, + compactsAutomatically: true, + autoCompactThreshold: liveUsage.autoCompactThreshold ?? null, + cost: null, + updatedAt: liveUsage.updatedAt, + }; + } + const providerUsage = providerThread?.contextUsage; + const providerUsageUpdatedAt = providerThread?.updatedAt; + if ( + providerUsage !== null && + providerUsage !== undefined && + providerUsageUpdatedAt !== undefined + ) { + const maxTokens = asFiniteNumber(providerUsage.maxTokens); + const usedTokens = providerUsage.usedTokens; + const usedPercentage = + maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; + return { + usedTokens, + totalProcessedTokens: asFiniteNumber(providerUsage.totalProcessedTokens), + maxTokens, + remainingTokens: maxTokens === null ? null : Math.max(0, Math.round(maxTokens - usedTokens)), + usedPercentage, + remainingPercentage: usedPercentage === null ? null : Math.max(0, 100 - usedPercentage), + inputTokens: asFiniteNumber(providerUsage.inputTokens), + cachedInputTokens: asFiniteNumber(providerUsage.cachedInputTokens), + outputTokens: asFiniteNumber(providerUsage.outputTokens), + reasoningOutputTokens: asFiniteNumber(providerUsage.reasoningOutputTokens), + lastUsedTokens: asFiniteNumber(providerUsage.lastUsedTokens), + lastInputTokens: asFiniteNumber(providerUsage.lastInputTokens), + lastCachedInputTokens: asFiniteNumber(providerUsage.lastCachedInputTokens), + lastOutputTokens: asFiniteNumber(providerUsage.lastOutputTokens), + lastReasoningOutputTokens: asFiniteNumber(providerUsage.lastReasoningOutputTokens), + toolUses: asFiniteNumber(providerUsage.toolUses), + durationMs: asFiniteNumber(providerUsage.durationMs), + compactsAutomatically: providerUsage.compactsAutomatically ?? null, + cost: providerUsage.cost ?? null, + autoCompactThreshold: providerUsage.autoCompactThreshold ?? null, + updatedAt: DateTime.formatIso(providerUsageUpdatedAt), + }; + } + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (!entry || entry.item.type !== "compaction") { + continue; + } + const payload = entry.item; + const usedTokens = asFiniteNumber(payload.afterTokenCount); + if (usedTokens === null || usedTokens < 0) { + continue; + } + + const maxTokens = null; + const usedPercentage = + maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; + const remainingTokens = + maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; + const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null; + + return { + usedTokens, + totalProcessedTokens: asFiniteNumber(payload.beforeTokenCount), + maxTokens, + remainingTokens, + usedPercentage, + remainingPercentage, + inputTokens: null, + cachedInputTokens: null, + outputTokens: null, + reasoningOutputTokens: null, + lastUsedTokens: null, + lastInputTokens: null, + lastCachedInputTokens: null, + lastOutputTokens: null, + lastReasoningOutputTokens: null, + toolUses: null, + durationMs: null, + compactsAutomatically: true, + autoCompactThreshold: null, + cost: null, + updatedAt: DateTime.formatIso(payload.startedAt ?? payload.updatedAt), + }; + } + + return null; +} + +export function hasDismissedResumeCompaction( + activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, +): boolean { + return activities.some((activity) => { + if (activity.kind !== "user-input.resolved") return false; + const payload = activity.payload; + if (!payload || typeof payload !== "object") return false; + const answers = (payload as { readonly answers?: unknown }).answers; + if (!answers || typeof answers !== "object" || Array.isArray(answers)) return false; + + return Object.entries(answers).some( + ([question, answer]) => + isClaudeResumeCompactionQuestion(question) && + answer === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + ); + }); +} + +export function shouldOfferResumeCompaction(input: { + readonly provider: string | null | undefined; + readonly usedTokens: number | null | undefined; + readonly updatedAt: string | null | undefined; + readonly promptCacheTtlMs?: number | undefined; + readonly now: string; +}): boolean { + if ( + input.provider !== "claudeAgent" || + (input.usedTokens ?? 0) < COMPACTION_SUGGESTION_MIN_TOKENS + ) { + return false; + } + + const updatedAt = Date.parse(input.updatedAt ?? ""); + const now = Date.parse(input.now); + return ( + Number.isFinite(updatedAt) && + Number.isFinite(now) && + now - updatedAt >= (input.promptCacheTtlMs ?? CLAUDE_RESUME_COMPACTION_FALLBACK_MS) + ); +} + +/** + * Whether a plain send into an idle thread compacts it first, as web's composer does under + * "When you return". Web reads the same state for its "Compact and send" button. + */ +export function shouldCompactBeforeSend(input: { + readonly projection: Pick< + OrchestrationV2ThreadProjection, + "thread" | "contextTransfers" | "providerThreads" | "providerTurns" | "runtimeRequests" | "runs" + >; + readonly visibleTurnItems: ReadonlyArray<{ readonly item: ThreadStructureTurnItem }>; + readonly provider: Pick; + readonly settings: Pick< + ServerSettings, + "providerInstances" | "compactIdleThreads" | "idleCompaction" + >; + readonly text: string; + readonly threadIdle: boolean; + readonly now: string; +}): boolean { + const { projection } = input; + if ( + !input.threadIdle || + COMPACT_COMMAND_PATTERN.test(input.text) || + resolveIdleCompaction(input.settings, input.provider.instanceId) === "off" || + !input.provider.slashCommands.some((command) => command.name === "compact") || + projection.runtimeRequests.some((request) => request.status === "pending") || + projection.runs.some((run) => run.status === "queued" && run.queueHeld === true) || + !input.visibleTurnItems.some( + ({ item }) => + item.type === "user_message" && + (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0), + ) || + hasDismissedResumeCompaction( + projection.runtimeRequests + .filter((request) => request.kind === "user_input" && request.status === "resolved") + .map((request) => ({ kind: "user-input.resolved", payload: { answers: request.answers } })), + ) + ) { + return false; + } + const providerThread = projection.providerThreads.find( + (thread) => thread.id === projection.thread.activeProviderThreadId, + ); + const liveUsage = latestProviderTurnTokenUsage( + projection.providerTurns, + projection, + providerThread, + ); + const contextWindow = deriveLatestContextWindowSnapshot( + input.visibleTurnItems, + liveUsage, + providerThread, + ); + return ( + contextWindow !== null && + shouldOfferResumeCompaction({ + provider: input.provider.driver, + usedTokens: contextWindow.usedTokens, + updatedAt: contextWindow.updatedAt, + promptCacheTtlMs: liveUsage?.promptCacheTtlMs, + now: input.now, + }) + ); +} From 6a8c20c835b96a9fee44a9d9e45cb26c9faae8d4 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 31 Oct 2026 01:20:34 +0000 Subject: [PATCH 2/4] fix(mobile): a send that hands off no longer compacts the old conversation shouldCompactBeforeSend checked the selected provider against token usage from every provider turn in the thread. A stale Codex thread at 293K switched to Claude compacted first, though the send hands off to a fresh Claude conversation, and an earlier conversation's usage could stand in for the resumed one. It now takes the selection and provider list, returns false when resolvePendingHandoff reports a handoff (so instances sharing a continuation group still compact), and reads usage only from the active provider thread's turns. The eligibility table moves to a Claude fixture with runs, provider threads and continuation groups. Co-Authored-By: Claude Opus 5.5 --- .../src/state/use-thread-composer-state.ts | 4 +- packages/client-runtime/src/handoff.ts | 2 +- .../src/state/contextWindow.test.ts | 121 +++++++++++++++--- .../client-runtime/src/state/contextWindow.ts | 33 +++-- 4 files changed, 128 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index dcf148be6d83d..8132d76f6a000 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -662,11 +662,11 @@ export function useThreadComposerState() { selectedEnvironmentRuntime?.connectionState === "connected" && selectedThreadProjection !== null && serverConfig != null && - provider !== undefined && shouldCompactBeforeSend({ projection: selectedThreadProjection.projection, visibleTurnItems: selectedThreadVisibleTurnItems, - provider, + selection: modelSelection, + providers: serverConfig.providers, settings: serverConfig.settings, text, threadIdle: diff --git a/packages/client-runtime/src/handoff.ts b/packages/client-runtime/src/handoff.ts index a26c018ad9cdf..64794bd17e486 100644 --- a/packages/client-runtime/src/handoff.ts +++ b/packages/client-runtime/src/handoff.ts @@ -16,7 +16,7 @@ export type HandoffTimelineRun = Pick< export type HandoffEndpoints = ReturnType; export function resolvePendingHandoff( - projection: ThreadStructure | null | undefined, + projection: Pick | null | undefined, selection: ModelSelection | null | undefined, providers: ReadonlyArray, ): HandoffEndpoints | null { diff --git a/packages/client-runtime/src/state/contextWindow.test.ts b/packages/client-runtime/src/state/contextWindow.test.ts index 85689b7c0c4ac..ab551b8aca4f5 100644 --- a/packages/client-runtime/src/state/contextWindow.test.ts +++ b/packages/client-runtime/src/state/contextWindow.test.ts @@ -2,19 +2,26 @@ import { DEFAULT_SERVER_SETTINGS, ProviderDriverKind, ProviderInstanceId, - type OrchestrationV2ThreadProjection, + ProviderThreadId, + RunId, + type ServerProvider, type ServerSettings, } from "@t3tools/contracts"; import { CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, formatClaudeResumeCompactionQuestion, } from "@t3tools/shared/claudeCompaction"; +import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; import { shouldCompactBeforeSend } from "./contextWindow.ts"; +import type { ThreadStructure } from "./models.ts"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; const claude = ProviderInstanceId.make("claudeAgent"); +const claudeWork = ProviderInstanceId.make("claude_work"); +const claudeOtherHome = ProviderInstanceId.make("claude_other_home"); +const codex = ProviderInstanceId.make("codex"); const lastTurnAt = "2026-10-30T14:23:02.000Z"; const returnedAt = "2026-10-30T23:03:21.000Z"; @@ -24,7 +31,9 @@ function sendInput( readonly promptCacheTtlMs?: number; readonly now?: string; readonly settings?: Partial; - readonly driver?: string; + readonly source?: ProviderInstanceId; + readonly selected?: ProviderInstanceId; + readonly onlyEarlierConversationUsage?: boolean; readonly slashCommands?: ReadonlyArray; readonly dismissed?: boolean; readonly heldQueue?: boolean; @@ -34,10 +43,57 @@ function sendInput( readonly threadIdle?: boolean; } = {}, ) { + const source = overrides.source ?? claude; + const sourceSelection = { + instanceId: source, + model: source === codex ? "gpt-5.4" : "claude-opus-4-6", + }; + const providers = [ + { instanceId: claude, driver: "claudeAgent", groupKey: "claude:~/.claude" }, + { instanceId: claudeWork, driver: "claudeAgent", groupKey: "claude:~/.claude" }, + { instanceId: claudeOtherHome, driver: "claudeAgent", groupKey: "claude:~/.claude-other" }, + { instanceId: codex, driver: "codex", groupKey: "codex:~/.codex" }, + ].map(({ instanceId, driver, groupKey }) => ({ + instanceId, + driver: ProviderDriverKind.make(driver), + continuation: { groupKey }, + slashCommands: (overrides.slashCommands ?? ["compact"]).map((name) => ({ name })), + })) as unknown as ReadonlyArray; + const providerThread = (id: string) => ({ + id: ProviderThreadId.make(id), + driver: providers.find((provider) => provider.instanceId === source)!.driver, + providerInstanceId: source, + contextUsage: null, + updatedAt: DateTime.makeUnsafe(lastTurnAt), + }); + const visibleTurnItems = [ + { + item: { + type: "user_message", + text: overrides.onlyCompactMessages === true ? "/compact" : "Fix the build", + attachments: [], + }, + }, + ]; const projection = { ...v2Projection, + thread: { + ...v2Projection.thread, + providerInstanceId: source, + modelSelection: sourceSelection, + activeProviderThreadId: ProviderThreadId.make("provider-thread-current"), + }, + providerThreads: [ + providerThread("provider-thread-earlier"), + providerThread("provider-thread-current"), + ], providerTurns: [ { + providerThreadId: ProviderThreadId.make( + overrides.onlyEarlierConversationUsage === true + ? "provider-thread-earlier" + : "provider-thread-current", + ), tokenUsage: { usedTokens: overrides.usedTokens ?? 293_611, updatedAt: lastTurnAt, @@ -47,8 +103,20 @@ function sendInput( }, }, ], - runs: - overrides.heldQueue === true ? [{ id: "queued-run", status: "queued", queueHeld: true }] : [], + runs: [ + { + id: RunId.make("run-1"), + ordinal: 1, + providerInstanceId: source, + modelSelection: sourceSelection, + status: "completed", + startedAt: "2026-10-30T14:20:00.000Z", + contextHandoffId: null, + }, + ...(overrides.heldQueue === true + ? [{ id: RunId.make("queued-run"), status: "queued", queueHeld: true, startedAt: null }] + : []), + ], runtimeRequests: [ ...(overrides.dismissed === true ? [ @@ -66,23 +134,17 @@ function sendInput( : []), ...(overrides.pendingRequest === true ? [{ kind: "approval", status: "pending" }] : []), ], - } as unknown as OrchestrationV2ThreadProjection; + visibleTurnItems, + } as unknown as ThreadStructure; + const selected = overrides.selected ?? claude; return { projection, - visibleTurnItems: [ - { - item: { - type: "user_message", - text: overrides.onlyCompactMessages === true ? "/compact" : "Fix the build", - attachments: [], - }, - }, - ] as never, - provider: { - instanceId: claude, - driver: ProviderDriverKind.make(overrides.driver ?? "claudeAgent"), - slashCommands: (overrides.slashCommands ?? ["compact"]).map((name) => ({ name })), + visibleTurnItems: visibleTurnItems as never, + selection: { + instanceId: selected, + model: selected === codex ? "gpt-5.4" : "claude-opus-4-6", }, + providers, settings: { ...DEFAULT_SERVER_SETTINGS, ...overrides.settings }, text: overrides.text ?? "back again", threadIdle: overrides.threadIdle ?? true, @@ -120,7 +182,28 @@ describe("shouldCompactBeforeSend", () => { }, expected: false, }, - { name: "on a Codex thread", driver: "codex", expected: false }, + { name: "on a Codex thread", source: codex, selected: codex, expected: false }, + { + name: "after switching a stale Codex thread to Claude", + source: codex, + selected: claude, + expected: false, + }, + { + name: "after switching to a Claude instance that continues the conversation", + selected: claudeWork, + expected: true, + }, + { + name: "after switching to a Claude instance with another home", + selected: claudeOtherHome, + expected: false, + }, + { + name: "when only an earlier conversation reported usage", + onlyEarlierConversationUsage: true, + expected: false, + }, { name: "for a provider without /compact", slashCommands: [], expected: false }, { name: "after Claude's resume dialog was answered Don't ask again", diff --git a/packages/client-runtime/src/state/contextWindow.ts b/packages/client-runtime/src/state/contextWindow.ts index 152e87c0fbbd4..bb378debc4001 100644 --- a/packages/client-runtime/src/state/contextWindow.ts +++ b/packages/client-runtime/src/state/contextWindow.ts @@ -1,9 +1,9 @@ import { resolveIdleCompaction, + type ModelSelection, type OrchestrationV2ProviderTurn, type OrchestrationV2ProviderTurnTokenUsage, type OrchestrationV2ProviderThread, - type OrchestrationV2ThreadProjection, type ServerProvider, type ServerSettings, type ThreadTokenUsageSnapshot, @@ -15,6 +15,7 @@ import { } from "@t3tools/shared/claudeCompaction"; import * as DateTime from "effect/DateTime"; +import { resolvePendingHandoff } from "../handoff.ts"; import type { ThreadStructure, ThreadStructureTurnItem } from "./models.ts"; // Fallback TTL: Claude's 1h cache plus slack. @@ -234,15 +235,23 @@ export function shouldOfferResumeCompaction(input: { /** * Whether a plain send into an idle thread compacts it first, as web's composer does under - * "When you return". Web reads the same state for its "Compact and send" button. + * "When you return". Web reads the same state for its "Compact and send" button. A send that + * hands off to another conversation starts that one fresh, so only the resumed one counts. */ export function shouldCompactBeforeSend(input: { readonly projection: Pick< - OrchestrationV2ThreadProjection, - "thread" | "contextTransfers" | "providerThreads" | "providerTurns" | "runtimeRequests" | "runs" + ThreadStructure, + | "thread" + | "contextTransfers" + | "providerThreads" + | "providerTurns" + | "runtimeRequests" + | "runs" + | "visibleTurnItems" >; readonly visibleTurnItems: ReadonlyArray<{ readonly item: ThreadStructureTurnItem }>; - readonly provider: Pick; + readonly selection: ModelSelection; + readonly providers: ReadonlyArray; readonly settings: Pick< ServerSettings, "providerInstances" | "compactIdleThreads" | "idleCompaction" @@ -252,11 +261,14 @@ export function shouldCompactBeforeSend(input: { readonly now: string; }): boolean { const { projection } = input; + const provider = input.providers.find((entry) => entry.instanceId === input.selection.instanceId); if ( + provider === undefined || !input.threadIdle || COMPACT_COMMAND_PATTERN.test(input.text) || - resolveIdleCompaction(input.settings, input.provider.instanceId) === "off" || - !input.provider.slashCommands.some((command) => command.name === "compact") || + resolveIdleCompaction(input.settings, provider.instanceId) === "off" || + !provider.slashCommands.some((command) => command.name === "compact") || + resolvePendingHandoff(projection, input.selection, input.providers) !== null || projection.runtimeRequests.some((request) => request.status === "pending") || projection.runs.some((run) => run.status === "queued" && run.queueHeld === true) || !input.visibleTurnItems.some( @@ -272,11 +284,12 @@ export function shouldCompactBeforeSend(input: { ) { return false; } + const activeProviderThreadId = projection.thread.activeProviderThreadId; const providerThread = projection.providerThreads.find( - (thread) => thread.id === projection.thread.activeProviderThreadId, + (thread) => thread.id === activeProviderThreadId, ); const liveUsage = latestProviderTurnTokenUsage( - projection.providerTurns, + projection.providerTurns.filter((turn) => turn.providerThreadId === activeProviderThreadId), projection, providerThread, ); @@ -288,7 +301,7 @@ export function shouldCompactBeforeSend(input: { return ( contextWindow !== null && shouldOfferResumeCompaction({ - provider: input.provider.driver, + provider: provider.driver, usedTokens: contextWindow.usedTokens, updatedAt: contextWindow.updatedAt, promptCacheTtlMs: liveUsage?.promptCacheTtlMs, From 325930af179bca139b0b18a0038ac67473671c40 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 31 Oct 2026 01:29:05 +0000 Subject: [PATCH 3/4] fix(mobile): keep fork compaction on its source instance --- .../src/state/contextWindow.test.ts | 31 +++++++++++++++++++ .../client-runtime/src/state/contextWindow.ts | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/contextWindow.test.ts b/packages/client-runtime/src/state/contextWindow.test.ts index ab551b8aca4f5..4b4fd4fbe4d7a 100644 --- a/packages/client-runtime/src/state/contextWindow.test.ts +++ b/packages/client-runtime/src/state/contextWindow.test.ts @@ -34,6 +34,7 @@ function sendInput( readonly source?: ProviderInstanceId; readonly selected?: ProviderInstanceId; readonly onlyEarlierConversationUsage?: boolean; + readonly unstartedFork?: boolean; readonly slashCommands?: ReadonlyArray; readonly dismissed?: boolean; readonly heldQueue?: boolean; @@ -136,6 +137,29 @@ function sendInput( ], visibleTurnItems, } as unknown as ThreadStructure; + if (overrides.unstartedFork === true) { + Object.assign(projection, { + thread: { + ...projection.thread, + activeProviderThreadId: null, + forkedFrom: { type: "run", threadId: "source-thread", runId: "source-run" }, + }, + runs: [], + providerThreads: [], + providerTurns: [], + contextTransfers: [ + { + type: "fork", + targetThreadId: projection.thread.id, + sourceThreadId: "source-thread", + sourcePoint: { runId: "source-run" }, + sourceProviderInstanceId: source, + status: "pending", + sourceTokenUsage: { usedTokens: 293_611, updatedAt: lastTurnAt }, + }, + ], + }); + } const selected = overrides.selected ?? claude; return { projection, @@ -204,6 +228,13 @@ describe("shouldCompactBeforeSend", () => { onlyEarlierConversationUsage: true, expected: false, }, + { name: "an unstarted fork resuming its source instance", unstartedFork: true, expected: true }, + { + name: "an unstarted fork selecting another Claude home", + unstartedFork: true, + selected: claudeOtherHome, + expected: false, + }, { name: "for a provider without /compact", slashCommands: [], expected: false }, { name: "after Claude's resume dialog was answered Don't ask again", diff --git a/packages/client-runtime/src/state/contextWindow.ts b/packages/client-runtime/src/state/contextWindow.ts index bb378debc4001..97162e7446515 100644 --- a/packages/client-runtime/src/state/contextWindow.ts +++ b/packages/client-runtime/src/state/contextWindow.ts @@ -290,7 +290,7 @@ export function shouldCompactBeforeSend(input: { ); const liveUsage = latestProviderTurnTokenUsage( projection.providerTurns.filter((turn) => turn.providerThreadId === activeProviderThreadId), - projection, + input.selection.instanceId === projection.thread.modelSelection.instanceId ? projection : null, providerThread, ); const contextWindow = deriveLatestContextWindowSnapshot( From c3c2f919fbce55f056b8a5ff8e7876650bfae1f1 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 31 Oct 2026 01:50:15 +0000 Subject: [PATCH 4/4] fix(mobile): compact when the original prompt is paged out --- .../src/state/thread-task-permissions.test.ts | 1 + .../src/state/contextWindow.test.ts | 28 +++++++++++++------ .../client-runtime/src/state/contextWindow.ts | 12 ++++++-- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 0b529e9947805..adf72cc6527b1 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -333,6 +333,7 @@ describe("mobile handoff preview and submission", () => { auth: { status: "authenticated" as const }, availability: "available" as "available" | "unavailable", models: [{ slug: selection.model }], + slashCommands: [], }; const projectOverride = { enabled: false }; state.serverConfig = { diff --git a/packages/client-runtime/src/state/contextWindow.test.ts b/packages/client-runtime/src/state/contextWindow.test.ts index 4b4fd4fbe4d7a..3f1a4d3d41994 100644 --- a/packages/client-runtime/src/state/contextWindow.test.ts +++ b/packages/client-runtime/src/state/contextWindow.test.ts @@ -40,6 +40,7 @@ function sendInput( readonly heldQueue?: boolean; readonly pendingRequest?: boolean; readonly onlyCompactMessages?: boolean; + readonly promptOutsideWindow?: boolean; readonly text?: string; readonly threadIdle?: boolean; } = {}, @@ -67,15 +68,19 @@ function sendInput( contextUsage: null, updatedAt: DateTime.makeUnsafe(lastTurnAt), }); - const visibleTurnItems = [ - { - item: { - type: "user_message", - text: overrides.onlyCompactMessages === true ? "/compact" : "Fix the build", - attachments: [], - }, - }, - ]; + const visibleTurnItems = + overrides.promptOutsideWindow === true + ? [{ item: { type: "assistant_message", text: "Build fixed" } }] + : [ + { + item: { + type: "user_message", + runId: RunId.make("run-1"), + text: overrides.onlyCompactMessages === true ? "/compact" : "Fix the build", + attachments: [], + }, + }, + ]; const projection = { ...v2Projection, thread: { @@ -179,6 +184,11 @@ function sendInput( describe("shouldCompactBeforeSend", () => { it.each([ { name: "a stale Claude thread over 200K", expected: true }, + { + name: "when the original prompt is outside the loaded window", + promptOutsideWindow: true, + expected: true, + }, { name: "when the environment compacts before the cache expires", settings: { idleCompaction: "beforeCacheExpires" }, diff --git a/packages/client-runtime/src/state/contextWindow.ts b/packages/client-runtime/src/state/contextWindow.ts index 97162e7446515..66c48807465af 100644 --- a/packages/client-runtime/src/state/contextWindow.ts +++ b/packages/client-runtime/src/state/contextWindow.ts @@ -271,11 +271,19 @@ export function shouldCompactBeforeSend(input: { resolvePendingHandoff(projection, input.selection, input.providers) !== null || projection.runtimeRequests.some((request) => request.status === "pending") || projection.runs.some((run) => run.status === "queued" && run.queueHeld === true) || - !input.visibleTurnItems.some( + (!input.visibleTurnItems.some( ({ item }) => item.type === "user_message" && (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0), - ) || + ) && + !projection.runs.some( + (run) => + run.status !== "rolled_back" && + run.startedAt !== null && + !input.visibleTurnItems.some( + ({ item }) => item.type === "user_message" && item.runId === run.id, + ), + )) || hasDismissedResumeCompaction( projection.runtimeRequests .filter((request) => request.kind === "user_input" && request.status === "resolved")