diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 9ec467e81885..7f8447fcea7d 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1033,7 +1033,13 @@ describe("planComposerTurn", () => { sendEnvMode: "worktree" as const, worktreePath: null, activeThreadBranch: "main", - resumeCompactionTokens: null, + resumeCompaction: null, + destinationProviderThreadId: ProviderThreadId.make("claude-conversation"), + selectedProvider: ProviderDriverKind.make("claudeAgent"), + selectedModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, keepFullHistory: false, phase: "ready" as const, dispatchMode: "auto" as const, @@ -1055,7 +1061,15 @@ describe("planComposerTurn", () => { }); it("queues behind a compaction of a stale session, except for /compact itself", () => { - const stale = { ...base, resumeCompactionTokens: 120_000 }; + const stale = { + ...base, + resumeCompaction: { + usedTokens: 120_000, + sourceUpdatedAt: 0, + providerInstanceId: base.selectedModelSelection.instanceId, + providerThreadId: base.destinationProviderThreadId, + }, + }; for (const messageText of ["hello", "/compactfoo"]) { expect(planComposerTurn({ ...stale, messageText })).toMatchObject({ compactBeforeSend: true, @@ -1072,6 +1086,109 @@ describe("planComposerTurn", () => { } }); + it("sends directly when the live composer selects Codex with a stale Claude recommendation", () => { + const entries = deriveProviderInstanceEntries( + ["claudeAgent", "codex"].map((driver) => ({ + driver: ProviderDriverKind.make(driver), + instanceId: ProviderInstanceId.make(driver), + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + version: null, + checkedAt: now, + models: [], + slashCommands: [], + skills: [], + })), + ); + const selection = { + entries, + candidateInstanceIds: [ + ProviderInstanceId.make("codex"), + base.selectedModelSelection.instanceId, + ], + lockedInstanceId: base.selectedModelSelection.instanceId, + }; + // ChatView keeps the conversation lock; the composer unlocks it for provider handoff. + const parent = resolveComposerProviderSelection({ + ...selection, + lockedProvider: base.selectedProvider, + }); + const composer = resolveComposerProviderSelection({ ...selection, lockedProvider: null }); + const staleParent = { + ...base, + resumeCompaction: { + usedTokens: 120_000, + sourceUpdatedAt: 0, + providerInstanceId: parent.selectedProviderEntry!.instanceId, + providerThreadId: ProviderThreadId.make("claude-conversation"), + }, + destinationProviderThreadId: ProviderThreadId.make("claude-conversation"), + }; + const liveSendContext = { + selectedProvider: composer.selectedProviderEntry!.driverKind, + selectedModelSelection: { + instanceId: composer.selectedProviderEntry!.instanceId, + model: "gpt-6.1-sol", + }, + }; + + expect(planComposerTurn({ ...staleParent, ...liveSendContext })).toMatchObject({ + compactBeforeSend: false, + turnDispatchMode: "auto", + shouldQueueBehindActiveRun: false, + }); + }); + + it("does not reuse eligibility for another Claude instance or conversation", () => { + const stale = { + ...base, + resumeCompaction: { + usedTokens: 120_000, + sourceUpdatedAt: 0, + providerInstanceId: base.selectedModelSelection.instanceId, + providerThreadId: base.destinationProviderThreadId, + }, + }; + for (const override of [ + { + selectedModelSelection: { + ...base.selectedModelSelection, + instanceId: ProviderInstanceId.make("claude-work"), + }, + }, + { destinationProviderThreadId: ProviderThreadId.make("new-claude-conversation") }, + { destinationProviderThreadId: null }, + { keepFullHistory: true }, + ]) { + expect(planComposerTurn({ ...stale, ...override })).toMatchObject({ + compactBeforeSend: false, + turnDispatchMode: "auto", + shouldQueueBehindActiveRun: false, + }); + } + }); + + it("compacts a cold Claude destination when returning from Codex", () => { + const returning = { + ...base, + activeProviderThreadId: ProviderThreadId.make("codex-conversation"), + destinationProviderThreadId: base.destinationProviderThreadId, + resumeCompaction: { + usedTokens: 250_000, + sourceUpdatedAt: 0, + providerInstanceId: base.selectedModelSelection.instanceId, + providerThreadId: base.destinationProviderThreadId, + }, + }; + expect(planComposerTurn(returning)).toMatchObject({ + compactBeforeSend: true, + turnDispatchMode: "queue", + shouldQueueBehindActiveRun: true, + }); + }); + it("queues behind a running turn only when asked to", () => { const running = { ...base, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 24fe3c614516..ced9698caa45 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,8 @@ import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts"; +import { + type ResumeCompaction, + resumeCompactionTokensForSelection, +} from "./chat/ContextWindowMeter.logic"; import { deriveLatestThreadRun, deriveThreadRuntime, @@ -1421,7 +1425,10 @@ export function planComposerTurn(input: { readonly sendEnvMode: DraftThreadEnvMode; readonly worktreePath: string | null; readonly activeThreadBranch: string | null; - readonly resumeCompactionTokens: number | null; + readonly resumeCompaction: ResumeCompaction | null; + readonly destinationProviderThreadId: Thread["activeProviderThreadId"]; + readonly selectedProvider: ProviderDriverKind; + readonly selectedModelSelection: ModelSelection; readonly keepFullHistory: boolean; readonly phase: SessionPhase; readonly dispatchMode: ComposerDispatchMode; @@ -1434,7 +1441,11 @@ export function planComposerTurn(input: { // old history. The message queues behind the /compact run, since steering into it is // rejected. const compactBeforeSend = - input.resumeCompactionTokens !== null && + resumeCompactionTokensForSelection({ + ...input, + selectedInstanceId: input.selectedModelSelection.instanceId, + }) !== null && + input.resumeCompaction?.providerThreadId === input.destinationProviderThreadId && !input.keepFullHistory && !COMPACT_COMMAND_PATTERN.test(input.messageText); return { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5b44cc0b247a..13e86ae426a2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -63,7 +63,6 @@ import { type MessageId, type ModelSelection, type PreviewAnnotationPayload, - ProviderInstanceId, type ServerProvider, type ScopedThreadRef, type ThreadId, @@ -312,6 +311,8 @@ import { resolveThreadSyncPhase } from "../threadSync"; import { hasAvailableCompactionProvider, hasDismissedResumeCompaction, + resolveResumeCompactionProviderThread, + resolveResumeCompactionContext, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; import { @@ -1246,7 +1247,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadModelInstanceId, activeProjectDefaultModelSelection?.instanceId, ], - lockedProvider, + lockedProvider: modelPickerLockedProvider, lockedInstanceId: activeRuntime?.providerInstanceId ?? activeThreadModelInstanceId, }); const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind; @@ -2453,12 +2454,20 @@ export default function ChatView(props: ChatViewProps) { ? "Compaction is unavailable for this provider" : "Compacting is unavailable right now" : null; - const resumeCompactionTokens = - activeContextWindow && - resolveIdleCompaction( - settings, - activeProviderInstanceId ?? ProviderInstanceId.make("claudeAgent"), - ) !== "off" && + const resumeCompactionProviderThread = resolveResumeCompactionProviderThread({ + providerThreads: serverProjection?.providerThreads ?? [], + threadId, + instanceId: activeProviderInstanceId, + }); + const resumeCompactionContext = resolveResumeCompactionContext({ + providerThread: resumeCompactionProviderThread, + providerTurns: serverProjection?.providerTurns ?? [], + entries: serverVisibleTurnItems, + }); + const resumeCompaction = + resumeCompactionProviderThread?.driver === "claudeAgent" && + resumeCompactionContext && + resolveIdleCompaction(settings, resumeCompactionProviderThread.providerInstanceId) !== "off" && !resumeCompactionPermanentlyDismissed && !nativeResumeCompactionDismissed && !compactDisabled && @@ -2466,12 +2475,17 @@ export default function ChatView(props: ChatViewProps) { multipleModelSelections === null && shouldOfferResumeCompaction({ provider: selectedProvider, - usedTokens: activeContextWindow.usedTokens, - updatedAt: activeContextWindow.updatedAt, - promptCacheTtlMs: activeThreadLiveTokenUsage?.promptCacheTtlMs, + usedTokens: resumeCompactionContext.usedTokens, + updatedAt: resumeCompactionContext.updatedAt, + promptCacheTtlMs: resumeCompactionContext.promptCacheTtlMs, now: `${nowMinute}:00.000Z`, }) - ? activeContextWindow.usedTokens + ? { + usedTokens: resumeCompactionContext.usedTokens, + providerInstanceId: resumeCompactionProviderThread.providerInstanceId, + providerThreadId: resumeCompactionProviderThread.id, + sourceUpdatedAt: resumeCompactionContext.sourceUpdatedAt, + } : null; const [fullHistoryThreadKeys, setFullHistoryThreadKeys] = useState>( () => new Set(), @@ -2804,7 +2818,7 @@ export default function ChatView(props: ChatViewProps) { panelAnimationsActive, panelAnimationDurationMs, keepFullHistory, - resumeCompactionTokens, + resumeCompaction, showPlanFollowUpPrompt, usageLimitsKey, usageLimitsOffered, @@ -3488,7 +3502,7 @@ export default function ChatView(props: ChatViewProps) { } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} bannerItems={composerBannerItems} - resumeCompactionTokens={resumeCompactionTokens} + resumeCompaction={resumeCompaction} keepFullHistory={keepFullHistory} onToggleKeepFullHistory={toggleKeepFullHistoryStable} onUsageLimitsCommand={ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 1d17f03e41b6..76b1cf123404 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -203,7 +203,9 @@ import { buildPullRequestReferenceContext } from "../pullRequest/pullRequestDeta import { getComposerPromptInjectionState, getComposerReasoningStep } from "./composerProviderState"; import { readPromptUltrathink } from "./TraitsPicker"; import { + type ResumeCompaction, providerSupportsManualCompaction, + resumeCompactionTokensForSelection, resolveContextWindowModelDisplayName, shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; @@ -393,7 +395,7 @@ export interface ChatComposerProps { isPreparingWorktree: boolean; isCompacting: boolean; bannerItems: readonly ComposerBannerStackItem[]; - resumeCompactionTokens: number | null; + resumeCompaction: ResumeCompaction | null; keepFullHistory: boolean; onToggleKeepFullHistory: () => void; onUsageLimitsCommand?: (() => void) | undefined; @@ -914,6 +916,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) lockedInstanceId: activeThread?.runtime?.providerInstanceId ?? activeThreadModelSelection?.instanceId, }); + const resumeCompactionTokens = resumeCompactionTokensForSelection({ + resumeCompaction: props.resumeCompaction, + selectedProvider, + selectedInstanceId, + }); const noProviderAvailable = selectedProviderEntry === undefined && multipleModelSelections === null; const providerCatalogPending = noProviderAvailable && !providerCatalogKnown; @@ -1266,7 +1273,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (!composerSendState.hasSendableContent && !showResumeAction); const collapsedComposerPrimaryActionLabel = showResumeAction ? "Resume thread" - : props.resumeCompactionTokens !== null && !props.keepFullHistory + : resumeCompactionTokens !== null && !props.keepFullHistory ? "Open composer to compact and send" : "Send message"; const showMobilePendingAnswerActions = @@ -3544,7 +3551,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onClick={(event) => { event.stopPropagation(); if (showResumeAction) onResume(); - else if (props.resumeCompactionTokens !== null && !props.keepFullHistory) + else if (resumeCompactionTokens !== null && !props.keepFullHistory) expandMobileComposer(); else submitComposer(); }} @@ -3849,7 +3856,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryActionStable} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryActionStable} - compactBeforeSendTokens={props.resumeCompactionTokens} + compactBeforeSendTokens={resumeCompactionTokens} keepFullHistory={props.keepFullHistory} onToggleKeepFullHistory={props.onToggleKeepFullHistory} compactDisabled={ diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts index e8d08570707b..9bed701588bc 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts @@ -1,13 +1,130 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ThreadId, + NodeId, + type OrchestrationV2ProviderThread, + type ServerProvider, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; import { deriveProviderInstanceEntries } from "../../providerInstances"; import { hasAvailableCompactionProvider, hasDismissedResumeCompaction, resolveContextWindowModelDisplayName, + resolveResumeCompactionProviderThread, + resumeCompactionTokensForSelection, + shouldOfferResumeCompaction, shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; +describe("resume compaction", () => { + const instanceId = ProviderInstanceId.make("claude-primary"); + const providerThreadId = ProviderThreadId.make("claude-conversation"); + const recommendation = { + usedTokens: 120_000, + providerInstanceId: instanceId, + providerThreadId, + sourceUpdatedAt: 0, + }; + const selection = { + resumeCompaction: recommendation, + selectedProvider: ProviderDriverKind.make("claudeAgent"), + selectedInstanceId: instanceId, + }; + + it("uses the latest root conversation belonging to the destination instance", () => { + const threadId = ThreadId.make("thread"); + const at = DateTime.makeUnsafe("2026-10-26T17:00:00.000Z"); + const source: OrchestrationV2ProviderThread = { + id: providerThreadId, + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: instanceId, + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [], + contextUsage: { usedTokens: 250_000 }, + nativeMetadata: null, + goal: null, + createdAt: at, + updatedAt: at, + }; + const newest = DateTime.makeUnsafe("2026-10-26T19:00:00.000Z"); + const replacement = { + ...source, + id: ProviderThreadId.make("replacement"), + updatedAt: DateTime.makeUnsafe("2026-10-26T18:00:00.000Z"), + }; + const providerThreads = [ + source, + replacement, + { + ...source, + id: ProviderThreadId.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + updatedAt: newest, + }, + { + ...source, + id: ProviderThreadId.make("child"), + ownerNodeId: NodeId.make("child-node"), + updatedAt: newest, + }, + { + ...source, + id: ProviderThreadId.make("other-app-thread"), + appThreadId: ThreadId.make("other-thread"), + updatedAt: newest, + }, + ]; + expect( + resolveResumeCompactionProviderThread({ providerThreads, threadId, instanceId }), + ).toEqual(replacement); + expect( + resolveResumeCompactionProviderThread({ + providerThreads, + threadId, + instanceId: ProviderInstanceId.make("claude-new"), + }), + ).toBeNull(); + }); + + it("shows the suggestion only for the matching Claude destination", () => { + expect(resumeCompactionTokensForSelection(selection)).toBe(120_000); + for (const override of [ + { selectedProvider: ProviderDriverKind.make("codex") }, + { selectedInstanceId: ProviderInstanceId.make("claude-work") }, + { resumeCompaction: null }, + ]) { + expect(resumeCompactionTokensForSelection({ ...selection, ...override })).toBeNull(); + } + }); + + it("offers compaction after the cache TTL, preserving warm and small contexts", () => { + const usage = { + provider: "claudeAgent", + usedTokens: 250_000, + updatedAt: "2026-10-26T17:00:00.000Z", + promptCacheTtlMs: 5 * 60_000, + now: "2026-10-26T17:05:00.000Z", + }; + expect(shouldOfferResumeCompaction(usage)).toBe(true); + expect(shouldOfferResumeCompaction({ ...usage, now: "2026-10-26T17:04:59.000Z" })).toBe(false); + expect(shouldOfferResumeCompaction({ ...usage, usedTokens: 1_000 })).toBe(false); + expect(shouldOfferResumeCompaction({ ...usage, provider: "codex" })).toBe(false); + }); +}); + function claudeProvider(input: { instanceId: string; continuationGroupKey: string; diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index 5a0911a10a05..55f99d958a81 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -1,4 +1,13 @@ -import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import type { + ModelSelection, + OrchestrationV2ProviderThread, + OrchestrationV2ProviderTurn, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import { CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, COMPACTION_SUGGESTION_MIN_TOKENS, @@ -9,6 +18,10 @@ import { type ProviderInstanceEntry, } from "../../providerInstances"; import { getDisplayModelName, type ModelEsque } from "./providerIconUtils"; +import { + deriveLatestContextWindowSnapshot, + latestProviderTurnTokenUsage, +} from "../../lib/contextWindow"; // Fallback TTL: Claude's 1h cache plus slack. const CLAUDE_RESUME_COMPACTION_FALLBACK_MS = 70 * 60_000; @@ -61,6 +74,75 @@ export function hasDismissedResumeCompaction( }); } +export interface ResumeCompaction { + readonly usedTokens: number; + readonly providerInstanceId: ProviderInstanceId; + readonly providerThreadId: ProviderThreadId; + readonly sourceUpdatedAt: number; +} + +export function resolveResumeCompactionContext(input: { + readonly providerThread: OrchestrationV2ProviderThread | null; + readonly providerTurns: ReadonlyArray; + readonly entries: Parameters[0]; +}) { + const { providerThread } = input; + if (providerThread === null) return null; + const usage = latestProviderTurnTokenUsage( + input.providerTurns.filter((turn) => turn.providerThreadId === providerThread.id), + ); + const context = deriveLatestContextWindowSnapshot( + input.entries.filter(({ item }) => item.providerThreadId === providerThread.id), + usage, + providerThread, + ); + return context === null + ? null + : { + usedTokens: context.usedTokens, + updatedAt: context.updatedAt, + promptCacheTtlMs: usage?.promptCacheTtlMs, + sourceUpdatedAt: Math.max( + DateTime.toEpochMillis(providerThread.updatedAt), + Date.parse(context.updatedAt), + ), + }; +} + +export function resolveResumeCompactionProviderThread(input: { + readonly providerThreads: ReadonlyArray; + readonly threadId: ThreadId; + readonly instanceId: ProviderInstanceId | null; +}): OrchestrationV2ProviderThread | null { + // Match the retained root conversation chosen by ProviderSwitchService. + return ( + input.providerThreads + .filter( + (thread) => + thread.appThreadId === input.threadId && + thread.ownerNodeId === null && + thread.providerInstanceId === input.instanceId, + ) + .toSorted( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), + )[0] ?? null + ); +} + +export function resumeCompactionTokensForSelection(input: { + readonly resumeCompaction: ResumeCompaction | null; + readonly selectedProvider: ProviderDriverKind; + readonly selectedInstanceId: ProviderInstanceId; +}): number | null { + const { resumeCompaction } = input; + return resumeCompaction !== null && + input.selectedProvider === "claudeAgent" && + input.selectedInstanceId === resumeCompaction.providerInstanceId + ? resumeCompaction.usedTokens + : null; +} + export function shouldOfferResumeCompaction(input: { readonly provider: string | null | undefined; readonly usedTokens: number | null | undefined; diff --git a/apps/web/src/components/chat/useComposerSend.browser.test.tsx b/apps/web/src/components/chat/useComposerSend.browser.test.tsx new file mode 100644 index 000000000000..5c187b2a6849 --- /dev/null +++ b/apps/web/src/components/chat/useComposerSend.browser.test.tsx @@ -0,0 +1,403 @@ +import { useImperativeHandle } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, +} from "@t3tools/contracts"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { act, render } from "../../../test/render"; +import { makeThreadFixture } from "../../test-fixtures"; +import type { ChatComposerHandle } from "./ChatComposer"; +import type { ChatMessage } from "../../types"; +import { useComposerSend } from "./useComposerSend"; + +const mocks = await vi.hoisted(async () => { + const { Atom } = await import("effect/reactivity"); + return { + activeProviderThreadId: "claude-conversation", + destinationProviderThreadId: "claude-conversation", + contextUpdatedAt: "2026-10-26T17:00:00.000Z", + contextTokens: 250_000, + usageUpdatedAt: null as string | null, + startTurn: vi.fn(), + awaitUploads: vi.fn(), + config: Atom.make(new Map()).pipe(Atom.keepAlive), + presentations: Atom.make(new Map()).pipe(Atom.keepAlive), + }; +}); + +vi.mock("../../state/use-orchestration-command", () => ({ + useOrchestrationCommand: () => mocks.startTurn, +})); +vi.mock("../../hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("../../hooks/useSettings", async (original) => ({ + ...(await original()), + useClientSettingsHydrated: () => true, + useEnvironmentSettings: () => true, +})); +vi.mock(import("../../state/session"), async (original) => ({ + ...(await original()), + readEnvironmentScope: () => true, +})); +vi.mock("./useMultipleModelSend", () => ({ useMultipleModelSend: () => vi.fn() })); +vi.mock("./useProposedPlanSend", () => ({ usePlanFollowUpSend: () => vi.fn() })); +vi.mock("../../state/server", async (original) => ({ + ...(await original()), + environmentServerConfigsAtom: mocks.config, +})); +vi.mock("../../state/presentation", async (original) => ({ + ...(await original()), + environmentPresentations: { presentationsAtom: mocks.presentations }, +})); +vi.mock("../../state/threads", async (original) => { + const { Atom } = await import("effect/reactivity"); + const DateTime = await import("effect/DateTime"); + return { + ...(await original()), + threadEnvironment: { startTurn: {}, uploadFeedback: {} }, + environmentThreadDetails: { + quietThreadAtom: () => + Atom.make({ + projection: { + thread: { activeProviderThreadId: mocks.activeProviderThreadId }, + providerTurns: + mocks.usageUpdatedAt === null + ? [] + : [ + { + providerThreadId: mocks.destinationProviderThreadId, + tokenUsage: { usedTokens: 250_000, updatedAt: mocks.usageUpdatedAt }, + }, + ], + providerThreads: [ + { + id: mocks.destinationProviderThreadId, + driver: "claudeAgent", + providerInstanceId: "claudeAgent", + appThreadId: "thread-test", + ownerNodeId: null, + updatedAt: DateTime.makeUnsafe(mocks.contextUpdatedAt), + contextUsage: { usedTokens: mocks.contextTokens }, + }, + ], + }, + }), + }, + }; +}); +vi.mock("../../lib/attachmentUploadQueue", () => ({ + awaitAttachmentUploads: () => mocks.awaitUploads(), + readAttachmentUpload: () => undefined, + startAttachmentUpload: () => {}, + retryAttachmentUpload: () => {}, + releaseDraftAttachments: () => {}, + getUploadedAttachments: () => [ + { + type: "image", + id: "uploaded-image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 4, + }, + ], +})); + +import { appAtomRegistry } from "../../rpc/atomRegistry"; + +type SendInput = Parameters[0]; +type Sender = ReturnType; + +function Harness({ input, sender }: { input: SendInput; sender: { current: Sender | null } }) { + const result = useComposerSend(input); + useImperativeHandle(sender, () => result); + return null; +} + +function setup( + provider = ProviderDriverKind.make("claudeAgent"), + prompt = "Keep the attachment", + currentProvider = "claudeAgent", +) { + mocks.activeProviderThreadId = + currentProvider === "codex" ? "codex-conversation" : "claude-conversation"; + mocks.destinationProviderThreadId = "claude-conversation"; + mocks.contextUpdatedAt = "2026-10-26T17:00:00.000Z"; + mocks.contextTokens = 250_000; + mocks.usageUpdatedAt = null; + mocks.startTurn.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + mocks.awaitUploads.mockReset().mockResolvedValue(undefined); + const environmentId = EnvironmentId.make("compaction-send-test"); + const instanceId = ProviderInstanceId.make(provider); + const sourceInstanceId = ProviderInstanceId.make("claudeAgent"); + const thread = makeThreadFixture({ + environmentId, + activeProviderThreadId: ProviderThreadId.make( + currentProvider === "codex" ? "codex-conversation" : "claude-conversation", + ), + modelSelection: { + instanceId: ProviderInstanceId.make(currentProvider), + model: currentProvider === "codex" ? "gpt-6.1-sol" : "claude-opus-4-6", + }, + }); + const target = scopeThreadRef(environmentId, thread.id); + const key = scopedThreadKey(target); + const image = { + type: "image" as const, + id: "local-image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 4, + file: new File(["test"], "image.png", { type: "image/png" }), + previewUrl: "data:image/png;base64,dGVzdA==", + }; + const config = { environment: { capabilities: { attachmentUploads: true } } }; + appAtomRegistry.set(mocks.config, new Map([[environmentId, config]])); + appAtomRegistry.set(mocks.presentations, new Map([[environmentId, { serverConfig: config }]])); + const handle: Partial = { + getSendContext: () => ({ + answeringPendingUserInput: false, + prompt, + images: [image], + files: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + threadContexts: [], + environmentContexts: [], + selectedPromptEffort: null, + selectedModelOptionsForDispatch: undefined, + selectedModelSelection: { + instanceId, + model: provider === "codex" ? "gpt-6.1-sol" : "claude-opus-4-6", + }, + multipleModelSelections: null, + providerAvailable: true, + selectedProvider: provider, + selectedModel: provider === "codex" ? "gpt-6.1-sol" : "claude-opus-4-6", + selectedProviderModels: [], + interactionMode: "default", + interactionModeEnabled: true, + shownPendingApprovalIds: [], + }), + validateProviderInput: () => true, + resetCursorState: () => {}, + }; + let optimisticMessages: ChatMessage[] = []; + const input: SendInput = { + environmentId, + routeThreadKey: key, + currentRouteThreadKeyRef: { current: key }, + optimisticBlobPreviews: { track: vi.fn(), release: vi.fn() }, + draftId: null, + draftThread: null, + activeThread: thread, + activeThreadKey: key, + activeThreadBranch: "main", + activeProject: { + id: thread.projectId, + environmentId, + title: "test", + workspaceRoot: "/workspace/test", + defaultModelSelection: null, + scripts: [], + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + }, + activeProjectDefaultModelSelection: null, + activeProposedPlan: null, + activeMessageCount: 2, + isServerThread: true, + isLocalDraftThread: false, + isGitRepo: false, + phase: "ready", + runtimeMode: "full-access", + sendEnvMode: "local", + startFromOrigin: false, + localCheckoutBranchMismatch: null, + logicalProjectEnvironments: [], + providerInstanceEntries: [], + composerRef: { current: handle as ChatComposerHandle }, + composerDraftTarget: target, + promptRef: { current: prompt }, + composerImagesRef: { current: [image] }, + composerFilesRef: { current: [] }, + composerTerminalContextsRef: { current: [] }, + feedbackUploadsInFlightRef: { current: new Set() }, + queuedRunEdit: { + isEditing: false, + editingRunId: null, + isSaving: false, + draftTarget: target, + editingAttachments: null, + beginEdit: vi.fn(), + cancelEdit: vi.fn(), + removeEditingAttachment: vi.fn(), + saveEdit: vi.fn(), + }, + isConnecting: false, + isRevertingCheckpoint: false, + threadDetailLoading: false, + activeEnvironmentUnavailable: false, + isSendBusy: false, + sendQueuesBehindSetup: false, + isDraftHeroState: false, + panelAnimationsActive: false, + panelAnimationDurationMs: 0, + keepFullHistory: false, + resumeCompaction: { + usedTokens: 250_000, + providerInstanceId: sourceInstanceId, + providerThreadId: ProviderThreadId.make("claude-conversation"), + sourceUpdatedAt: Date.parse("2026-10-26T17:00:00.000Z"), + }, + showPlanFollowUpPrompt: false, + usageLimitsKey: null, + usageLimitsOffered: false, + openUsageLimits: vi.fn(), + clearUsageLimitsFor: vi.fn(), + needsLoadBalancing: false, + loadBalancing: { refresh: vi.fn(), pending: false, environmentId: null, failed: false }, + readPendingEnvironmentChange: () => null, + pendingApprovals: [], + respondingRequestIds: [], + activePendingProgress: null, + onAdvanceActivePendingUserInput: vi.fn(), + onRespondToApproval: vi.fn(), + beginLocalDispatch: vi.fn(), + resetLocalDispatch: vi.fn(), + beginWorktreeSetup: vi.fn(), + captureDraftHeroComposerRect: vi.fn(), + setDockedDraftHeroThreadKey: vi.fn(), + setSetupQueueSendThreadKey: vi.fn(), + setOptimisticUserMessages: (update) => { + optimisticMessages = typeof update === "function" ? update(optimisticMessages) : update; + }, + setFeedbackSubmissionsByThreadKey: vi.fn(), + setThreadError: vi.fn(), + setKeepFullHistory: vi.fn(), + anchorNewTurn: vi.fn(), + acknowledgeActiveThreadWoke: vi.fn(), + handleInteractionModeChange: vi.fn(), + handleNewThreadInActiveProject: vi.fn(), + persistThreadSettingsForNextTurn: vi + .fn() + .mockResolvedValue({ _tag: "Success", value: undefined }), + }; + return { + input, + sender: { current: null } as { current: Sender | null }, + getOptimisticMessages: () => optimisticMessages, + }; +} + +describe("resume compaction dispatch", () => { + it("sends once to live Codex despite stale parent Claude eligibility", async () => { + const { input, sender } = setup(ProviderDriverKind.make("codex")); + await using _screen = await render(); + await act(() => sender.current!.onSend()); + expect(mocks.startTurn).toHaveBeenCalledTimes(1); + expect(mocks.startTurn).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + modelSelection: { instanceId: "codex", model: "gpt-6.1-sol" }, + dispatchMode: "auto", + message: expect.objectContaining({ + text: "Keep the attachment", + attachments: [expect.objectContaining({ id: "uploaded-image" })], + }), + }), + }), + ); + }); + + it.each([ + { change: "replaced", dispatchMode: "auto" }, + { change: "refreshed", dispatchMode: "auto" }, + { change: "usage-refreshed", dispatchMode: "auto" }, + { change: "compacted", dispatchMode: "auto" }, + { change: "refreshed", dispatchMode: "queue" }, + { change: "refreshed", dispatchMode: "steer" }, + ] as const)( + "preserves $dispatchMode dispatch when the source is $change during upload", + async ({ change, dispatchMode }) => { + const { input: initialInput, sender, getOptimisticMessages } = setup(); + const input: SendInput = { + ...initialInput, + phase: dispatchMode === "auto" ? "ready" : "running", + }; + let finishUpload!: () => void; + const upload = new Promise((resolve) => { + finishUpload = resolve; + }); + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + mocks.awaitUploads.mockImplementation(() => { + markStarted(); + return upload; + }); + await using _screen = await render(); + const send = sender.current!.onSend(undefined, dispatchMode); + await started; + if (change === "replaced") mocks.destinationProviderThreadId = "replacement-conversation"; + else if (change === "refreshed") mocks.contextUpdatedAt = "2026-10-26T19:00:00.000Z"; + else if (change === "usage-refreshed") mocks.usageUpdatedAt = "2026-10-26T19:00:00.000Z"; + else mocks.contextTokens = 1_000; + await act(async () => { + finishUpload(); + await send; + }); + expect(getOptimisticMessages()).toHaveLength(1); + expect(getOptimisticMessages()[0]?.inputIntent).toBe( + dispatchMode === "queue" ? "queued_turn" : dispatchMode === "steer" ? "steer" : undefined, + ); + if (dispatchMode === "queue") expect(input.anchorNewTurn).not.toHaveBeenCalled(); + else + expect(input.anchorNewTurn).toHaveBeenCalledExactlyOnceWith( + input.activeThreadKey, + getOptimisticMessages()[0]!.id, + ); + expect(mocks.startTurn).toHaveBeenCalledTimes(1); + expect(mocks.startTurn).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + dispatchMode, + message: expect.objectContaining({ + text: "Keep the attachment", + attachments: [expect.objectContaining({ id: "uploaded-image" })], + }), + }), + }), + ); + }, + ); + + it.each(["claudeAgent", "codex"])( + "compacts the saved Claude destination when returning from %s", + async (currentProvider) => { + const { input, sender, getOptimisticMessages } = setup( + ProviderDriverKind.make("claudeAgent"), + "Keep the attachment", + currentProvider, + ); + await using _screen = await render(); + await act(() => sender.current!.onSend()); + expect(getOptimisticMessages()[0]?.inputIntent).toBe("queued_turn"); + expect(input.anchorNewTurn).not.toHaveBeenCalled(); + expect(mocks.startTurn).toHaveBeenCalledTimes(2); + expect(mocks.startTurn.mock.calls[0]![0].input.modelSelection.instanceId).toBe("claudeAgent"); + expect( + mocks.startTurn.mock.calls.map(([call]) => [ + call.input.message.text, + call.input.dispatchMode, + ]), + ).toEqual([ + ["/compact", undefined], + ["Keep the attachment", "queue"], + ]); + }, + ); +}); diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index d932c4c0b4a5..e8faecc223e7 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -82,7 +82,12 @@ import { previewAnnotationContextReference, } from "../../lib/composerContextRecords"; import { environmentServerConfigsAtom } from "../../state/server"; -import { threadEnvironment } from "../../state/threads"; +import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; +import { + type ResumeCompaction, + resolveResumeCompactionProviderThread, + resolveResumeCompactionContext, +} from "./ContextWindowMeter.logic"; import { runMobileComposerTransition } from "./draftHeroTransition"; import { useClientSettingsHydrated, useEnvironmentSettings } from "../../hooks/useSettings"; import { environmentPresentations } from "../../state/presentation"; @@ -202,7 +207,7 @@ interface ComposerSendInput { readonly panelAnimationsActive: boolean; readonly panelAnimationDurationMs: number; readonly keepFullHistory: boolean; - readonly resumeCompactionTokens: number | null; + readonly resumeCompaction: ResumeCompaction | null; readonly showPlanFollowUpPrompt: boolean; readonly usageLimitsKey: string | null; readonly usageLimitsOffered: boolean; @@ -294,7 +299,7 @@ export function useComposerSend(input: ComposerSendInput) { panelAnimationsActive, panelAnimationDurationMs, keepFullHistory, - resumeCompactionTokens, + resumeCompaction, showPlanFollowUpPrompt, usageLimitsKey, usageLimitsOffered, @@ -796,25 +801,43 @@ export function useComposerSend(input: ComposerSendInput) { environmentContexts: composerEnvironmentContextsSnapshot, messageText: messageTextForSend, } = sendSnapshot; - const { - missingWorktreeBaseBranch, - baseBranchForWorktree, - compactBeforeSend, - turnDispatchMode, - shouldQueueBehindActiveRun, - } = planComposerTurn({ - messageText: messageTextForSend, - sendQueuesBehindSetup, - isServerThread, - activeMessageCount, - sendEnvMode, - worktreePath: activeThread.worktreePath, - activeThreadBranch, - resumeCompactionTokens, - keepFullHistory, - phase, - dispatchMode, - }); + const readTurnPlan = () => { + const projection = appAtomRegistry.get( + environmentThreadDetails.quietThreadAtom(scopeThreadRef(environmentId, threadIdForSend)), + )?.projection; + const destination = resolveResumeCompactionProviderThread({ + providerThreads: projection?.providerThreads ?? [], + threadId: threadIdForSend, + instanceId: ctxSelectedModelSelection.instanceId, + }); + const context = resolveResumeCompactionContext({ + providerThread: destination, + providerTurns: projection?.providerTurns ?? [], + entries: projection?.visibleTurnItems ?? [], + }); + return planComposerTurn({ + messageText: messageTextForSend, + sendQueuesBehindSetup, + isServerThread, + activeMessageCount, + sendEnvMode, + worktreePath: activeThread.worktreePath, + activeThreadBranch, + resumeCompaction: + resumeCompaction?.sourceUpdatedAt === context?.sourceUpdatedAt && + resumeCompaction?.usedTokens === context?.usedTokens + ? resumeCompaction + : null, + destinationProviderThreadId: destination?.id ?? null, + selectedProvider: ctxSelectedProvider, + selectedModelSelection: ctxSelectedModelSelection, + keepFullHistory, + phase, + dispatchMode, + }); + }; + const { missingWorktreeBaseBranch, baseBranchForWorktree, shouldQueueBehindActiveRun } = + readTurnPlan(); if (missingWorktreeBaseBranch) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; @@ -1220,6 +1243,34 @@ export function useComposerSend(input: ComposerSendInput) { failure = turnAttachmentsResult; } + // Uploads and settings persistence can outlive the source conversation. + const { + compactBeforeSend, + turnDispatchMode, + shouldQueueBehindActiveRun: queueAfterPreparation, + } = readTurnPlan(); + if (failure === null && shouldQueueBehindActiveRun !== queueAfterPreparation) { + setOptimisticUserMessages((existing) => + existing.map((message) => + message.id === messageIdForSend + ? { + ...message, + inputIntent: queueAfterPreparation + ? "queued_turn" + : phase === "running" && dispatchMode === "steer" + ? "steer" + : undefined, + } + : message, + ), + ); + if (!queueAfterPreparation) { + anchorNewTurn( + scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), + messageIdForSend, + ); + } + } if (failure === null && compactBeforeSend) { const compactResult = await startThreadTurn({ environmentId,