fix(desktop): messages typed during approval/sudo/secret prompts run as the next turn
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.
This commit is contained in:
parent
1706502aa7
commit
5af2c2ff52
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
Loading…
Reference in New Issue