From 108011c5e56b201fc5671298b894859b46053dbf Mon Sep 17 00:00:00 2001 From: Praffi <167605652+praffiii@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:26:10 +0700 Subject: [PATCH 1/4] fix(mobile): stale Claude threads show a compact-before-send notice Mobile already queues /compact ahead of a plain send into an idle Claude thread past its prompt cache TTL, but the composer showed nothing. A dismissible notice above the composer now says the next message compacts first, and dismissing it keeps the full history for that send, like web's Keep toggle. The shared helper returns the token count so the notice and the send path read the same decision. Fixes #34247 Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadDetailScreen.tsx | 18 +++++ .../features/threads/ThreadRouteScreen.tsx | 1 + .../src/state/thread-task-permissions.test.ts | 15 +++- .../src/state/use-thread-composer-state.ts | 73 ++++++++++++++----- .../src/state/contextWindow.test.ts | 8 +- .../client-runtime/src/state/contextWindow.ts | 19 ++--- 6 files changed, 100 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 72361dd242047..45d2aad72e428 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -176,6 +176,8 @@ import { PendingApprovalCard } from "./PendingApprovalCard"; import { ComposerErrorNotice } from "./ComposerErrorNotice"; import { ManagedRuntimeModeNotice } from "./ManagedRuntimeModeNotice"; import { FreshSessionNotice } from "./FreshSessionNotice"; +import { Notice } from "../../components/ui/Notice"; +import { formatTokens } from "@t3tools/shared/usageFormat"; import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; @@ -244,6 +246,11 @@ export interface ThreadDetailScreenProps { readonly providerSubagentStatus?: ProviderSubagentStatus | null; readonly extensionStatuses?: NonNullable; readonly isCompacting: boolean; + /** Set while the next send compacts the thread first. */ + readonly compactBeforeSend?: { + readonly tokens: number; + readonly keepFullHistory: () => void; + } | null; readonly creationState: | { readonly kind: "preparing"; @@ -1681,6 +1688,17 @@ function ThreadDetailBody( {freshSessionAvailable ? ( ) : null} + {props.compactBeforeSend ? ( + + + + ) : null} {props.canOperateThread ? ( ({ vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" })); vi.mock("@t3tools/client-runtime/state/context-window", async (importOriginal) => ({ ...(await importOriginal()), - shouldCompactBeforeSend: () => state.compactDue, + compactBeforeSendTokens: () => (state.compactDue ? 248_000 : null), })); +vi.mock("../lib/clock", () => ({ useNowMinuteMs: () => 0 })); vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() })); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); vi.mock("./queued-run-edit", () => ({ @@ -288,7 +289,7 @@ describe("mobile task permissions", () => { expect(state.setFreshPending).toHaveBeenCalledWith("secondary:thread", false); }); - it("compacts a stale thread before sending unless a fresh session is armed", async () => { + it("compacts a stale thread before sending unless kept or a fresh session is armed", async () => { state.grantedEnvironments.add("secondary"); state.projection = { thread: { @@ -316,10 +317,20 @@ describe("mobile task permissions", () => { } as unknown as OrchestrationV2ThreadProjection; state.compactDue = true; const composer = useThreadComposerState(NO_SIDE_CHATS); + expect(composer.compactBeforeSend?.tokens).toBe(248_000); await composer.onSendMessage(); expect(state.enqueue).toHaveBeenLastCalledWith( expect.objectContaining({ compactBeforeSend: true }), ); + composer.compactBeforeSend?.keepFullHistory(); + const kept = useThreadComposerState(NO_SIDE_CHATS); + expect(kept.compactBeforeSend).toBeNull(); + await kept.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ compactBeforeSend: true }), + ); + // Keeping covers one send, as on web. + expect(useThreadComposerState(NO_SIDE_CHATS).compactBeforeSend?.tokens).toBe(248_000); state.freshPending = true; await composer.onSendMessage(); expect(state.enqueue).toHaveBeenLastCalledWith( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 61a72f1af65dc..3137b02e5242b 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -3,7 +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 { compactBeforeSendTokens } 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"; @@ -41,6 +41,7 @@ import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLega import { restoreAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import { composerContextSendBlockReason, reidentifyComposerContext } from "../lib/composerContext"; import { uuidv4 } from "../lib/uuid"; +import { useNowMinuteMs } from "../lib/clock"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { @@ -118,6 +119,17 @@ import { useAtomCommand } from "./use-atom-command"; const EMPTY_QUEUE_WORKFLOW_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-thread-queue-workflow:empty"), ); +// Threads whose next send keeps the full history instead of compacting first. +const fullHistoryThreadKeysAtom = Atom.make>>({}).pipe( + Atom.keepAlive, +); + +function setKeepFullHistory(threadKey: string, keep: boolean) { + const { [threadKey]: kept, ...rest } = appAtomRegistry.get(fullHistoryThreadKeysAtom); + if ((kept === true) === keep) return; + appAtomRegistry.set(fullHistoryThreadKeysAtom, keep ? { ...rest, [threadKey]: true } : rest); +} + const EMPTY_CHECKPOINT_SUMMARIES_ATOM = Atom.make>([]); const EMPTY_THREAD_SEQUENCE_ATOM = Atom.make(0); @@ -388,6 +400,34 @@ export function useThreadComposerState(input: { : null; const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime); + const resumeCompactionTokens = (text: string, now: string) => + selectedEnvironmentRuntime?.connectionState === "connected" && + selectedThreadProjection !== null && + serverConfig != null && + modelSelection !== null + ? compactBeforeSendTokens({ + projection: selectedThreadProjection.projection, + visibleTurnItems: selectedThreadVisibleTurnItems, + selection: modelSelection, + providers: serverConfig.providers, + settings: serverConfig.settings, + text, + threadIdle: + !activeThreadBusy && + !runlessWorkActive && + !isCompacting && + selectedThreadQueuedMessages.length === 0, + now, + }) + : null; + const nowMinuteMs = useNowMinuteMs(); + const compactTokens = + selectedDraft.freshContext === true + ? null + : resumeCompactionTokens("", new Date(nowMinuteMs).toISOString()); + const fullHistoryThreadKeys = useAtomValue(fullHistoryThreadKeysAtom); + const keepFullHistory = + selectedThreadKey !== null && fullHistoryThreadKeys[selectedThreadKey] === true; const interruptibleRunId = threadRuntimeHasInterruptibleRun(selectedThreadRuntime) ? (selectedThreadRuntime?.activeRunId ?? null) : null; @@ -725,23 +765,8 @@ export function useThreadComposerState(input: { const compactBeforeSend = !freshContext && 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, - }); + appAtomRegistry.get(fullHistoryThreadKeysAtom)[threadKey] !== true && + resumeCompactionTokens(text, metadata.createdAt) !== null; const messageId = MessageId.make(metadata.messageId); onWillEnqueue?.(); clearThreadComposerError(threadKey); @@ -767,7 +792,10 @@ export function useThreadComposerState(input: { clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); if (freshContext) setFreshSessionPending(threadKey, false); enqueuePromise.then( - () => scheduleUnusedComposerAttachmentCleanup(attachments), + () => { + setKeepFullHistory(threadKey, false); + scheduleUnusedComposerAttachmentCleanup(attachments); + }, (error: unknown) => { // Restore with uncapped append; capped merge could drop attachments added during the failed write. void mergeComposerDraftContent(threadKey, { @@ -851,6 +879,13 @@ export function useThreadComposerState(input: { ), providerSubagentStatus, isCompacting, + compactBeforeSend: + compactTokens === null || selectedThreadKey === null || keepFullHistory + ? null + : { + tokens: compactTokens, + keepFullHistory: () => setKeepFullHistory(selectedThreadKey, true), + }, composerDraftKey, followUpBehavior, canSteerActiveTurn, diff --git a/packages/client-runtime/src/state/contextWindow.test.ts b/packages/client-runtime/src/state/contextWindow.test.ts index 3f1a4d3d41994..994d3d62a2065 100644 --- a/packages/client-runtime/src/state/contextWindow.test.ts +++ b/packages/client-runtime/src/state/contextWindow.test.ts @@ -14,7 +14,7 @@ import { import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; -import { shouldCompactBeforeSend } from "./contextWindow.ts"; +import { compactBeforeSendTokens } from "./contextWindow.ts"; import type { ThreadStructure } from "./models.ts"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; @@ -181,7 +181,7 @@ function sendInput( }; } -describe("shouldCompactBeforeSend", () => { +describe("compactBeforeSendTokens", () => { it.each([ { name: "a stale Claude thread over 200K", expected: true }, { @@ -257,8 +257,8 @@ describe("shouldCompactBeforeSend", () => { { 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, + expect(compactBeforeSendTokens(sendInput(overrides as Parameters[0]))).toBe( + expected ? 293_611 : null, ); }); }); diff --git a/packages/client-runtime/src/state/contextWindow.ts b/packages/client-runtime/src/state/contextWindow.ts index 66c48807465af..94be1e481ad42 100644 --- a/packages/client-runtime/src/state/contextWindow.ts +++ b/packages/client-runtime/src/state/contextWindow.ts @@ -234,11 +234,12 @@ 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. A send that - * hands off to another conversation starts that one fresh, so only the resumed one counts. + * The tokens a plain send into an idle thread compacts first, as web's composer does under + * "When you return", or null when it sends as is. 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: { +export function compactBeforeSendTokens(input: { readonly projection: Pick< ThreadStructure, | "thread" @@ -259,7 +260,7 @@ export function shouldCompactBeforeSend(input: { readonly text: string; readonly threadIdle: boolean; readonly now: string; -}): boolean { +}): number | null { const { projection } = input; const provider = input.providers.find((entry) => entry.instanceId === input.selection.instanceId); if ( @@ -290,7 +291,7 @@ export function shouldCompactBeforeSend(input: { .map((request) => ({ kind: "user-input.resolved", payload: { answers: request.answers } })), ) ) { - return false; + return null; } const activeProviderThreadId = projection.thread.activeProviderThreadId; const providerThread = projection.providerThreads.find( @@ -306,8 +307,7 @@ export function shouldCompactBeforeSend(input: { liveUsage, providerThread, ); - return ( - contextWindow !== null && + return contextWindow !== null && shouldOfferResumeCompaction({ provider: provider.driver, usedTokens: contextWindow.usedTokens, @@ -315,5 +315,6 @@ export function shouldCompactBeforeSend(input: { promptCacheTtlMs: liveUsage?.promptCacheTtlMs, now: input.now, }) - ); + ? contextWindow.usedTokens + : null; } From 0186d2afe772be43f82adad155ba7b8fbd885858 Mon Sep 17 00:00:00 2001 From: Praffi <167605652+praffiii@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:48:07 +0700 Subject: [PATCH 2/4] fix(mobile): compact-before-send uses the model picked at send time The shared helper read the model from the last render, while the send enqueues the draft's current model. A model switched just before sending could decide compaction for the wrong provider. The send now passes its own selection, as it did before the helper existed. Co-Authored-By: Claude Opus 5.5 --- .../src/state/thread-task-permissions.test.ts | 22 +++++++++++++------ .../src/state/use-thread-composer-state.ts | 10 ++++----- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index c11c043b55f33..200c88cbe0cfd 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -38,7 +38,7 @@ const state = vi.hoisted(() => ({ pendingRequests: { approvals: [], userInputs: [] } as PendingThreadRequests, enqueue: vi.fn(async () => undefined), freshPending: false, - compactDue: false, + compactModel: null as string | null, setFreshPending: vi.fn(), clearDraft: vi.fn(), approve: vi.fn(), @@ -79,7 +79,8 @@ vi.mock("react", () => ({ vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" })); vi.mock("@t3tools/client-runtime/state/context-window", async (importOriginal) => ({ ...(await importOriginal()), - compactBeforeSendTokens: () => (state.compactDue ? 248_000 : null), + compactBeforeSendTokens: (input: { readonly selection: ModelSelection }) => + input.selection.model === state.compactModel ? 248_000 : null, })); vi.mock("../lib/clock", () => ({ useNowMinuteMs: () => 0 })); vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() })); @@ -204,7 +205,7 @@ beforeEach(() => { runtimeMode: undefined, }; state.freshPending = false; - state.compactDue = false; + state.compactModel = null; state.thread.latestRun = null; state.projection = null; state.serverConfig = { @@ -315,13 +316,20 @@ describe("mobile task permissions", () => { visibleTurnItems: [], updatedAt: DateTime.makeUnsafe(0), } as unknown as OrchestrationV2ThreadProjection; - state.compactDue = true; - const composer = useThreadComposerState(NO_SIDE_CHATS); - expect(composer.compactBeforeSend?.tokens).toBe(248_000); - await composer.onSendMessage(); + state.compactModel = "gpt-5.5"; + const stale = useThreadComposerState(NO_SIDE_CHATS); + expect(stale.compactBeforeSend).toBeNull(); + // A model picked after the last render still decides the send. + state.draft = { + ...state.draft, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }, + }; + await stale.onSendMessage(); expect(state.enqueue).toHaveBeenLastCalledWith( expect.objectContaining({ compactBeforeSend: true }), ); + const composer = useThreadComposerState(NO_SIDE_CHATS); + expect(composer.compactBeforeSend?.tokens).toBe(248_000); composer.compactBeforeSend?.keepFullHistory(); const kept = useThreadComposerState(NO_SIDE_CHATS); expect(kept.compactBeforeSend).toBeNull(); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 3137b02e5242b..d1d025013ac8e 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -400,15 +400,15 @@ export function useThreadComposerState(input: { : null; const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime); - const resumeCompactionTokens = (text: string, now: string) => + const resumeCompactionTokens = (selection: ModelSelection | null, text: string, now: string) => selectedEnvironmentRuntime?.connectionState === "connected" && selectedThreadProjection !== null && serverConfig != null && - modelSelection !== null + selection !== null ? compactBeforeSendTokens({ projection: selectedThreadProjection.projection, visibleTurnItems: selectedThreadVisibleTurnItems, - selection: modelSelection, + selection, providers: serverConfig.providers, settings: serverConfig.settings, text, @@ -424,7 +424,7 @@ export function useThreadComposerState(input: { const compactTokens = selectedDraft.freshContext === true ? null - : resumeCompactionTokens("", new Date(nowMinuteMs).toISOString()); + : resumeCompactionTokens(modelSelection, "", new Date(nowMinuteMs).toISOString()); const fullHistoryThreadKeys = useAtomValue(fullHistoryThreadKeysAtom); const keepFullHistory = selectedThreadKey !== null && fullHistoryThreadKeys[selectedThreadKey] === true; @@ -766,7 +766,7 @@ export function useThreadComposerState(input: { !freshContext && followUpDispatchMode === null && appAtomRegistry.get(fullHistoryThreadKeysAtom)[threadKey] !== true && - resumeCompactionTokens(text, metadata.createdAt) !== null; + resumeCompactionTokens(modelSelection, text, metadata.createdAt) !== null; const messageId = MessageId.make(metadata.messageId); onWillEnqueue?.(); clearThreadComposerError(threadKey); From 72fb96d33e7d514f40c82fa199aed70f246ac458 Mon Sep 17 00:00:00 2001 From: Praffi <167605652+praffiii@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:00:21 +0700 Subject: [PATCH 3/4] fix(mobile): sends compact only after the notice announces it The notice checks eligibility on a minute clock while the send checks the exact time, so for up to a minute after a thread went stale a send could compact with no notice and no way to keep the history. A send now compacts only when the notice was showing and the send-time check still agrees, which matches web, where the send reads the same value as the chip. Co-Authored-By: Claude Opus 5.5 --- .../src/state/thread-task-permissions.test.ts | 29 ++++++++++++------- .../src/state/use-thread-composer-state.ts | 4 ++- 2 files changed, 22 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 200c88cbe0cfd..100455e7b8a60 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -316,20 +316,29 @@ describe("mobile task permissions", () => { visibleTurnItems: [], updatedAt: DateTime.makeUnsafe(0), } as unknown as OrchestrationV2ThreadProjection; - state.compactModel = "gpt-5.5"; - const stale = useThreadComposerState(NO_SIDE_CHATS); - expect(stale.compactBeforeSend).toBeNull(); - // A model picked after the last render still decides the send. - state.draft = { - ...state.draft, - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }, - }; - await stale.onSendMessage(); + const due: ModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }; + state.compactModel = due.model; + const unannounced = useThreadComposerState(NO_SIDE_CHATS); + expect(unannounced.compactBeforeSend).toBeNull(); + // Due at send time but not at the last render, as when the minute clock trails the deadline. + state.draft = { ...state.draft, modelSelection: due }; + await unannounced.onSendMessage(); expect(state.enqueue).toHaveBeenLastCalledWith( - expect.objectContaining({ compactBeforeSend: true }), + expect.not.objectContaining({ compactBeforeSend: true }), ); const composer = useThreadComposerState(NO_SIDE_CHATS); expect(composer.compactBeforeSend?.tokens).toBe(248_000); + await composer.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.objectContaining({ compactBeforeSend: true }), + ); + // A model picked after the last render still decides the send. + state.draft = { ...state.draft, modelSelection: undefined }; + await composer.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ compactBeforeSend: true }), + ); + state.draft = { ...state.draft, modelSelection: due }; composer.compactBeforeSend?.keepFullHistory(); const kept = useThreadComposerState(NO_SIDE_CHATS); expect(kept.compactBeforeSend).toBeNull(); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index d1d025013ac8e..005a17281370a 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -761,8 +761,10 @@ export function useThreadComposerState(input: { // A redirect belongs to the approval it answers; the armed session waits for the next message. const freshContext = beforeDispatch === undefined && isFreshSessionPending(threadKey); const metadata = makeQueuedMessageMetadata(); - // A fresh session drops the context a compaction would summarize. + // A fresh session drops the context a compaction would summarize. Only a send the notice + // announced compacts, since the notice's minute clock can trail the exact deadline. const compactBeforeSend = + compactTokens !== null && !freshContext && followUpDispatchMode === null && appAtomRegistry.get(fullHistoryThreadKeysAtom)[threadKey] !== true && From 68d614d8cd469e66fe0c80abf371d6803ab5fea0 Mon Sep 17 00:00:00 2001 From: Praffi <167605652+praffiii@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:17:03 +0700 Subject: [PATCH 4/4] test(mobile): compaction notice case crosses the time boundary The case that sends after the thread became due now does so through the eligibility time, with the model unchanged, so it fails for the reason the notice gate exists: the notice's minute clock trailing the exact deadline. Co-Authored-By: Claude Opus 5.5 --- .../src/state/thread-task-permissions.test.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 100455e7b8a60..3b43a1451eb41 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -39,6 +39,7 @@ const state = vi.hoisted(() => ({ enqueue: vi.fn(async () => undefined), freshPending: false, compactModel: null as string | null, + compactDueAt: "", setFreshPending: vi.fn(), clearDraft: vi.fn(), approve: vi.fn(), @@ -79,8 +80,10 @@ vi.mock("react", () => ({ vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" })); vi.mock("@t3tools/client-runtime/state/context-window", async (importOriginal) => ({ ...(await importOriginal()), - compactBeforeSendTokens: (input: { readonly selection: ModelSelection }) => - input.selection.model === state.compactModel ? 248_000 : null, + compactBeforeSendTokens: (input: { readonly selection: ModelSelection; readonly now: string }) => + input.selection.model === state.compactModel && input.now >= state.compactDueAt + ? 248_000 + : null, })); vi.mock("../lib/clock", () => ({ useNowMinuteMs: () => 0 })); vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() })); @@ -206,6 +209,7 @@ beforeEach(() => { }; state.freshPending = false; state.compactModel = null; + state.compactDueAt = ""; state.thread.latestRun = null; state.projection = null; state.serverConfig = { @@ -316,16 +320,16 @@ describe("mobile task permissions", () => { visibleTurnItems: [], updatedAt: DateTime.makeUnsafe(0), } as unknown as OrchestrationV2ThreadProjection; - const due: ModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }; - state.compactModel = due.model; + state.compactModel = "gpt-5.4"; + // The notice's minute clock (the epoch here) trails a deadline the send's exact time has passed. + state.compactDueAt = "2026-01-01T00:00:00.000Z"; const unannounced = useThreadComposerState(NO_SIDE_CHATS); expect(unannounced.compactBeforeSend).toBeNull(); - // Due at send time but not at the last render, as when the minute clock trails the deadline. - state.draft = { ...state.draft, modelSelection: due }; await unannounced.onSendMessage(); expect(state.enqueue).toHaveBeenLastCalledWith( expect.not.objectContaining({ compactBeforeSend: true }), ); + state.compactDueAt = ""; const composer = useThreadComposerState(NO_SIDE_CHATS); expect(composer.compactBeforeSend?.tokens).toBe(248_000); await composer.onSendMessage(); @@ -333,12 +337,15 @@ describe("mobile task permissions", () => { expect.objectContaining({ compactBeforeSend: true }), ); // A model picked after the last render still decides the send. - state.draft = { ...state.draft, modelSelection: undefined }; + state.draft = { + ...state.draft, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }, + }; await composer.onSendMessage(); expect(state.enqueue).toHaveBeenLastCalledWith( expect.not.objectContaining({ compactBeforeSend: true }), ); - state.draft = { ...state.draft, modelSelection: due }; + state.draft = { ...state.draft, modelSelection: undefined }; composer.compactBeforeSend?.keepFullHistory(); const kept = useThreadComposerState(NO_SIDE_CHATS); expect(kept.compactBeforeSend).toBeNull();