From 5af2c2ff523097b0ca4008f76572d5455923737e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 12 Aug 2026 23:37:47 -0500 Subject: [PATCH] fix(desktop): messages typed during approval/sudo/secret prompts run as the next turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing while the turn was parked on a blocking prompt routed the text through steer (session.redirect), which sat undelivered behind the blocked tool batch — nothing rendered, and stopping the turn to force it through resolved the prompt to empty and ended the turn as the literal "Operation interrupted." row, eating the message. Clarify already had a carve-out (typing skips the question and steers) because a real message IS an answer to a clarify. Approval/sudo/secret have no such answer path, so the busy submit now queues the words as the next turn instead: the prompt stays answerable, the queue drains on settle, and the busy button advertises queue rather than steer while one is pending. Slash commands still execute inline, and another session's prompt never affects this one. --- .../hooks/use-composer-submit.test.tsx | 96 +++++++++++++++++++ .../composer/hooks/use-composer-submit.ts | 16 +++- apps/desktop/src/app/chat/composer/index.tsx | 9 +- apps/desktop/src/store/prompts.ts | 22 +++++ 4 files changed, 140 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx index dd83d04f0c0fc..1619288b494c1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { $clarifyRequests } from '@/store/clarify' import type { ComposerAttachment } from '@/store/composer' import { $gateway } from '@/store/gateway' +import { clearAllPrompts, hasBlockingPromptRequest, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts' import { useComposerSubmit } from './use-composer-submit' @@ -263,3 +264,98 @@ describe('useComposerSubmit with a clarify parked on the session', () => { expect($clarifyRequests.get()['other-session']).toBeDefined() }) }) + +describe('useComposerSubmit with a blocking prompt parked on the session', () => { + // Typing cannot answer approval/sudo/secret prompts, so the busy submit must + // route text to the QUEUE — a steer would sit undelivered behind the blocked + // tool batch, and interrupting to force it through resolves the prompt empty + // and ends the turn as "Operation interrupted." with the message lost. + afterEach(() => { + cleanup() + clearAllPrompts() + vi.restoreAllMocks() + }) + + it('queues a busy text follow-up instead of steering while an approval is pending', () => { + setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous', sessionId: 'runtime-session' }) + + const { hook, onCancel, onSteer, queueCurrentDraft } = renderSubmitHook({ + busy: true, + text: 'and also fix the padding' + }) + + act(() => { + hook.result.current.submitDraft() + }) + + expect(queueCurrentDraft).toHaveBeenCalledTimes(1) + expect(onSteer).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + }) + + it('queues while a sudo prompt is pending', () => { + setSudoRequest({ requestId: 'sudo-1', sessionId: 'runtime-session' }) + + const { hook, onSteer, queueCurrentDraft } = renderSubmitHook({ busy: true, text: 'next thing' }) + + act(() => { + hook.result.current.submitDraft() + }) + + expect(queueCurrentDraft).toHaveBeenCalledTimes(1) + expect(onSteer).not.toHaveBeenCalled() + }) + + it('queues while a secret prompt is pending', () => { + setSecretRequest({ envVar: 'API_KEY', prompt: 'key?', requestId: 'sec-1', sessionId: 'runtime-session' }) + + const { hook, onSteer, queueCurrentDraft } = renderSubmitHook({ busy: true, text: 'next thing' }) + + act(() => { + hook.result.current.submitDraft() + }) + + expect(queueCurrentDraft).toHaveBeenCalledTimes(1) + expect(onSteer).not.toHaveBeenCalled() + }) + + it('still runs slash commands inline', async () => { + setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous', sessionId: 'runtime-session' }) + + const { hook, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ busy: true, text: '/status' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('/status', { composerScope: 'stored-session' })) + expect(queueCurrentDraft).not.toHaveBeenCalled() + expect(onSteer).not.toHaveBeenCalled() + }) + + it("ignores another session's blocking prompt and still steers", async () => { + setApprovalRequest({ command: 'ls', description: 'other', sessionId: 'other-session' }) + + const { hook, onSteer, queueCurrentDraft } = renderSubmitHook({ busy: true, text: 'change course' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course')) + expect(queueCurrentDraft).not.toHaveBeenCalled() + }) + + it('leaves the prompt pending — queueing must not resolve or dismiss it', () => { + setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous', sessionId: 'runtime-session' }) + + const { hook } = renderSubmitHook({ busy: true, text: 'follow-up' }) + + act(() => { + hook.result.current.submitDraft() + }) + + // The approval card is still the turn's owner; only its own buttons answer it. + expect(hasBlockingPromptRequest('runtime-session')).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index 9d34ddfca3f13..47f9616a08cd3 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -6,6 +6,7 @@ import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify' import { clearSessionDraft, type ComposerAttachment } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue' +import { hasBlockingPromptRequest } from '@/store/prompts' import { cloneAttachments, type QueueEditState } from '../composer-utils' import { onComposerSubmitRequest } from '../focus' @@ -161,6 +162,15 @@ export function useComposerSubmit({ void skipClarifyRequest(sessionId) } + // Approval / sudo / secret prompts also park the turn inside a tool batch, + // but typing CANNOT answer them (no message text approves a command or + // supplies a password), so there is no skip-and-steer path: a steer would + // sit undelivered behind the blocked prompt, and stopping the turn to force + // it through resolves the prompt to empty and ends the turn as "Operation + // interrupted." — the message looks eaten. Queue the words as the next turn + // instead; the prompt stays answerable and the queue drains on settle. + const blockingPrompt = !queueEdit && hasBlockingPromptRequest(sessionId) + if (queueEdit) { exitQueuedEdit('save') } else if (busy) { @@ -175,14 +185,16 @@ export function useComposerSubmit({ triggerHaptic('submit') clearDraft() dispatchSubmit(text) - } else if (!compacting && !attachments.length && text.trim()) { + } else if (!compacting && !blockingPrompt && !attachments.length && text.trim()) { // Cursor-style stop-and-correct: interrupt the live turn and redirect // it with this text. redirect() preserves the shown reasoning/work; if // the turn already ended, steerDraft re-queues so nothing is lost. steerDraft() } else if (payloadPresent) { // Attachments can't ride a redirect (no tool-result image carriage) — - // queue the whole payload for the next turn. + // queue the whole payload for the next turn. Same for a turn parked on + // an approval/sudo/secret prompt: a steer can't reach the model while + // the tool batch is blocked, so the message runs as the next turn. queueCurrentDraft() } else { // Stop button (the only way to reach here while busy with an empty diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 5d490eb61c412..6b22c2fda5420 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -19,6 +19,7 @@ import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } f import { POPOUT_WIDTH_REM } from '@/store/composer-popout' import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue' import { $hudMode } from '@/store/hud' +import { sessionBlockingPrompt } from '@/store/prompts' import { toggleReview } from '@/store/review' import { $gatewayState } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' @@ -156,6 +157,10 @@ export function ChatBar({ // would discard a question the user may want to come back to. The blocking // prompt owns its own dismissal (Skip, Reject, dialog close). const awaitingInput = useStore(scope.$awaitingInput) + // Parked on an approval/sudo/secret prompt: typing can't answer those, so the + // busy submit routes text to the queue instead of a steer (which would sit + // undelivered behind the blocked tool batch). Drives the button affordance. + const blockingPrompt = useStore(useMemo(() => sessionBlockingPrompt(sessionId ?? null), [sessionId])) const activeQueueSessionKey = queueSessionKey || sessionId || null // Status items (subagents, background processes) are keyed by the RUNTIME @@ -328,7 +333,9 @@ export function ChatBar({ // Steer only makes sense mid-turn, text-only (the gateway can't carry images // into a tool result) and never for a slash command (those execute inline). - const canSteer = busy && !compacting && !!onSteer && attachments.length === 0 && isSteerableText + // A blocking prompt (approval/sudo/secret) also rules it out: the tool batch + // is parked on the user, so a steer can't reach the model — text queues. + const canSteer = busy && !compacting && !blockingPrompt && !!onSteer && attachments.length === 0 && isSteerableText // While busy: text redirects the live turn (Cursor-style stop-and-correct), // attachments queue for the next turn, an empty composer stops. diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts index f15e4b33ffb37..0efe97515a993 100644 --- a/apps/desktop/src/store/prompts.ts +++ b/apps/desktop/src/store/prompts.ts @@ -147,6 +147,28 @@ export const $activeSessionAwaitingInput = computed( (clarify, approval, sudo, secret) => Boolean(clarify || approval || sudo || secret) ) +/** True when `sessionId` is parked on a blocking prompt that typing cannot + * answer (approval / sudo / secret). Clarify is deliberately excluded: typing + * a real message IS an answer to a clarify ("none of these" — the composer + * skips it and routes the words), but no message text can approve a command + * or supply a password. Imperative read — the composer checks this on Enter, + * not on every render. */ +export const hasBlockingPromptRequest = (sessionId: string | null | undefined): boolean => { + const key = keyFor(sessionId) + + return Boolean(approval.$all.get()[key] || sudo.$all.get()[key] || secret.$all.get()[key]) +} + +/** Reactive twin of `hasBlockingPromptRequest`, for the composer's busy-action + * affordance (the primary button must advertise queue, not steer, while the + * turn is parked on a prompt Enter can't answer). */ +export const sessionBlockingPrompt = (sessionId: string | null) => + computed([approval.$all, sudo.$all, secret.$all], (approvals, sudos, secrets) => { + const key = keyFor(sessionId) + + return Boolean(approvals[key] || sudos[key] || secrets[key]) + }) + /** Per-session `awaitingInput` — the tile composer's counterpart of * `$activeSessionAwaitingInput` (same sources, fixed session instead of the * active one). */