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 0db48453529c..f1b038fb5d87 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -77,14 +77,11 @@ 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/reactivity"; import { AsyncResult } from "effect/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 { @@ -312,7 +309,6 @@ export function useThreadComposerState() { threadId: selectedThreadShell.id, }), ); - const canSteerActiveTurn = queueWorkflow?.canPromoteToSteer === true; const queuedRunEdit = useQueuedRunEdit(selectedThreadKey); const composerDraftKey = selectedThreadKey === null @@ -395,6 +391,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( () => @@ -659,16 +658,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); @@ -722,6 +718,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 256ecd45e75a..42381219724e 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,69 @@ it("targets the latest active run for explicit steer and restart intent", () => ); }); +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"); + 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("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..84fe351778b7 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"]; +/** 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 && + ["/compact", "/logout"].includes(message.text.trim().toLowerCase()) + ); +} + +/** 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 de100d291b8f..60d04dc39740 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -75,7 +75,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"; @@ -359,18 +363,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()) - ); -} - /** A native `/goal` command. It changes the provider's goal, so it never steers a running turn. */ function isGoalCommand(message: { readonly text: string; 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 9f6256ff7d76..c77f7294606a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11291,6 +11291,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 ee9c313cbec7..028fe9531e0f 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, @@ -4193,6 +4198,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: false, activeTurnDefault: settings.followUpBehavior, + activeTurnIsCompaction, }), submissionIntent, ); @@ -4214,6 +4220,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) noProviderAvailable, onSend, settings.followUpBehavior, + activeTurnIsCompaction, phase, promptRef, shouldBlurMobileComposerOnSubmit, @@ -4228,10 +4235,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( @@ -4240,9 +4248,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 || @@ -4422,6 +4431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) running: phase === "running", alternateModifier: submissionIntent === "alternate", activeTurnDefault: settings.followUpBehavior, + activeTurnIsCompaction, }), submissionIntent, ); @@ -7521,6 +7531,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..6bb116f5c427 100644 --- a/packages/client-runtime/src/state/composerDispatch.test.ts +++ b/packages/client-runtime/src/state/composerDispatch.test.ts @@ -69,4 +69,45 @@ 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("queues the alternate too while compacting, since the run cannot take a steer", () => { + expect( + resolveComposerDispatchMode({ + running: true, + alternateModifier: true, + activeTurnDefault: "queue", + activeTurnIsCompaction: true, + }), + ).toBe("queue"); + expect(alternateComposerDispatchAction("queue", true)).toBe("queue"); + }); + + 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..6411349c27e8 100644 --- a/packages/client-runtime/src/state/composerDispatch.ts +++ b/packages/client-runtime/src/state/composerDispatch.ts @@ -13,8 +13,12 @@ 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 + * 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"; return defaultAction; @@ -23,10 +27,12 @@ export function resolveComposerDispatchMode(input: { /** 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; }