From 800b164afd896e3adeaa1e4a62490416d160bd52 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:17:06 +1000 Subject: [PATCH 1/4] fix(v2): queue messages sent while context compaction runs Queue automatic follow-ups during compaction. Preserve explicit Steer and Restart intent so the existing unavailable errors remain visible. Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../src/state/use-thread-composer-state.ts | 4 +- .../orchestration-v2/CommandPolicy.test.ts | 87 +++++++++++++++++++ .../src/orchestration-v2/CommandPolicy.ts | 40 ++++++++- .../src/orchestration-v2/Orchestrator.ts | 18 ++-- .../orchestration-v2/RestartContinuation.ts | 2 +- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/components/chat/ChatComposer.tsx | 15 +++- .../chat/ComposerPrimaryActions.tsx | 17 +++- .../src/state/composerDispatch.test.ts | 49 +++++++++++ .../src/state/composerDispatch.ts | 6 ++ 10 files changed, 218 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 9688eddc65a4..54f1045ce9a4 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -312,7 +312,6 @@ export function useThreadComposerState() { threadId: selectedThreadShell.id, }), ); - const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true; const queuedRunEdit = useQueuedRunEdit(selectedThreadKey); const composerDraftKey = selectedThreadKey === null @@ -395,6 +394,9 @@ export function useThreadComposerState() { selectedThreadRuntime, selectedThreadVisibleTurnItems, ]); + // Compaction runs cannot take a steer, so the composer labels and sends + // follow-ups as queued behind it, matching the server's dispatch policy. + const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true && !isCompacting; const runlessWorkStartedAt = useMemo( () => diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 256ecd45e75a..233f0b1597b0 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -3,6 +3,7 @@ import { CommandId, type OrchestrationV2ProviderCapabilities, type OrchestrationV2ThreadProjection, + MessageId, ProviderInstanceId, ProviderSessionId, ProviderThreadId, @@ -34,6 +35,7 @@ function dispatchProjection( const providerThreadId = ProviderThreadId.make("command-policy-provider-thread"); const providerSessionId = ProviderSessionId.make("command-policy-provider-session"); return { + messages: [], runs: sessionCapabilities === undefined ? [] @@ -141,6 +143,91 @@ it("targets the latest active run for explicit steer and restart intent", () => ); }); +it.each(["/compact", " /COMPACT ", "/logout"])( + "queues automatic follow-ups behind %s and preserves explicit intent", + (text) => { + const messageId = MessageId.make("command-policy-compaction-message"); + const base = dispatchProjection(baseCapabilities); + const projection = { + ...base, + runs: base.runs.map((run) => ({ ...run, userMessageId: messageId })), + messages: [{ id: messageId, role: "user", text, attachments: [] }], + } as unknown as OrchestrationV2ThreadProjection; + const queueAfterActive = { type: "queue_after_active" }; + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent(projection, { type: "start_immediately" }, "auto"), + queueAfterActive, + ); + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent( + projection, + { type: "start_immediately" }, + "steer", + ), + { type: "steer_active", targetRunId: activeRunId }, + ); + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent( + projection, + { type: "start_immediately" }, + "restart", + ), + { type: "restart_active", targetRunId: activeRunId }, + ); + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent(projection, { + type: "steer_active", + targetRunId: activeRunId, + }), + { type: "steer_active", targetRunId: activeRunId }, + ); + }, +); + +it.each(["/Logout", "/LOGOUT"])("preserves ordinary delivery for %s", (text) => { + const messageId = MessageId.make("command-policy-mixed-case-message"); + const base = dispatchProjection(baseCapabilities); + const projection = { + ...base, + runs: base.runs.map((run) => ({ ...run, userMessageId: messageId })), + messages: [{ id: messageId, role: "user", text, attachments: [] }], + } as unknown as OrchestrationV2ThreadProjection; + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent(projection, { type: "start_immediately" }, "steer"), + { type: "steer_active", targetRunId: activeRunId }, + ); + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent( + projection, + { type: "start_immediately" }, + "restart", + ), + { type: "restart_active", targetRunId: activeRunId }, + ); +}); + +it("keeps steering an ordinary run alongside a compacted message in history", () => { + const messageId = MessageId.make("command-policy-ordinary-message"); + const base = dispatchProjection(baseCapabilities); + const projection = { + ...base, + runs: base.runs.map((run) => ({ ...run, userMessageId: messageId })), + messages: [ + { id: messageId, role: "user", text: "ship it", attachments: [] }, + { + id: MessageId.make("command-policy-earlier-compaction"), + role: "user", + text: "/compact", + attachments: [], + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent(projection, { type: "start_immediately" }, "auto"), + { type: "steer_active", targetRunId: activeRunId }, + ); +}); + const layer = it.layer(CommandPolicy.layer); layer("CommandPolicyV2", (it) => { diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index 5f7d130c17fd..b47e8bb080a1 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -1,4 +1,5 @@ import { + ChatAttachment, CommandId, ModelSelection, type OrchestrationV2Command, @@ -116,6 +117,35 @@ type MessageDispatchMode = Extract< { readonly type: "message.dispatch" } >["dispatchMode"]; +/** Native commands the provider executes as its own whole-turn task, so they + * own the run they start and cannot take steering or a restart. */ +export function isNativeMaintenanceCommand(message: { + readonly text: string; + readonly attachments: ReadonlyArray; +}): boolean { + return ( + message.attachments.length === 0 && + (message.text.trim().toLowerCase() === "/compact" || message.text.trim() === "/logout") + ); +} + +/** Queue automatic follow-ups behind maintenance. Explicit steer and restart + * keep their intent and receive the orchestrator's unavailable error. */ +function queueBehindMaintenanceRun( + projection: OrchestrationV2ThreadProjection, + decision: MessageDispatchMode, +): MessageDispatchMode { + if (decision.type !== "steer_active" && decision.type !== "restart_active") return decision; + const targetRun = projection.runs.find((run) => run.id === decision.targetRunId); + const targetMessage = + targetRun === undefined + ? undefined + : projection.messages.find((message) => message.id === targetRun.userMessageId); + return targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage) + ? { type: "queue_after_active" } + : decision; +} + /** Resolve client intent from the state serialized by the thread dispatch lock. */ export function resolveMessageDispatchIntent( projection: OrchestrationV2ThreadProjection, @@ -153,13 +183,19 @@ export function resolveMessageDispatchIntent( ); const capabilities = providerSession?.capabilities.turns; if (capabilities?.supportsActiveSteering === true) { - return { type: "steer_active", targetRunId: activeRun.id }; + return queueBehindMaintenanceRun(projection, { + type: "steer_active", + targetRunId: activeRun.id, + }); } if (capabilities?.supportsQueuedMessages === true) { return { type: "queue_after_active" }; } if (capabilities?.supportsSteeringByInterruptRestart === true) { - return { type: "restart_active", targetRunId: activeRun.id }; + return queueBehindMaintenanceRun(projection, { + type: "restart_active", + targetRunId: activeRun.id, + }); } return { type: "queue_after_active" }; } diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index fab0276e55ef..b1265de02ff5 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -73,7 +73,11 @@ import { SHARED_WORKSPACE_RESTORE_MESSAGE, } from "./CheckpointRestoreSafety.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; -import { CommandPolicyV2, resolveMessageDispatchIntent } from "./CommandPolicy.ts"; +import { + CommandPolicyV2, + isNativeMaintenanceCommand, + resolveMessageDispatchIntent, +} from "./CommandPolicy.ts"; import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; import { notificationTurnItem } from "./Notification.ts"; @@ -357,18 +361,6 @@ function wakeWorkStartedAt( return previous === undefined ? {} : { workStartedAt: orchestrationV2RunWorkStartedAt(previous) }; } -/** A native /compact or /logout turn: provider maintenance, not agent work. */ -export function isNativeMaintenanceCommand(message: { - readonly text: string; - readonly attachments: ReadonlyArray; - readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined; -}): boolean { - return ( - message.attachments.length === 0 && - ["/compact", "/logout"].includes(message.text.trim().toLowerCase()) - ); -} - const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 013e7a07fbc4..dc57342565ad 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -11,7 +11,7 @@ import * as Effect from "effect/Effect"; import type { ProjectionRuntimeRecoveryState } from "./ProjectionStore.ts"; import * as ServerSettings from "../serverSettings.ts"; -import { isNativeMaintenanceCommand } from "./Orchestrator.ts"; +import { isNativeMaintenanceCommand } from "./CommandPolicy.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import { isRestartNoteSource, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ec924e2c694a..38121a56838f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11161,6 +11161,7 @@ export default function ChatView(props: ChatViewProps) { } phase={phase} canInterrupt={canInterruptRunningThread} + activeTurnIsCompaction={isCompacting} isConnecting={isConnecting} isSendBusy={isSendBusy || isSavingQueuedEdit || isResuming} canResume={resumableRunId !== null || hasHeldQueuedRuns} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 36b2b2a6a271..37ea25d45cd2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1361,6 +1361,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( } | null; isRunning: boolean; canInterrupt: boolean; + activeTurnIsCompaction: boolean; followUpBehavior: "queue" | "steer"; alternateShortcutLabel: string | null; showPlanFollowUpPrompt: boolean; @@ -1400,6 +1401,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( pendingAction={props.pendingAction} isRunning={props.isRunning} canInterrupt={props.canInterrupt} + activeTurnIsCompaction={props.activeTurnIsCompaction} followUpBehavior={props.followUpBehavior} alternateShortcutLabel={props.alternateShortcutLabel} showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} @@ -1529,6 +1531,8 @@ export interface ChatComposerProps { phase: SessionPhase; /** Stop is offered: a run is preparing, starting, or running. */ canInterrupt: boolean; + /** The in-flight turn is context compaction, so follow-ups queue behind it. */ + activeTurnIsCompaction: boolean; isConnecting: boolean; isSendBusy: boolean; canResume: boolean; @@ -1705,6 +1709,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectSelectionRequired, phase, canInterrupt, + activeTurnIsCompaction, isConnecting, isSendBusy, canResume, @@ -4185,6 +4190,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: false, activeTurnDefault: settings.followUpBehavior, + activeTurnIsCompaction, }), submissionIntent, ); @@ -4206,6 +4212,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) noProviderAvailable, onSend, settings.followUpBehavior, + activeTurnIsCompaction, phase, promptRef, shouldBlurMobileComposerOnSubmit, @@ -4220,10 +4227,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: event.metaKey || event.ctrlKey, activeTurnDefault: settings.followUpBehavior, + activeTurnIsCompaction, }), ); }, - [phase, settings.followUpBehavior, submitComposer], + [phase, activeTurnIsCompaction, settings.followUpBehavior, submitComposer], ); const submitCitationAndSend = useCallback(() => { submitComposer( @@ -4232,9 +4240,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: false, activeTurnDefault: settings.followUpBehavior, + activeTurnIsCompaction, }), ); - }, [phase, settings.followUpBehavior, submitComposer]); + }, [phase, activeTurnIsCompaction, settings.followUpBehavior, submitComposer]); const compactThreadContext = useCallback(() => { if ( compactDisabled || @@ -4414,6 +4423,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: submissionIntent === "alternate", activeTurnDefault: settings.followUpBehavior, + activeTurnIsCompaction, }), submissionIntent, ); @@ -7513,6 +7523,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingAction={pendingPrimaryAction} isRunning={phase === "running"} canInterrupt={canInterrupt} + activeTurnIsCompaction={activeTurnIsCompaction} followUpBehavior={settings.followUpBehavior} alternateShortcutLabel={shortcutLabelForCommand( keybindings, diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 81bce817fe4a..6e52d62b659b 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -31,6 +31,7 @@ interface ComposerPrimaryActionsProps { isRunning: boolean; /** Stop can reach a run, including one still preparing or starting. */ canInterrupt: boolean; + activeTurnIsCompaction?: boolean; followUpBehavior?: "queue" | "steer"; alternateShortcutLabel?: string | null; showPlanFollowUpPrompt: boolean; @@ -83,6 +84,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ pendingAction, isRunning, canInterrupt, + activeTurnIsCompaction = false, followUpBehavior = "steer", alternateShortcutLabel = null, showPlanFollowUpPrompt, @@ -112,9 +114,18 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ resolveComposerDispatchMode({ running: isRunning, activeTurnDefault: followUpBehavior, + activeTurnIsCompaction, alternateModifier: shortcutModifiers.metaKey || shortcutModifiers.ctrlKey, }) === "queue"; - const alternateAction = alternateComposerDispatchAction(followUpBehavior); + const alternateAction = alternateComposerDispatchAction(followUpBehavior, activeTurnIsCompaction); + // What a plain click delivers while the turn runs: the configured default, + // except automatic follow-ups during compaction, which queue. + const effectiveDefaultAction = resolveComposerDispatchMode({ + running: isRunning, + activeTurnDefault: followUpBehavior, + activeTurnIsCompaction, + alternateModifier: false, + }); const isSendDisabled = sendDisabledReason !== null; const stageBackdropVariant = useSidebarStageBackdropVariant( environmentIdentificationMode === "artwork", @@ -276,7 +287,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ const submitTooltip = submitStatus ?? (isRunning && !isEditingQueuedMessage - ? `Click to ${followUpBehavior}, Ctrl/⌘-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}` + ? effectiveDefaultAction === alternateAction + ? `Click to ${effectiveDefaultAction}` + : `Click to ${effectiveDefaultAction}, Ctrl/⌘-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}` : submitLabel); const sendButton = ( diff --git a/packages/client-runtime/src/state/composerDispatch.test.ts b/packages/client-runtime/src/state/composerDispatch.test.ts index c2d7cfb12693..024649fcbd5b 100644 --- a/packages/client-runtime/src/state/composerDispatch.test.ts +++ b/packages/client-runtime/src/state/composerDispatch.test.ts @@ -69,4 +69,53 @@ describe("resolveComposerDispatchMode", () => { expect(alternateComposerDispatchAction("steer")).toBe("queue"); expect(alternateComposerDispatchAction()).toBe("queue"); }); + + it("queues automatic follow-ups while the active turn is compacting", () => { + expect( + resolveComposerDispatchMode({ + running: true, + alternateModifier: false, + activeTurnIsCompaction: true, + }), + ).toBe("queue"); + expect( + resolveComposerDispatchMode({ + running: true, + alternateModifier: true, + activeTurnDefault: "steer", + activeTurnIsCompaction: true, + }), + ).toBe("queue"); + expect( + resolveComposerDispatchMode({ + running: false, + alternateModifier: false, + activeTurnIsCompaction: true, + }), + ).toBe("auto"); + }); + + it("preserves explicit steer and restart while compacting", () => { + expect( + resolveComposerDispatchMode({ + running: true, + alternateModifier: true, + activeTurnDefault: "queue", + activeTurnIsCompaction: true, + }), + ).toBe("steer"); + expect( + resolveComposerDispatchMode({ + running: true, + alternateModifier: false, + activeTurnDefault: "restart", + activeTurnIsCompaction: true, + }), + ).toBe("restart"); + expect(alternateComposerDispatchAction("queue", true)).toBe("steer"); + }); + + it("labels the alternate as queue while compacting", () => { + expect(alternateComposerDispatchAction("steer", true)).toBe("queue"); + }); }); diff --git a/packages/client-runtime/src/state/composerDispatch.ts b/packages/client-runtime/src/state/composerDispatch.ts index 4e5f9b6ae119..264a5c5120b6 100644 --- a/packages/client-runtime/src/state/composerDispatch.ts +++ b/packages/client-runtime/src/state/composerDispatch.ts @@ -13,20 +13,26 @@ export function resolveComposerDispatchMode(input: { readonly running: boolean; readonly alternateModifier: boolean; readonly activeTurnDefault?: ActiveTurnComposerAction; + /** The in-flight turn is context compaction, which cannot take a steer, so + * automatic follow-ups queue behind it. */ + readonly activeTurnIsCompaction?: boolean; }): ComposerDispatchMode { if (!input.running) return "auto"; const defaultAction = input.activeTurnDefault ?? "steer"; if (input.alternateModifier) return defaultAction === "queue" ? "steer" : "queue"; + if (input.activeTurnIsCompaction && defaultAction !== "restart") return "queue"; return defaultAction; } /** What the alternate would do, for labelling the affordance that triggers it. */ export function alternateComposerDispatchAction( activeTurnDefault?: ActiveTurnComposerAction, + activeTurnIsCompaction?: boolean, ): ActiveTurnComposerAction { return resolveComposerDispatchMode({ running: true, alternateModifier: true, ...(activeTurnDefault === undefined ? {} : { activeTurnDefault }), + ...(activeTurnIsCompaction === undefined ? {} : { activeTurnIsCompaction }), }) as ActiveTurnComposerAction; } From afb8b983b4a3768ae39c8cea4d968956da596d37 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:14:33 +1000 Subject: [PATCH 2/4] fix(composer): queue every follow-up while compaction runs Ctrl/Cmd-send with the queue default resolved to steer during compaction, which the server rejects. Compaction now overrides the alternate too. Mobile sent the follow-up as auto while /compact was still being dispatched, so it steered the ordinary run while the button said Queue. It now sends an explicit queue whenever compaction is in progress. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../threads/composerSendPresentation.test.ts | 32 ++++++++++++++++++- .../threads/composerSendPresentation.ts | 26 +++++++++++++++ .../src/state/use-thread-composer-state.ts | 22 ++++++------- .../orchestration-v2/CommandPolicy.test.ts | 4 +++ .../src/state/composerDispatch.test.ts | 14 ++------ .../src/state/composerDispatch.ts | 4 +-- 6 files changed, 75 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/features/threads/composerSendPresentation.test.ts b/apps/mobile/src/features/threads/composerSendPresentation.test.ts index f36d45997f1e..df57cc40a65a 100644 --- a/apps/mobile/src/features/threads/composerSendPresentation.test.ts +++ b/apps/mobile/src/features/threads/composerSendPresentation.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveComposerSendPresentation } from "./composerSendPresentation"; +import { + resolveComposerSendPresentation, + resolveFollowUpDispatchMode, +} from "./composerSendPresentation"; const idle = { editingQueuedMessage: false, @@ -79,3 +82,30 @@ describe("resolveComposerSendPresentation", () => { expect(presentation.offersFollowUpChoice).toBe(false); }); }); + +describe("resolveFollowUpDispatchMode", () => { + const running = { + running: true, + canSteer: true, + isCompacting: false, + followUpBehavior: "steer", + } as const; + + it("lets the server decide while the thread is idle", () => { + expect(resolveFollowUpDispatchMode({ ...running, running: false })).toBeNull(); + }); + + it("sends steering as auto and queueing as queue", () => { + expect(resolveFollowUpDispatchMode(running)).toBe("auto"); + expect(resolveFollowUpDispatchMode({ ...running, followUpBehavior: "queue" })).toBe("queue"); + expect(resolveFollowUpDispatchMode({ ...running, followUpOverride: "queue" })).toBe("queue"); + }); + + it("queues explicitly while compacting, matching the Queue label", () => { + // While /compact is still being dispatched the active run is the ordinary + // one, which an auto send would steer. + expect(resolveFollowUpDispatchMode({ ...running, canSteer: false, isCompacting: true })).toBe( + "queue", + ); + }); +}); diff --git a/apps/mobile/src/features/threads/composerSendPresentation.ts b/apps/mobile/src/features/threads/composerSendPresentation.ts index e8d02f4edf67..8702456c0bd8 100644 --- a/apps/mobile/src/features/threads/composerSendPresentation.ts +++ b/apps/mobile/src/features/threads/composerSendPresentation.ts @@ -1,5 +1,6 @@ import { alternateComposerDispatchAction, + resolveComposerDispatchMode, type ActiveTurnComposerAction, } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -65,3 +66,28 @@ export function resolveComposerSendPresentation(input: { offersFollowUpChoice: input.canSteer, }; } + +/** + * The outbox dispatch mode for a send, or null when the thread is idle and the + * server decides. Steering travels as "auto" so a turn that ends before the + * outbox delivers degrades to a queued run instead of failing the delivery and + * bouncing the message back into the draft. + */ +export function resolveFollowUpDispatchMode(input: { + readonly running: boolean; + readonly canSteer: boolean; + readonly isCompacting: boolean; + readonly followUpBehavior: FollowUpBehavior; + readonly followUpOverride?: ActiveTurnComposerAction; +}): "queue" | "auto" | null { + const action = resolveComposerDispatchMode({ + // Compaction queues explicitly: while /compact is still being dispatched + // the active run is the ordinary one, which an auto send would steer. + running: input.running && (input.canSteer || input.isCompacting), + alternateModifier: + input.followUpOverride !== undefined && input.followUpOverride !== input.followUpBehavior, + activeTurnDefault: input.followUpBehavior, + activeTurnIsCompaction: input.isCompacting, + }); + return action === "auto" ? null : action === "queue" ? "queue" : "auto"; +} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 54f1045ce9a4..3288f6da4432 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -52,6 +52,7 @@ import { scopedThreadKey } from "../lib/scopedEntities"; import { buildThreadFeed } from "../lib/threadActivity"; import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed"; +import { resolveFollowUpDispatchMode } from "../features/threads/composerSendPresentation"; import { threadAllowsProviderSwitch } from "./thread-provider-switching"; import { appAtomRegistry } from "../state/atom-registry"; import { pendingThreadCreationMessage } from "./pending-thread-creation"; @@ -77,10 +78,7 @@ import { updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; -import { - resolveComposerDispatchMode, - type ActiveTurnComposerAction, -} from "@t3tools/client-runtime/state/composer-dispatch"; +import { type ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch"; import { Atom } from "effect/unstable/reactivity"; import { AsyncResult } from "effect/unstable/reactivity"; import { prepareTurnAttachments } from "../lib/attachmentUpload"; @@ -661,16 +659,13 @@ export function useThreadComposerState() { // Resolved here rather than at drain time: the outbox can deliver minutes // later, and the choice belongs to the moment the user pressed send. - // Steering travels as "auto" so a turn that ends in the meantime degrades - // to a queued run on the server instead of failing the delivery and - // bouncing the message back into the draft. - const followUpAction = resolveComposerDispatchMode({ - running: activeThreadBusy && canSteerActiveTurn, - alternateModifier: followUpOverride !== undefined && followUpOverride !== followUpBehavior, - activeTurnDefault: followUpBehavior, + const followUpDispatchMode = resolveFollowUpDispatchMode({ + running: activeThreadBusy, + canSteer: canSteerActiveTurn, + isCompacting, + followUpBehavior, + ...(followUpOverride === undefined ? {} : { followUpOverride }), }); - const followUpDispatchMode = - followUpAction === "auto" ? null : followUpAction === "queue" ? "queue" : "auto"; const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); @@ -724,6 +719,7 @@ export function useThreadComposerState() { activeThreadBusy, canSteerActiveTurn, followUpBehavior, + isCompacting, saveQueuedRunEdit, selectedEnvironmentRuntime?.connectionState, selectedEnvironmentRuntime?.serverConfig, diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 233f0b1597b0..53a66bf5bcae 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -192,6 +192,10 @@ it.each(["/Logout", "/LOGOUT"])("preserves ordinary delivery for %s", (text) => runs: base.runs.map((run) => ({ ...run, userMessageId: messageId })), messages: [{ id: messageId, role: "user", text, attachments: [] }], } as unknown as OrchestrationV2ThreadProjection; + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent(projection, { type: "start_immediately" }, "auto"), + { type: "steer_active", targetRunId: activeRunId }, + ); assert.deepEqual( CommandPolicy.resolveMessageDispatchIntent(projection, { type: "start_immediately" }, "steer"), { type: "steer_active", targetRunId: activeRunId }, diff --git a/packages/client-runtime/src/state/composerDispatch.test.ts b/packages/client-runtime/src/state/composerDispatch.test.ts index 024649fcbd5b..6bb116f5c427 100644 --- a/packages/client-runtime/src/state/composerDispatch.test.ts +++ b/packages/client-runtime/src/state/composerDispatch.test.ts @@ -95,7 +95,7 @@ describe("resolveComposerDispatchMode", () => { ).toBe("auto"); }); - it("preserves explicit steer and restart while compacting", () => { + it("queues the alternate too while compacting, since the run cannot take a steer", () => { expect( resolveComposerDispatchMode({ running: true, @@ -103,16 +103,8 @@ describe("resolveComposerDispatchMode", () => { activeTurnDefault: "queue", activeTurnIsCompaction: true, }), - ).toBe("steer"); - expect( - resolveComposerDispatchMode({ - running: true, - alternateModifier: false, - activeTurnDefault: "restart", - activeTurnIsCompaction: true, - }), - ).toBe("restart"); - expect(alternateComposerDispatchAction("queue", true)).toBe("steer"); + ).toBe("queue"); + expect(alternateComposerDispatchAction("queue", true)).toBe("queue"); }); it("labels the alternate as queue while compacting", () => { diff --git a/packages/client-runtime/src/state/composerDispatch.ts b/packages/client-runtime/src/state/composerDispatch.ts index 264a5c5120b6..6411349c27e8 100644 --- a/packages/client-runtime/src/state/composerDispatch.ts +++ b/packages/client-runtime/src/state/composerDispatch.ts @@ -14,13 +14,13 @@ export function resolveComposerDispatchMode(input: { readonly alternateModifier: boolean; readonly activeTurnDefault?: ActiveTurnComposerAction; /** The in-flight turn is context compaction, which cannot take a steer, so - * automatic follow-ups queue behind it. */ + * every follow-up, the alternate included, queues behind it. */ readonly activeTurnIsCompaction?: boolean; }): ComposerDispatchMode { if (!input.running) return "auto"; + if (input.activeTurnIsCompaction) return "queue"; const defaultAction = input.activeTurnDefault ?? "steer"; if (input.alternateModifier) return defaultAction === "queue" ? "steer" : "queue"; - if (input.activeTurnIsCompaction && defaultAction !== "restart") return "queue"; return defaultAction; } From f1d5024e97910297d6db81b95830bdb4e1d7e7dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:25:57 +1000 Subject: [PATCH 3/4] fix(mobile): keep follow-up dispatch helper in lib The mobile dependency graph forbids state importing from features, so the helper that resolves the outbox dispatch mode moves next to the follow-up behavior preference it reads. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../threads/composerSendPresentation.test.ts | 32 +------------------ .../threads/composerSendPresentation.ts | 26 --------------- apps/mobile/src/lib/followUpBehavior.test.ts | 30 +++++++++++++++++ apps/mobile/src/lib/followUpBehavior.ts | 30 ++++++++++++++++- .../src/state/use-thread-composer-state.ts | 3 +- 5 files changed, 61 insertions(+), 60 deletions(-) create mode 100644 apps/mobile/src/lib/followUpBehavior.test.ts diff --git a/apps/mobile/src/features/threads/composerSendPresentation.test.ts b/apps/mobile/src/features/threads/composerSendPresentation.test.ts index df57cc40a65a..f36d45997f1e 100644 --- a/apps/mobile/src/features/threads/composerSendPresentation.test.ts +++ b/apps/mobile/src/features/threads/composerSendPresentation.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - resolveComposerSendPresentation, - resolveFollowUpDispatchMode, -} from "./composerSendPresentation"; +import { resolveComposerSendPresentation } from "./composerSendPresentation"; const idle = { editingQueuedMessage: false, @@ -82,30 +79,3 @@ describe("resolveComposerSendPresentation", () => { expect(presentation.offersFollowUpChoice).toBe(false); }); }); - -describe("resolveFollowUpDispatchMode", () => { - const running = { - running: true, - canSteer: true, - isCompacting: false, - followUpBehavior: "steer", - } as const; - - it("lets the server decide while the thread is idle", () => { - expect(resolveFollowUpDispatchMode({ ...running, running: false })).toBeNull(); - }); - - it("sends steering as auto and queueing as queue", () => { - expect(resolveFollowUpDispatchMode(running)).toBe("auto"); - expect(resolveFollowUpDispatchMode({ ...running, followUpBehavior: "queue" })).toBe("queue"); - expect(resolveFollowUpDispatchMode({ ...running, followUpOverride: "queue" })).toBe("queue"); - }); - - it("queues explicitly while compacting, matching the Queue label", () => { - // While /compact is still being dispatched the active run is the ordinary - // one, which an auto send would steer. - expect(resolveFollowUpDispatchMode({ ...running, canSteer: false, isCompacting: true })).toBe( - "queue", - ); - }); -}); diff --git a/apps/mobile/src/features/threads/composerSendPresentation.ts b/apps/mobile/src/features/threads/composerSendPresentation.ts index 8702456c0bd8..e8d02f4edf67 100644 --- a/apps/mobile/src/features/threads/composerSendPresentation.ts +++ b/apps/mobile/src/features/threads/composerSendPresentation.ts @@ -1,6 +1,5 @@ import { alternateComposerDispatchAction, - resolveComposerDispatchMode, type ActiveTurnComposerAction, } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -66,28 +65,3 @@ export function resolveComposerSendPresentation(input: { offersFollowUpChoice: input.canSteer, }; } - -/** - * The outbox dispatch mode for a send, or null when the thread is idle and the - * server decides. Steering travels as "auto" so a turn that ends before the - * outbox delivers degrades to a queued run instead of failing the delivery and - * bouncing the message back into the draft. - */ -export function resolveFollowUpDispatchMode(input: { - readonly running: boolean; - readonly canSteer: boolean; - readonly isCompacting: boolean; - readonly followUpBehavior: FollowUpBehavior; - readonly followUpOverride?: ActiveTurnComposerAction; -}): "queue" | "auto" | null { - const action = resolveComposerDispatchMode({ - // Compaction queues explicitly: while /compact is still being dispatched - // the active run is the ordinary one, which an auto send would steer. - running: input.running && (input.canSteer || input.isCompacting), - alternateModifier: - input.followUpOverride !== undefined && input.followUpOverride !== input.followUpBehavior, - activeTurnDefault: input.followUpBehavior, - activeTurnIsCompaction: input.isCompacting, - }); - return action === "auto" ? null : action === "queue" ? "queue" : "auto"; -} diff --git a/apps/mobile/src/lib/followUpBehavior.test.ts b/apps/mobile/src/lib/followUpBehavior.test.ts new file mode 100644 index 000000000000..90e022b8bf97 --- /dev/null +++ b/apps/mobile/src/lib/followUpBehavior.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveFollowUpDispatchMode } from "./followUpBehavior"; + +describe("resolveFollowUpDispatchMode", () => { + const running = { + running: true, + canSteer: true, + isCompacting: false, + followUpBehavior: "steer", + } as const; + + it("lets the server decide while the thread is idle", () => { + expect(resolveFollowUpDispatchMode({ ...running, running: false })).toBeNull(); + }); + + it("sends steering as auto and queueing as queue", () => { + expect(resolveFollowUpDispatchMode(running)).toBe("auto"); + expect(resolveFollowUpDispatchMode({ ...running, followUpBehavior: "queue" })).toBe("queue"); + expect(resolveFollowUpDispatchMode({ ...running, followUpOverride: "queue" })).toBe("queue"); + }); + + it("queues explicitly while compacting, matching the Queue label", () => { + // While /compact is still being dispatched the active run is the ordinary + // one, which an auto send would steer. + expect(resolveFollowUpDispatchMode({ ...running, canSteer: false, isCompacting: true })).toBe( + "queue", + ); + }); +}); diff --git a/apps/mobile/src/lib/followUpBehavior.ts b/apps/mobile/src/lib/followUpBehavior.ts index dfb5cae60ac6..11129e45c5af 100644 --- a/apps/mobile/src/lib/followUpBehavior.ts +++ b/apps/mobile/src/lib/followUpBehavior.ts @@ -1,4 +1,7 @@ -import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch"; +import { + resolveComposerDispatchMode, + type ActiveTurnComposerAction, +} from "@t3tools/client-runtime/state/composer-dispatch"; /** * What the send button does while a turn is already running: `queue` waits for @@ -11,3 +14,28 @@ import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/com export type FollowUpBehavior = Extract; export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = "queue"; + +/** + * The outbox dispatch mode for a send, or null when the thread is idle and the + * server decides. Steering travels as "auto" so a turn that ends before the + * outbox delivers degrades to a queued run instead of failing the delivery and + * bouncing the message back into the draft. + */ +export function resolveFollowUpDispatchMode(input: { + readonly running: boolean; + readonly canSteer: boolean; + readonly isCompacting: boolean; + readonly followUpBehavior: FollowUpBehavior; + readonly followUpOverride?: ActiveTurnComposerAction; +}): "queue" | "auto" | null { + const action = resolveComposerDispatchMode({ + // Compaction queues explicitly: while /compact is still being dispatched + // the active run is the ordinary one, which an auto send would steer. + running: input.running && (input.canSteer || input.isCompacting), + alternateModifier: + input.followUpOverride !== undefined && input.followUpOverride !== input.followUpBehavior, + activeTurnDefault: input.followUpBehavior, + activeTurnIsCompaction: input.isCompacting, + }); + return action === "auto" ? null : action === "queue" ? "queue" : "auto"; +} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 3288f6da4432..4afce81a1f3d 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -52,7 +52,6 @@ import { scopedThreadKey } from "../lib/scopedEntities"; import { buildThreadFeed } from "../lib/threadActivity"; import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed"; -import { resolveFollowUpDispatchMode } from "../features/threads/composerSendPresentation"; import { threadAllowsProviderSwitch } from "./thread-provider-switching"; import { appAtomRegistry } from "../state/atom-registry"; import { pendingThreadCreationMessage } from "./pending-thread-creation"; @@ -82,7 +81,7 @@ import { type ActiveTurnComposerAction } from "@t3tools/client-runtime/state/com import { Atom } from "effect/unstable/reactivity"; import { AsyncResult } from "effect/unstable/reactivity"; import { prepareTurnAttachments } from "../lib/attachmentUpload"; -import { DEFAULT_FOLLOW_UP_BEHAVIOR } from "../lib/followUpBehavior"; +import { DEFAULT_FOLLOW_UP_BEHAVIOR, resolveFollowUpDispatchMode } from "../lib/followUpBehavior"; import { mobilePreferencesAtom } from "./preferences"; import { environmentThreadDetails } from "./threads"; import { From 1ba39f268f76bf80c4a7fb6cb85bf4c2e918adf3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:00:28 +1100 Subject: [PATCH 4/4] fix(v2): keep main's maintenance-command matching Main now checks /compact and /logout case-insensitively and restart continuation relies on that. The predicate only moves to CommandPolicy, so it keeps main's definition, and RestartContinuation imports it from there. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../orchestration-v2/CommandPolicy.test.ts | 28 +------------------ .../src/orchestration-v2/CommandPolicy.ts | 6 ++-- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 53a66bf5bcae..42381219724e 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -143,7 +143,7 @@ it("targets the latest active run for explicit steer and restart intent", () => ); }); -it.each(["/compact", " /COMPACT ", "/logout"])( +it.each(["/compact", " /COMPACT ", "/logout", " /LOGOUT "])( "queues automatic follow-ups behind %s and preserves explicit intent", (text) => { const messageId = MessageId.make("command-policy-compaction-message"); @@ -184,32 +184,6 @@ it.each(["/compact", " /COMPACT ", "/logout"])( }, ); -it.each(["/Logout", "/LOGOUT"])("preserves ordinary delivery for %s", (text) => { - const messageId = MessageId.make("command-policy-mixed-case-message"); - const base = dispatchProjection(baseCapabilities); - const projection = { - ...base, - runs: base.runs.map((run) => ({ ...run, userMessageId: messageId })), - messages: [{ id: messageId, role: "user", text, attachments: [] }], - } as unknown as OrchestrationV2ThreadProjection; - assert.deepEqual( - CommandPolicy.resolveMessageDispatchIntent(projection, { type: "start_immediately" }, "auto"), - { type: "steer_active", targetRunId: activeRunId }, - ); - assert.deepEqual( - CommandPolicy.resolveMessageDispatchIntent(projection, { type: "start_immediately" }, "steer"), - { type: "steer_active", targetRunId: activeRunId }, - ); - assert.deepEqual( - CommandPolicy.resolveMessageDispatchIntent( - projection, - { type: "start_immediately" }, - "restart", - ), - { type: "restart_active", targetRunId: activeRunId }, - ); -}); - it("keeps steering an ordinary run alongside a compacted message in history", () => { const messageId = MessageId.make("command-policy-ordinary-message"); const base = dispatchProjection(baseCapabilities); diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index b47e8bb080a1..84fe351778b7 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -117,15 +117,15 @@ type MessageDispatchMode = Extract< { readonly type: "message.dispatch" } >["dispatchMode"]; -/** Native commands the provider executes as its own whole-turn task, so they - * own the run they start and cannot take steering or a restart. */ +/** A native /compact or /logout turn: provider maintenance, not agent work. It + * owns the run it starts and cannot take steering or a restart. */ export function isNativeMaintenanceCommand(message: { readonly text: string; readonly attachments: ReadonlyArray; }): boolean { return ( message.attachments.length === 0 && - (message.text.trim().toLowerCase() === "/compact" || message.text.trim() === "/logout") + ["/compact", "/logout"].includes(message.text.trim().toLowerCase()) ); }