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/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/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 89d7b68b9201b..44bd564ef8969 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -16,10 +16,10 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + MessageId, managedRuntimeModeDenial, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type EnvironmentId, - type MessageId, } from "@t3tools/contracts"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; @@ -873,6 +873,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 288d37d9f169e..9add42a950533 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"; @@ -660,6 +661,25 @@ export function useThreadComposerState() { : "auto"; const metadata = makeQueuedMessageMetadata(); + const compactBeforeSend = + followUpDispatchMode === null && + selectedEnvironmentRuntime?.connectionState === "connected" && + selectedThreadProjection !== null && + serverConfig != null && + shouldCompactBeforeSend({ + projection: selectedThreadProjection.projection, + visibleTurnItems: selectedThreadVisibleTurnItems, + selection: modelSelection, + providers: serverConfig.providers, + 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({ @@ -676,7 +696,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 93468b9e04abf..765bf03fa476d 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, @@ -834,10 +835,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 6cd1c7775d96b..c374005ea5b2c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -312,15 +312,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 aa2c52443a5cb..00be39a699edb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -252,7 +252,7 @@ import { resolveComposerDispatchMode, type ComposerDispatchMode, } from "@t3tools/client-runtime/state/composer-dispatch"; -import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; +import { deriveLatestContextWindowSnapshot } 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 cb5e78c1e8cfe..6ac13a5142879 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 05211d6ace44e..f3ee87e1b7f7b 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 0ed918a9d0580..ec7475afb48a9 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -57,7 +57,8 @@ When you return to a large thread 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 **Keep** 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: an eligible message sent +from it 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/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 new file mode 100644 index 0000000000000..3f1a4d3d41994 --- /dev/null +++ b/packages/client-runtime/src/state/contextWindow.test.ts @@ -0,0 +1,264 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + 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"; + +function sendInput( + overrides: { + readonly usedTokens?: number; + readonly promptCacheTtlMs?: number; + readonly now?: string; + readonly settings?: Partial; + readonly source?: ProviderInstanceId; + readonly selected?: ProviderInstanceId; + readonly onlyEarlierConversationUsage?: boolean; + readonly unstartedFork?: boolean; + readonly slashCommands?: ReadonlyArray; + readonly dismissed?: boolean; + readonly heldQueue?: boolean; + readonly pendingRequest?: boolean; + readonly onlyCompactMessages?: boolean; + readonly promptOutsideWindow?: boolean; + readonly text?: string; + 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 = + 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: { + ...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, + ...(overrides.promptCacheTtlMs === undefined + ? {} + : { promptCacheTtlMs: overrides.promptCacheTtlMs }), + }, + }, + ], + 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 + ? [ + { + kind: "user_input", + status: "resolved", + answers: { + [formatClaudeResumeCompactionQuestion({ + ageMinutes: 90, + estimatedTokens: 250_000, + })]: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + }, + }, + ] + : []), + ...(overrides.pendingRequest === true ? [{ kind: "approval", status: "pending" }] : []), + ], + 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, + 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, + now: overrides.now ?? returnedAt, + }; +} + +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" }, + 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", 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: "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", + 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..66c48807465af --- /dev/null +++ b/packages/client-runtime/src/state/contextWindow.ts @@ -0,0 +1,319 @@ +import { + resolveIdleCompaction, + type ModelSelection, + type OrchestrationV2ProviderTurn, + type OrchestrationV2ProviderTurnTokenUsage, + type OrchestrationV2ProviderThread, + 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 { resolvePendingHandoff } from "../handoff.ts"; +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. 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< + ThreadStructure, + | "thread" + | "contextTransfers" + | "providerThreads" + | "providerTurns" + | "runtimeRequests" + | "runs" + | "visibleTurnItems" + >; + readonly visibleTurnItems: ReadonlyArray<{ readonly item: ThreadStructureTurnItem }>; + readonly selection: ModelSelection; + readonly providers: ReadonlyArray; + readonly settings: Pick< + ServerSettings, + "providerInstances" | "compactIdleThreads" | "idleCompaction" + >; + readonly text: string; + readonly threadIdle: boolean; + 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, 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( + ({ 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") + .map((request) => ({ kind: "user-input.resolved", payload: { answers: request.answers } })), + ) + ) { + return false; + } + const activeProviderThreadId = projection.thread.activeProviderThreadId; + const providerThread = projection.providerThreads.find( + (thread) => thread.id === activeProviderThreadId, + ); + const liveUsage = latestProviderTurnTokenUsage( + projection.providerTurns.filter((turn) => turn.providerThreadId === activeProviderThreadId), + input.selection.instanceId === projection.thread.modelSelection.instanceId ? projection : null, + providerThread, + ); + const contextWindow = deriveLatestContextWindowSnapshot( + input.visibleTurnItems, + liveUsage, + providerThread, + ); + return ( + contextWindow !== null && + shouldOfferResumeCompaction({ + provider: provider.driver, + usedTokens: contextWindow.usedTokens, + updatedAt: contextWindow.updatedAt, + promptCacheTtlMs: liveUsage?.promptCacheTtlMs, + now: input.now, + }) + ); +}