From 4871e966be4fa5a3e167f8cbbb361485b7d3e605 Mon Sep 17 00:00:00 2001 From: Imamuzzaki Abu Salam Date: Fri, 21 Aug 2026 11:05:15 +0700 Subject: [PATCH 1/2] fix(server): strip OSC/ANSI escapes from OpenCode CLI inventory and stored agent selections opencode <=1.18 writes ESC ]0;: ready BEL to stdout for every non-help command even when stdout is a pipe (agent list, models --verbose, debug skill). T3's ChildProcessSpawner captures that stdout via collectStreamAsString and the parsers stored a polluted agent id like "\x1b]0;imbios: ready\x07build" in model_selection_json. Later sendTurn used that polluted id and opencode rejected it with "Agent not found: \"\x1b]0;imbios: ready\x07build\"" which was surfaced as session.error UnknownError + a generic SessionPrompt UnknownError wrapper (the stack the user pasted). Fix: - packages/shared/src/stripTerminalEscapes.ts: shared OSC/CSI sanitizer - apps/server/src/provider/opencodeRuntime.ts: strip before parseModels/Agent/Skills and via parse* entry points; keeps skills from silently degrading to [] when polluted - apps/server/src/provider/Layers/OpenCodeProvider.ts: sanitize inventory agent names/variants and --version parsing; build clean capability option ids - apps/server/src/provider/Layers/OpenCodeAdapter.ts & textGeneration/OpenCodeTextGeneration.ts: sanitize stored getModelSelectionStringOptionValue values before promptAsync - packages/shared/src/model.ts: sanitize persisted option values and model slugs on read (repairs 3 polluted threads without DB migration) - tests: add OSC/ANSI regression cases for both parsers Polluted threads still read as clean via model.ts sanitizer; no migration needed but DB can be cleaned with stripTerminalEscapes. Fixes the reported UnknownError at SessionPrompt.createUserMessage and the earlier "Agent not found" session.error. --- .../src/provider/Layers/OpenCodeAdapter.ts | 9 ++- .../src/provider/Layers/OpenCodeProvider.ts | 14 ++++- .../opencodeRuntime.cliParsers.test.ts | 62 +++++++++++++++++++ apps/server/src/provider/opencodeRuntime.ts | 8 ++- .../textGeneration/OpenCodeTextGeneration.ts | 7 ++- packages/shared/package.json | 4 ++ packages/shared/src/model.ts | 11 +++- packages/shared/src/stripTerminalEscapes.ts | 38 ++++++++++++ 8 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 packages/shared/src/stripTerminalEscapes.ts diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..46607da71efb 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -25,6 +25,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { sanitizeTerminalValue } from "@t3tools/shared/stripTerminalEscapes"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -1472,12 +1473,14 @@ export function makeOpenCodeAdapter( }); } - const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); - const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + const rawAgent = getModelSelectionStringOptionValue(modelSelection, "agent"); + const rawVariant = getModelSelectionStringOptionValue(modelSelection, "variant"); + const agent = rawAgent ? sanitizeTerminalValue(rawAgent) : undefined; + const variant = rawVariant ? sanitizeTerminalValue(rawVariant) : undefined; context.activeTurnId = turnId; context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); - context.activeVariant = variant; + context.activeVariant = variant || undefined; yield* updateProviderSession( context, { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 62f29c47eb38..947782883fce 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -11,6 +11,10 @@ import * as Effect from "effect/Effect"; import { createModelCapabilities } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; +import { + sanitizeTerminalValue, + stripTerminalEscapes, +} from "@t3tools/shared/stripTerminalEscapes"; import { buildServerProvider, nonEmptyTrimmed, @@ -174,14 +178,18 @@ function openCodeCapabilitiesForModel(input: { readonly model: ProviderListResponse["all"][number]["models"][string]; readonly agents: ReadonlyArray; }): ModelCapabilities { - const variantValues = Object.keys(input.model.variants ?? {}); + const variantValues = Object.keys(input.model.variants ?? {}).map(sanitizeTerminalValue); const defaultVariant = inferDefaultVariant(input.providerID, variantValues); const variantOptions = variantValues.map((value) => defaultVariant === value ? { id: value, label: titleCaseSlug(value), isDefault: true as const } : { id: value, label: titleCaseSlug(value) }, ); - const primaryAgents = input.agents.filter( + const sanitizedAgents = input.agents.map((agent) => ({ + ...agent, + name: sanitizeTerminalValue(agent.name), + })); + const primaryAgents = sanitizedAgents.filter( (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), ); const defaultAgent = inferDefaultAgent(primaryAgents); @@ -390,7 +398,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu if (versionExit._tag === "Failure") { return fallback(Cause.squash(versionExit.cause)); } - version = parseGenericCliVersion(versionExit.value.stdout) ?? null; + version = parseGenericCliVersion(stripTerminalEscapes(versionExit.value.stdout)) ?? null; if (!version) { return fallback( diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 8d5ba353389d..970a627c4b73 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -154,6 +154,30 @@ describe("parseModelsCliOutput", () => { NodeAssert.equal(model.id, "qwen/qwen3-coder"); NodeAssert.equal(model.providerID, "openrouter"); }); + + it("strips OSC title escapes from model slugs (opencode CLI leak)", () => { + const stdout = [ + "\x1b]0;t3code: ready\x07opencode/big-pickle", + JSON.stringify({ id: "big-pickle", providerID: "opencode", name: "Big Pickle" }), + "\x1b]0;tmp: ready\x07anthropic/claude-sonnet-4-5", + JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 2); + NodeAssert.ok(result.providers.get("opencode")!.models["big-pickle"]); + NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); + }); + + it("strips ANSI escapes from model slugs", () => { + const stdout = [ + "\x1b[33mopencode/gpt-5.4\x1b[0m", + JSON.stringify({ id: "gpt-5.4", providerID: "opencode", name: "GPT-5.4" }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.ok(result.providers.get("opencode")!.models["gpt-5.4"]); + }); }); describe("parseAgentListCliOutput", () => { @@ -255,9 +279,47 @@ describe("parseAgentListCliOutput", () => { NodeAssert.equal(result[0]!.hidden, true); NodeAssert.equal(result[1]!.hidden, false); }); + + it("strips OSC title escapes leaked by opencode CLI", () => { + // opencode <=1.18 writes `ESC ]0;: ready BEL` to stdout for every + // non-help command — even when stdout is a pipe. Without stripping, the + // agent name becomes `ESC]0;...BELbuild` and later fails with + // `Agent not found: "ESC]0;...build"`. + const stdout = [ + "\x1b]0;t3code: ready\x07build (primary)", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + "\x1b]0;tmp: ready\x07explore (subagent)", + " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 2); + NodeAssert.equal(result[0]!.name, "build"); + NodeAssert.equal(result[0]!.mode, "primary"); + NodeAssert.equal(result[1]!.name, "explore"); + NodeAssert.equal(result[1]!.mode, "subagent"); + }); + + it("strips ANSI CSI color escapes from agent headers", () => { + const stdout = [ + "\x1b[31mbuild (primary)\x1b[0m", + " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), + ].join("\n"); + + const result = parseAgentListCliOutput(stdout); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "build"); + }); }); describe("parseSkillsCliOutput", () => { + it("strips OSC escapes before JSON parsing (opencode CLI leak)", () => { + const polluted = "\x1b]0;tmp: ready\x07" + JSON.stringify([{ name: "review-pr", location: "/tmp/x", description: "d", content: "c" }]); + const result = parseSkillsCliOutput(polluted); + NodeAssert.equal(result.length, 1); + NodeAssert.equal(result[0]!.name, "review-pr"); + }); + it("parses skill metadata from the CLI JSON output", () => { const result = parseSkillsCliOutput( JSON.stringify([ diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 2ff4fa1292f2..2aba395eebbd 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -34,6 +34,7 @@ import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { sanitizeTerminalValue, stripTerminalEscapes } from "@t3tools/shared/stripTerminalEscapes"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; @@ -216,7 +217,7 @@ export function parseModelsCliOutput(stdout: string): { string, { id: string; name: string; models: { [key: string]: Model } } >(); - const lines = stdout.split("\n"); + const lines = stripTerminalEscapes(stdout).split("\n"); let currentSlug: string | null = null; const jsonLines: Array = []; @@ -269,7 +270,7 @@ export function parseModelsCliOutput(stdout: string): { /** @internal */ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { const agents: Array = []; - const lines = stdout.split("\n"); + const lines = stripTerminalEscapes(stdout).split("\n"); let currentHeader: { name: string; mode: string } | null = null; const blockLines: Array = []; @@ -311,7 +312,8 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { /** @internal */ export function parseSkillsCliOutput(stdout: string): ReadonlyArray { - const result = decodeOpenCodeSkillsCliOutputExit(stdout); + const clean = stripTerminalEscapes(stdout); + const result = decodeOpenCodeSkillsCliOutputExit(clean); return Exit.isSuccess(result) ? result.value : []; } diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index e09c3db2cffc..a757d838c997 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -15,6 +15,7 @@ import { import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import { sanitizeTerminalValue } from "@t3tools/shared/stripTerminalEscapes"; import * as ServerConfig from "../config.ts"; import { resolveAttachmentPath } from "../attachmentStore.ts"; @@ -408,8 +409,10 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" cwd: input.cwd, }); } - const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const rawAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const rawVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const selectedAgent = rawAgent ? sanitizeTerminalValue(rawAgent) : undefined; + const selectedVariant = rawVariant ? sanitizeTerminalValue(rawVariant) : undefined; const promptContext = { operation: input.operation, cwd: input.cwd, diff --git a/packages/shared/package.json b/packages/shared/package.json index a797e97b6625..e7a8c0e2cad1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -226,6 +226,10 @@ "./usageFormat": { "types": "./src/usageFormat.ts", "import": "./src/usageFormat.ts" + }, + "./stripTerminalEscapes": { + "types": "./src/stripTerminalEscapes.ts", + "import": "./src/stripTerminalEscapes.ts" } }, "scripts": { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index bdc0c0cc8efb..bfca0a092cd6 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -10,6 +10,8 @@ import { type ProviderOptionSelection, } from "@t3tools/contracts"; +import { sanitizeTerminalValue } from "./stripTerminalEscapes.ts"; + const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); export interface SelectableModelOption { @@ -45,7 +47,9 @@ export function getProviderOptionStringSelectionValue( id: string, ): string | undefined { const value = getProviderOptionSelectionValue(selections, id); - return typeof value === "string" ? value : undefined; + if (typeof value !== "string") return undefined; + const sanitized = sanitizeTerminalValue(value); + return sanitized.length > 0 ? sanitized : undefined; } export function getProviderOptionBooleanSelectionValue( @@ -254,7 +258,7 @@ export function normalizeCustomModelSlug(model: string | null | undefined): stri return null; } - return model.trim() || null; + return sanitizeTerminalValue(model) || null; } export function resolveSelectableModel( @@ -308,7 +312,8 @@ export function resolveModelSlugForProvider( /** Trim a string, returning null for empty/missing values. */ export function trimOrNull(value: T | null | undefined): T | null { if (typeof value !== "string") return null; - const trimmed = value.trim() as T; + const sanitized = sanitizeTerminalValue(value); + const trimmed = sanitized.trim() as T; return trimmed || null; } diff --git a/packages/shared/src/stripTerminalEscapes.ts b/packages/shared/src/stripTerminalEscapes.ts new file mode 100644 index 000000000000..0c00ccc55d6d --- /dev/null +++ b/packages/shared/src/stripTerminalEscapes.ts @@ -0,0 +1,38 @@ +/** + * Strip terminal escape sequences from captured CLI stdout. + * + * OpenCode's CLI (and potentially other provider CLIs) can emit OSC title + * sequences (`ESC ]0; BEL` / `ESC \`) and ANSI CSI color codes directly + * to stdout, even when stdout is a pipe. When T3 Code captures that output + * via `ChildProcessSpawner`, those bytes pollute structured parsing — e.g. + * `opencode agent list` becomes `\x1b]0;t3code: ready\x07build (primary)` + * instead of `build (primary)`, causing the agent inventory to store a + * polluted id that later fails with `Agent not found`. + * + * This is defensive for any provider CLI; the regexes are intentionally + * permissive and also handle Ghostty/Zsh title integrations that can leak + * through `shell: true` spawns. + */ +const OSC_RE = /\x1b\].*?(?:\x07|\x1b\\)/g; +const CSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g; +const CHARSET_RE = /\x1b[()][A-Za-z0-9]/g; +const SINGLE_ESC_RE = /\x1b[@-Z\\-_]/g; + +export function stripTerminalEscapes(input: string): string { + if (!input || input.indexOf("\x1b") === -1) { + return input; + } + return input + .replace(OSC_RE, "") + .replace(CSI_RE, "") + .replace(CHARSET_RE, "") + .replace(SINGLE_ESC_RE, ""); +} + +/** + * Strip escapes and also trim the result. Useful for single-value fields + * like agent/variant names that should never contain control bytes. + */ +export function sanitizeTerminalValue(input: string): string { + return stripTerminalEscapes(input).trim(); +} From fde721003ea7b02afbc6fe752415040058bfc504 Mon Sep 17 00:00:00 2001 From: Imamuzzaki Abu Salam <imbios@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:13:07 +0000 Subject: [PATCH 2/2] fix(mobile): make Stop work during preparing worktree phase --- .../src/features/threads/ThreadComposer.tsx | 20 +++- .../features/threads/ThreadDetailScreen.tsx | 6 + .../features/threads/ThreadRouteScreen.tsx | 110 ++++++++++++++++-- .../src/state/pending-thread-creation.test.ts | 65 +++++++++++ .../src/state/pending-thread-creation.ts | 36 ++++++ 5 files changed, 223 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7e195510074e..d1ea37be772b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -137,6 +137,10 @@ export interface ThreadComposerProps { readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; + /** Optimistic Stop feedback: disables Stop while the interrupt/cancel settles. */ + readonly isStoppingThread: boolean; + /** True while the thread creation is still preparing (e.g. worktree checkout). */ + readonly isPreparingCreation: boolean; readonly onSendMessage: () => Promise<MessageId | null>; /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */ readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; @@ -308,7 +312,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const showStopAction = !hasContent && (props.selectedThread.session?.status === "running" || - props.selectedThread.session?.status === "starting"); + props.selectedThread.session?.status === "starting" || + props.isPreparingCreation); + const stopAccessibilityLabel = props.isStoppingThread + ? "Stopping agent" + : props.isPreparingCreation && + props.selectedThread.session?.status !== "running" && + props.selectedThread.session?.status !== "starting" + ? "Cancel task setup" + : "Stop agent"; const uploadStates = useAtomValue(composerAttachmentUploadsAtom); const attachmentsUploading = @@ -804,9 +816,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> {showStopAction ? ( <ComposerActionButton - accessibilityLabel="Stop agent" + accessibilityLabel={stopAccessibilityLabel} icon="stop.fill" variant="danger" + disabled={props.isStoppingThread} onPress={props.onStopThread} /> ) : ( @@ -895,9 +908,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> {showStopAction ? ( <ComposerActionButton - accessibilityLabel="Stop agent" + accessibilityLabel={stopAccessibilityLabel} icon="stop.fill" variant="danger" + disabled={props.isStoppingThread} onPress={props.onStopThread} /> ) : voicePresentation.showsSend ? ( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 55cd9e9a6c83..e51f39958251 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -157,6 +157,10 @@ export interface ThreadDetailScreenProps { readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; + /** Optimistic Stop feedback: set on press, cleared when work ends (#12187). */ + readonly isStoppingThread: boolean; + /** True while the thread creation is still preparing (e.g. worktree checkout). */ + readonly isPreparingCreation: boolean; readonly onSendMessage: () => Promise<MessageId | null>; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; @@ -1051,6 +1055,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onNativePasteImages={props.onNativePasteImages} onRemoveDraftImage={props.onRemoveDraftImage} onStopThread={props.onStopThread} + isStoppingThread={props.isStoppingThread} + isPreparingCreation={props.isPreparingCreation} onSendMessage={handleSendMessage} onShowUsageLimits={showUsageLimits} onUpdateModelSelection={props.onUpdateThreadModelSelection} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index bb78e189bf43..07cc7aeaf30e 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -26,7 +26,11 @@ import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; -import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation"; +import { + clearPendingThreadCreationOutcome, + resolvePreparingStopAction, +} from "../../state/pending-thread-creation"; +import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal"; import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft"; import { useEnvironmentQuery } from "../../state/query"; import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; @@ -498,24 +502,104 @@ function ThreadRouteContent( const handleOpenConnectionEditor = useCallback(() => { void navigation.navigate("Connections"); }, [navigation]); + const [isStoppingThread, setIsStoppingThread] = useState(false); const handleStopThread = useCallback(() => { - if ( - !selectedThread || - (selectedThread.session?.status !== "running" && - selectedThread.session?.status !== "starting") - ) { + const stopAction = resolvePreparingStopAction({ + isPreparing: + selectedThreadCreation !== null && selectedThreadCreation.outcome?.kind !== "failed", + creationOutcomeKind: + selectedThreadCreation?.outcome?.kind === "delivered" + ? "delivered" + : selectedThreadCreation?.outcome?.kind === "failed" + ? "failed" + : null, + creationMessageId: selectedThreadCreation + ? String(selectedThreadCreation.message.messageId) + : null, + dispatchingMessageId: composer.dispatchingQueuedMessageId + ? String(composer.dispatchingQueuedMessageId) + : null, + sessionStatus: selectedThread?.session?.status ?? null, + }); + // A still-queued creation has no server turn to interrupt: cancel it + // locally with immediate feedback instead of a silent no-op (#12187). + if (stopAction.kind === "cancel-queued-creation" && selectedThreadCreation) { + const queuedMessage = selectedThreadCreation.message; + const threadKey = routeThreadIdentity; + setIsStoppingThread(true); + void (async () => { + try { + const removed = await removeThreadOutboxMessage(queuedMessage); + if (!removed) { + return; + } + if (threadKey) { + clearPendingThreadCreationOutcome(threadKey); + } + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + navigation.dispatch(StackActions.replace("Home")); + } + } catch (error) { + Alert.alert( + "Could not cancel task", + error instanceof Error ? error.message : String(error), + ); + } finally { + setIsStoppingThread(false); + } + })(); + return; + } + if (stopAction.kind !== "interrupt-running-turn") { return; } - return interruptThreadTurn({ + if (!selectedThread) { + return; + } + const activeSession = selectedThread.session; + if (!activeSession) { + return; + } + setIsStoppingThread(true); + const interrupt = interruptThreadTurn({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, - ...(selectedThread.session.activeTurnId - ? { turnId: selectedThread.session.activeTurnId } - : {}), + ...(activeSession.activeTurnId ? { turnId: activeSession.activeTurnId } : {}), }, }); - }, [interruptThreadTurn, selectedThread]); + void interrupt.then((result) => { + if (result._tag === "Failure") { + setIsStoppingThread(false); + } + }); + }, [ + composer.dispatchingQueuedMessageId, + interruptThreadTurn, + navigation, + routeThreadIdentity, + selectedThread, + selectedThreadCreation, + ]); + // Optimistic Stop feedback clears once work actually ends, when switching + // threads, or immediately on a failed interrupt above (#12187). + const selectedThreadSessionStatus = selectedThread?.session?.status ?? null; + const selectedThreadIdentity = selectedThread + ? scopedThreadKey(selectedThread.environmentId, selectedThread.id) + : null; + const previousStopThreadIdentityRef = useRef<string | null>(null); + useEffect(() => { + if (previousStopThreadIdentityRef.current !== selectedThreadIdentity) { + previousStopThreadIdentityRef.current = selectedThreadIdentity; + setIsStoppingThread(false); + return; + } + if (selectedThreadSessionStatus !== "running" && selectedThreadSessionStatus !== "starting") { + setIsStoppingThread(false); + } + }, [selectedThreadIdentity, selectedThreadSessionStatus]); const handleOpenTerminal = useCallback( (nextTerminalId?: string | null) => { @@ -894,6 +978,10 @@ function ThreadRouteContent( onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} onStopThread={handleStopThread} + isStoppingThread={isStoppingThread} + isPreparingCreation={ + selectedThreadCreation !== null && selectedThreadCreation.outcome?.kind !== "failed" + } onSendMessage={composer.onSendMessage} onReconnectEnvironment={handleReconnectEnvironment} onUpdateThreadModelSelection={composer.onUpdateModelSelection} diff --git a/apps/mobile/src/state/pending-thread-creation.test.ts b/apps/mobile/src/state/pending-thread-creation.test.ts index f397200d33b6..95c3deafb762 100644 --- a/apps/mobile/src/state/pending-thread-creation.test.ts +++ b/apps/mobile/src/state/pending-thread-creation.test.ts @@ -14,6 +14,7 @@ import { pendingThreadCreationMessage, pendingThreadCreationShell, resolvePendingThreadCreation, + resolvePreparingStopAction, type PendingThreadCreation, } from "./pending-thread-creation"; import type { QueuedThreadMessage } from "./thread-outbox-model"; @@ -261,3 +262,67 @@ describe("pendingThreadCreationMessage", () => { expect(pendingThreadCreationMessage(creation)).not.toHaveProperty("attachments"); }); }); + +describe("resolvePreparingStopAction", () => { + const creationMessageId = String(creation.messageId); + + it("cancels a still-queued creation during preparing", () => { + expect( + resolvePreparingStopAction({ + isPreparing: true, + creationOutcomeKind: null, + creationMessageId, + dispatchingMessageId: null, + sessionStatus: null, + }), + ).toEqual({ kind: "cancel-queued-creation" }); + }); + + it("never cancels mid-delivery; falls through to interrupt when running", () => { + expect( + resolvePreparingStopAction({ + isPreparing: true, + creationOutcomeKind: null, + creationMessageId, + dispatchingMessageId: creationMessageId, + sessionStatus: "starting", + }), + ).toEqual({ kind: "interrupt-running-turn" }); + }); + + it("interrupts after delivery while the worktree is still checking out", () => { + expect( + resolvePreparingStopAction({ + isPreparing: true, + creationOutcomeKind: "delivered", + creationMessageId, + dispatchingMessageId: null, + sessionStatus: "starting", + }), + ).toEqual({ kind: "interrupt-running-turn" }); + }); + + it("is a noop when preparing is over and nothing is running", () => { + expect( + resolvePreparingStopAction({ + isPreparing: false, + creationOutcomeKind: "delivered", + creationMessageId, + dispatchingMessageId: null, + sessionStatus: "stopped", + }), + ).toEqual({ kind: "noop" }); + }); + + it("is a noop for a delivered creation whose session never started", () => { + expect( + resolvePreparingStopAction({ + isPreparing: true, + creationOutcomeKind: "delivered", + creationMessageId, + dispatchingMessageId: null, + sessionStatus: null, + }), + ).toEqual({ kind: "noop" }); + }); +}); diff --git a/apps/mobile/src/state/pending-thread-creation.ts b/apps/mobile/src/state/pending-thread-creation.ts index 4a2d7839e13e..e102f0be375f 100644 --- a/apps/mobile/src/state/pending-thread-creation.ts +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -106,6 +106,42 @@ export function isPendingThreadCreationVisible(input: { return !input.loadedMessageIds?.includes(input.creationMessageId); } +/** + * What Stop should do from the thread screen. The preparing window (queued + * creation or worktree checkout before the first turn) has no turn to + * interrupt: when the creation is still queued locally, Stop cancels it + * outright; once delivered, Stop falls through to the normal interrupt path + * (best effort — the server may still be checking out the worktree). + */ +export type PreparingStopAction = + | { readonly kind: "cancel-queued-creation" } + | { readonly kind: "interrupt-running-turn" } + | { readonly kind: "noop" }; + +export function resolvePreparingStopAction(input: { + /** Null once the server's detail has taken over the pill. */ + readonly isPreparing: boolean; + /** Outcome recorded by the outbox drain; null while still queued. */ + readonly creationOutcomeKind: "delivered" | "failed" | null; + readonly creationMessageId: string | null; + /** Currently-delivering outbox message, if any. Never cancel mid-delivery. */ + readonly dispatchingMessageId: string | null; + readonly sessionStatus: string | null; +}): PreparingStopAction { + if ( + input.isPreparing && + input.creationMessageId !== null && + input.creationOutcomeKind === null && + input.dispatchingMessageId !== input.creationMessageId + ) { + return { kind: "cancel-queued-creation" }; + } + if (input.sessionStatus === "running" || input.sessionStatus === "starting") { + return { kind: "interrupt-running-turn" }; + } + return { kind: "noop" }; +} + export function pendingThreadCreationMessage( message: QueuedThreadMessage, ): OrchestrationThread["messages"][number] {