From 855216e7eecbe1428c047a0915c63b07b7e91fbf Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:01:07 -0800 Subject: [PATCH 1/4] fix(web,mobile): resume a blocked goal from the goal line Web hid Resume while the agent worked, leaving only Clear. Resume now shows whenever the goal is paused or blocked; while working it queues /goal resume behind the current turn instead of stopping it. Mobile's goal row gains the same Resume action. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadDetailScreen.tsx | 11 ++++++++ .../features/threads/ThreadRouteScreen.tsx | 28 +++++++++++++++++++ .../threads/floating-working-control.tsx | 3 +- .../threads/floating-working-status.ts | 7 ++++- .../components/chat/ThreadActivityBanners.tsx | 20 +++++++++---- .../components/chat/useThreadLifecycle.tsx | 7 ++++- docs/user/composer.md | 2 ++ 7 files changed, 70 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index bf8c77ade6834..2bb92c1dc5718 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -298,6 +298,7 @@ export interface ThreadDetailScreenProps { readonly onNativePasteText: (paste: ComposerTextPaste) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; + readonly onResumeGoal?: () => Promise; /** Present while the thread's queue is held. */ readonly onResumeQueue: (() => Promise) | null; readonly offersSideChat: boolean; @@ -898,11 +899,21 @@ function ThreadDetailBody( }; } if (props.selectedThread.goal !== null && contentPresentationKind === "ready") { + const onResumeGoal = props.onResumeGoal; const goal = presentProviderGoal(props.selectedThread.goal, false); return { kind: "goal", label: goal.title, accessibilityLabel: `${goal.title}: ${goal.objective}`, + action: + goal.canResume && onResumeGoal !== undefined + ? { + label: "Resume", + accessibilityLabel: "Resume goal", + disabled: !props.canOperateThread, + onPress: () => void onResumeGoal(), + } + : null, }; } if (extensionStatus !== null && contentPresentationKind === "ready") { diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index ff82315af7303..35fb2531fb9da 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -707,6 +707,33 @@ function ThreadRouteContent( } }; + const handleResumeGoal = async () => { + if (!selectedThread) return; + const runtimeMode = composer.runtimeMode ?? selectedThread.runtimeMode; + const interactionMode = composer.interactionMode ?? selectedThread.interactionMode; + const result = await continueThreadRun({ + environmentId: selectedThread.environmentId, + input: { + creationSource: "mobile", + threadId: selectedThread.id, + ...(composer.modelSelection ? { modelSelection: composer.modelSelection } : {}), + message: { + messageId: MessageId.make(uuidv4()), + role: "user", + text: "/goal resume", + attachments: [], + }, + runtimeMode, + interactionMode, + createdAt: new Date().toISOString(), + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert("Could not resume goal", error instanceof Error ? error.message : "Try again."); + } + }; + const handleOpenTerminal = (nextTerminalId?: string | null) => { if ( !selectedThread || @@ -1090,6 +1117,7 @@ function ThreadRouteContent( onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread} + onResumeGoal={handleResumeGoal} onResumeQueue={ composer.queueHeld || composer.resumableRunId !== null ? handleResumeQueue : null } diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index a41fc2b3de39c..e8fbcd2ec03d1 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -92,7 +92,8 @@ export function FloatingWorkingControl(props: { const action = props.status?.kind === "background" || props.status?.kind === "lifecycle" || - props.status?.kind === "stop" + props.status?.kind === "stop" || + props.status?.kind === "goal" ? props.status.action : null; const [agentsWidth, setAgentsWidth] = useState(0); diff --git a/apps/mobile/src/features/threads/floating-working-status.ts b/apps/mobile/src/features/threads/floating-working-status.ts index e3c137ebfd259..8a3877b791990 100644 --- a/apps/mobile/src/features/threads/floating-working-status.ts +++ b/apps/mobile/src/features/threads/floating-working-status.ts @@ -37,7 +37,12 @@ export type FloatingWorkingStatus = readonly unresponsive: boolean; readonly action: FloatingStatusAction | null; } - | { readonly kind: "goal"; readonly label: string; readonly accessibilityLabel: string } + | { + readonly kind: "goal"; + readonly label: string; + readonly accessibilityLabel: string; + readonly action: FloatingStatusAction | null; + } | { readonly kind: "extension"; readonly label: string; readonly onPress: () => void } | { readonly kind: "preparing"; readonly label: string } | { diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index d5a6973213389..5b9fbeb52b31a 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -90,7 +90,11 @@ export function goalNowItem(input: { readonly threadId: ThreadId; readonly goal: OrchestrationV2ProviderGoal; readonly isWorking: boolean; - readonly onSendCommand: (text: string, failureMessage: string) => Promise; + readonly onSendCommand: ( + text: string, + failureMessage: string, + dispatchMode?: "queue", + ) => Promise; }): ThreadNowItem { const { isWorking, onSendCommand } = input; const presentation = presentProviderGoal(input.goal, isWorking); @@ -102,15 +106,21 @@ export function goalNowItem(input: { ? presentation.title : `${presentation.title} · ${presentation.usage}`, description: presentation.objective, - // Clear stops goal work; Resume would stop unrelated work, so it waits. + // Clear stops goal work; Resume queues behind the current turn so it never stops unrelated work. actions: ( <> - {presentation.canResume && !isWorking ? ( + {presentation.canResume ? ( <> void onSendCommand("/goal resume", "Failed to resume goal.")} + onClick={() => + void onSendCommand( + "/goal resume", + "Failed to resume goal.", + isWorking ? "queue" : undefined, + ) + } > - Resume + {isWorking ? "Resume after turn" : "Resume"} · diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index fab21a17b32f9..e2a5485b811b1 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -470,7 +470,11 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { onOpenThread: onOpenRelatedThreadStable, }); - const sendStandaloneCommand = async (text: string, failureMessage: string) => { + const sendStandaloneCommand = async ( + text: string, + failureMessage: string, + dispatchMode?: "queue", + ) => { if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return; const context = composerRef.current?.getSendContext(); if (!context?.providerAvailable) return; @@ -514,6 +518,7 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { runtimeMode, interactionMode: context.interactionMode, createdAt, + ...(dispatchMode === undefined ? {} : { dispatchMode }), }, }); if (result._tag === "Failure") { diff --git a/docs/user/composer.md b/docs/user/composer.md index 90f862149e89f..7b9f7e4f8ec97 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -436,6 +436,8 @@ its progress. - `/goal` alone shows the current goal. `/goal clear` removes it. - Codex and OpenCode also support `/goal pause` and `/goal resume`. Stopping their goal pauses it. Codex also pauses when its work ends without meeting the goal. + **Resume** on the goal line restarts a paused or blocked goal. While the agent + works, it waits for the current turn to end. - Stopping Claude ends the current turn, but the goal stays set. Claude checks it again at the end of your next message. - Sending a new goal, or `/goal resume`, while the agent works stops the current From 38ab59c2a3a1565dbdbb6521ff682a5543dd51de Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Nov 2026 19:02:14 -0800 Subject: [PATCH 2/4] fix(mobile): keep goal resume available during active turns --- .../features/threads/ThreadDetailScreen.tsx | 35 ++++++++++++------- .../features/threads/ThreadRouteScreen.tsx | 1 + .../threads/floating-working-control.tsx | 1 + .../threads/floating-working-status.ts | 6 +++- .../components/chat/ThreadActivityBanners.tsx | 1 - 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 2bb92c1dc5718..ba95954f89b85 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -841,8 +841,27 @@ function ThreadDetailBody( if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } + const goal = + props.selectedThread.goal === null + ? null + : presentProviderGoal(props.selectedThread.goal, props.activeWorkStartedAt !== null); + const onResumeGoal = props.onResumeGoal; + const goalAction = + goal?.canResume && onResumeGoal !== undefined + ? { + label: props.activeWorkStartedAt !== null ? "Resume after turn" : "Resume", + accessibilityLabel: + props.activeWorkStartedAt !== null ? "Resume goal after turn" : "Resume goal", + disabled: !props.canOperateThread, + onPress: () => void onResumeGoal(), + } + : null; if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { - return { kind: "working", startedAt: props.activeWorkStartedAt }; + return { + kind: "working", + startedAt: props.activeWorkStartedAt, + action: goalAction, + }; } if (props.selectedThread.groupDiscussionOpen && contentPresentationKind === "ready") { return { kind: "preparing", label: "Group discussion" }; @@ -898,22 +917,12 @@ function ThreadDetailBody( }, }; } - if (props.selectedThread.goal !== null && contentPresentationKind === "ready") { - const onResumeGoal = props.onResumeGoal; - const goal = presentProviderGoal(props.selectedThread.goal, false); + if (goal !== null && contentPresentationKind === "ready") { return { kind: "goal", label: goal.title, accessibilityLabel: `${goal.title}: ${goal.objective}`, - action: - goal.canResume && onResumeGoal !== undefined - ? { - label: "Resume", - accessibilityLabel: "Resume goal", - disabled: !props.canOperateThread, - onPress: () => void onResumeGoal(), - } - : null, + action: goalAction, }; } if (extensionStatus !== null && contentPresentationKind === "ready") { diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 35fb2531fb9da..6dbc20bef1688 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -723,6 +723,7 @@ function ThreadRouteContent( text: "/goal resume", attachments: [], }, + dispatchMode: composer.activeWorkStartedAt !== null ? "queue" : "auto", runtimeMode, interactionMode, createdAt: new Date().toISOString(), diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index e8fbcd2ec03d1..12b599ac6a85a 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -90,6 +90,7 @@ export function FloatingWorkingControl(props: { const extensionOnly = props.status?.kind === "extension"; const useGlass = NATIVE_LIQUID_GLASS_SUPPORTED && !extensionOnly; const action = + props.status?.kind === "working" || props.status?.kind === "background" || props.status?.kind === "lifecycle" || props.status?.kind === "stop" || diff --git a/apps/mobile/src/features/threads/floating-working-status.ts b/apps/mobile/src/features/threads/floating-working-status.ts index 8a3877b791990..ed137dc40428d 100644 --- a/apps/mobile/src/features/threads/floating-working-status.ts +++ b/apps/mobile/src/features/threads/floating-working-status.ts @@ -8,7 +8,11 @@ export interface FloatingStatusAction { } export type FloatingWorkingStatus = - | { readonly kind: "working"; readonly startedAt: string } + | { + readonly kind: "working"; + readonly startedAt: string; + readonly action: FloatingStatusAction | null; + } | { readonly kind: "syncing"; readonly label: string } | { readonly kind: "compacting" } | { diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 5b9fbeb52b31a..0ef52f5064123 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -106,7 +106,6 @@ export function goalNowItem(input: { ? presentation.title : `${presentation.title} · ${presentation.usage}`, description: presentation.objective, - // Clear stops goal work; Resume queues behind the current turn so it never stops unrelated work. actions: ( <> {presentation.canResume ? ( From cf4c6db0e2d58e022fd277623f4861c143738d0f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Nov 2026 19:25:50 -0800 Subject: [PATCH 3/4] fix: queue goal resume safely and persist mobile run modes --- .../features/threads/ThreadRouteScreen.tsx | 32 +++--- .../features/threads/resume-thread.test.ts | 29 +++++- .../src/features/threads/resume-thread.ts | 99 +++++++++++-------- .../ThreadActivityBanners.browser.test.tsx | 23 +++++ .../components/chat/ThreadActivityBanners.tsx | 8 +- 5 files changed, 120 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadActivityBanners.browser.test.tsx diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 6dbc20bef1688..193f21ee0374a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -111,7 +111,7 @@ import { threadRouteOpensDraft, } from "./thread-route-hydration"; import { ThreadHeader } from "./ThreadHeader"; -import { resumeThread } from "./resume-thread"; +import { continueThread, resumeThread } from "./resume-thread"; import { useAndroidWorkspaceBack, useWorkspaceBackNavigation, @@ -709,25 +709,17 @@ function ThreadRouteContent( const handleResumeGoal = async () => { if (!selectedThread) return; - const runtimeMode = composer.runtimeMode ?? selectedThread.runtimeMode; - const interactionMode = composer.interactionMode ?? selectedThread.interactionMode; - const result = await continueThreadRun({ - environmentId: selectedThread.environmentId, - input: { - creationSource: "mobile", - threadId: selectedThread.id, - ...(composer.modelSelection ? { modelSelection: composer.modelSelection } : {}), - message: { - messageId: MessageId.make(uuidv4()), - role: "user", - text: "/goal resume", - attachments: [], - }, - dispatchMode: composer.activeWorkStartedAt !== null ? "queue" : "auto", - runtimeMode, - interactionMode, - createdAt: new Date().toISOString(), - }, + const result = await continueThread({ + thread: selectedThread, + continuation: { kind: "goal" }, + runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode, + interactionMode: composer.interactionMode ?? selectedThread.interactionMode, + modelSelection: composer.modelSelection, + messageId: MessageId.make(uuidv4()), + createdAt: new Date().toISOString(), + setRuntimeMode: setThreadRuntimeMode, + setInteractionMode: setThreadInteractionMode, + startTurn: continueThreadRun, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); diff --git a/apps/mobile/src/features/threads/resume-thread.test.ts b/apps/mobile/src/features/threads/resume-thread.test.ts index 3520f72293f89..13b5d9aab40fc 100644 --- a/apps/mobile/src/features/threads/resume-thread.test.ts +++ b/apps/mobile/src/features/threads/resume-thread.test.ts @@ -3,7 +3,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { describe, expect, it } from "vite-plus/test"; -import { resumeThread } from "./resume-thread"; +import { continueThread, resumeThread } from "./resume-thread"; const runId = RunId.make("run-limited"); const failure = AsyncResult.failure(Cause.fail(new Error("not connected"))); @@ -64,14 +64,13 @@ describe("resumeThread", () => { expect(calls.map((call) => call.command)).toEqual(["startTurn"]); }); - it("saves changed modes to the thread before continuing", async () => { + it.each(["run", "goal"] as const)("saves changed modes before continuing a %s", async (kind) => { const { calls, base } = setup(); - await resumeThread({ + await continueThread({ ...base, runtimeMode: "approval-required", interactionMode: "plan", - resumableRunId: runId, - queueHeld: false, + continuation: kind === "run" ? { kind, runId } : { kind }, }); expect(calls.map((call) => call.command)).toEqual([ @@ -79,6 +78,10 @@ describe("resumeThread", () => { "setInteractionMode", "startTurn", ]); + expect(calls[2]?.input).toMatchObject({ + dispatchMode: kind === "goal" ? "queue" : "start", + message: { text: kind === "goal" ? "/goal resume" : "Continue where you left off." }, + }); }); it("resumes a held queue directly when no run needs continuing", async () => { @@ -87,4 +90,20 @@ describe("resumeThread", () => { expect(calls.map((call) => call.command)).toEqual(["resumeQueue"]); }); + + it.each(["run", "goal"] as const)( + "does not continue a %s when saving modes fails", + async (kind) => { + const { calls, base } = setup(); + const result = await continueThread({ + ...base, + continuation: kind === "run" ? { kind, runId } : { kind }, + runtimeMode: "approval-required", + setRuntimeMode: async () => failure, + }); + + expect(result).toBe(failure); + expect(calls).toEqual([]); + }, + ); }); diff --git a/apps/mobile/src/features/threads/resume-thread.ts b/apps/mobile/src/features/threads/resume-thread.ts index e112423868254..a349de0974c39 100644 --- a/apps/mobile/src/features/threads/resume-thread.ts +++ b/apps/mobile/src/features/threads/resume-thread.ts @@ -20,16 +20,13 @@ type Command = (request: { readonly input: Input; }) => Promise>; -/** Resolves to the first failed command's result, or the last successful one. */ -export async function resumeThread(input: { +interface ThreadContinuationInput { readonly thread: { readonly environmentId: EnvironmentId; readonly id: ThreadId; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; }; - readonly resumableRunId: RunId | null; - readonly queueHeld: boolean; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly modelSelection: ModelSelection | null; @@ -38,46 +35,70 @@ export async function resumeThread(input: { readonly setRuntimeMode: Command; readonly setInteractionMode: Command; readonly startTurn: Command; - readonly resumeQueue: Command; -}): Promise> { +} + +export async function continueThread( + input: ThreadContinuationInput & { + readonly continuation: + | { readonly kind: "goal" } + | { readonly kind: "run"; readonly runId: RunId }; + }, +): Promise> { const { thread, runtimeMode, interactionMode, createdAt } = input; const environmentId = thread.environmentId; - // The server holds the queue behind a stopped or usage-limited run until that run is continued. - if (input.resumableRunId !== null) { - // A turn on an existing thread runs with the thread's saved modes, not the turn's. - if (runtimeMode !== thread.runtimeMode) { - const saved = await input.setRuntimeMode({ - environmentId, - input: { threadId: thread.id, runtimeMode, createdAt }, - }); - if (saved._tag === "Failure") return saved; - } - if (interactionMode !== thread.interactionMode) { - const saved = await input.setInteractionMode({ - environmentId, - input: { threadId: thread.id, interactionMode, createdAt }, - }); - if (saved._tag === "Failure") return saved; - } - const continued = await input.startTurn({ + if (runtimeMode !== thread.runtimeMode) { + const saved = await input.setRuntimeMode({ + environmentId, + input: { threadId: thread.id, runtimeMode, createdAt }, + }); + if (saved._tag === "Failure") return saved; + } + if (interactionMode !== thread.interactionMode) { + const saved = await input.setInteractionMode({ environmentId, - input: { - creationSource: "mobile", - threadId: thread.id, - manualContinuationOfRunId: input.resumableRunId, - ...(input.modelSelection ? { modelSelection: input.modelSelection } : {}), - message: { - messageId: input.messageId, - role: "user", - text: "Continue where you left off.", - attachments: [], - }, - runtimeMode, - interactionMode, - dispatchMode: "start", + input: { threadId: thread.id, interactionMode, createdAt }, + }); + if (saved._tag === "Failure") return saved; + } + return input.startTurn({ + environmentId, + input: { + creationSource: "mobile", + threadId: thread.id, + ...(input.continuation.kind === "run" + ? { manualContinuationOfRunId: input.continuation.runId } + : {}), + ...(input.modelSelection ? { modelSelection: input.modelSelection } : {}), + message: { + messageId: input.messageId, + role: "user", + text: input.continuation.kind === "goal" ? "/goal resume" : "Continue where you left off.", + attachments: [], }, + runtimeMode, + interactionMode, + dispatchMode: input.continuation.kind === "goal" ? "queue" : "start", + createdAt, + }, + }); +} + +export async function resumeThread( + input: ThreadContinuationInput & { + readonly resumableRunId: RunId | null; + readonly queueHeld: boolean; + readonly resumeQueue: Command; + }, +): Promise> { + if (input.resumableRunId !== null) { + const continued = await continueThread({ + ...input, + continuation: { kind: "run", runId: input.resumableRunId }, }); if (continued._tag === "Failure" || !input.queueHeld) return continued; } - return input.resumeQueue({ environmentId, input: { threadId: thread.id } }); + return input.resumeQueue({ + environmentId: input.thread.environmentId, + input: { threadId: input.thread.id }, + }); } diff --git a/apps/web/src/components/chat/ThreadActivityBanners.browser.test.tsx b/apps/web/src/components/chat/ThreadActivityBanners.browser.test.tsx new file mode 100644 index 0000000000000..939542c9bcc85 --- /dev/null +++ b/apps/web/src/components/chat/ThreadActivityBanners.browser.test.tsx @@ -0,0 +1,23 @@ +import { ThreadId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../../test/render"; +import { goalNowItem } from "./ThreadActivityBanners"; + +it.each([false, true])( + "queues goal resume when the client reports working=%s", + async (isWorking) => { + const send = vi.fn(async () => undefined); + const item = goalNowItem({ + threadId: ThreadId.make("goal-thread"), + goal: { objective: "Finish the tests", status: "blocked" }, + isWorking, + onSendCommand: send, + }); + await using screen = await render(<>{item.actions}); + await screen + .getByRole("button", { name: isWorking ? "Resume after turn" : "Resume", exact: true }) + .click(); + expect(send).toHaveBeenCalledWith("/goal resume", "Failed to resume goal.", "queue"); + }, +); diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 0ef52f5064123..1f88fd600287e 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -111,13 +111,7 @@ export function goalNowItem(input: { {presentation.canResume ? ( <> - void onSendCommand( - "/goal resume", - "Failed to resume goal.", - isWorking ? "queue" : undefined, - ) - } + onClick={() => void onSendCommand("/goal resume", "Failed to resume goal.", "queue")} > {isWorking ? "Resume after turn" : "Resume"} From d2d0adc68e54b174809866cc17d1dff88174ca98 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 11 Nov 2026 19:41:34 -0800 Subject: [PATCH 4/4] test(server): pin queued goal stop policy --- .../SteeringCompletion.integration.test.ts | 70 +++++++++++-------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index bc8fc9a20e3d3..e298c86782e03 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -2203,38 +2203,46 @@ it.effect("queues a goal clear without stopping work when no goal drives it", () ), ); -it.effect("promotes a queued goal by stopping the running turn", () => - Effect.scoped( - Effect.gen(function* () { - const { started, steered, layer, startFirstTurn, send, read } = - yield* goalHarness("goal-promote"); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = yield* startFirstTurn; - yield* send("queued", "queued", "queue"); - yield* send("goal", "/goal ship it", "queue"); - const goalRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( - (run) => run.userMessageId === MessageId.make("message:goal"), - )!; - yield* orchestrator.dispatch({ - type: "queued-message.promote-to-steer", - commandId: CommandId.make("promote"), - threadId, - queuedRunId: goalRun.id, - targetRunId: started[0]!.runId, - }); - yield* worker.drain(); +it.effect.each(["/goal ship it", "/goal resume"])( + "queues %s until promoted by stopping the running turn", + (text) => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn, send, read } = + yield* goalHarness("goal-promote"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* send("queued", "queued", "queue"); + yield* send("goal", text, "queue"); + yield* worker.drain(); + assert.deepEqual(yield* read(threadId), { + queue: ["queued", text], + started: ["first"], + stopRequested: false, + }); + const goalRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === MessageId.make("message:goal"), + )!; + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: goalRun.id, + targetRunId: started[0]!.runId, + }); + yield* worker.drain(); - assert.deepEqual(steered, []); - assert.deepEqual(yield* read(threadId), { - queue: ["queued"], - started: ["first", "/goal ship it"], - stopRequested: true, - }); - }).pipe(Effect.provide(layer)); - }), - ), + assert.deepEqual(steered, []); + assert.deepEqual(yield* read(threadId), { + queue: ["queued"], + started: ["first", text], + stopRequested: true, + }); + }).pipe(Effect.provide(layer)); + }), + ), ); // A fresh message skips compaction as its own turn, which steering a running turn would not.