diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts index 46ac4730f2e..71614832e4f 100644 --- a/mobile/src/session/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/mobile-native-chat-image-send.test.ts @@ -45,6 +45,7 @@ describe('pasteMobileNativeChatImagePaths', () => { terminal: 'term-1', text: '\x15', enter: false, + nativeChat: true, client: { id: 'device-9', type: 'mobile' } } }) diff --git a/mobile/src/session/mobile-native-chat-image-send.ts b/mobile/src/session/mobile-native-chat-image-send.ts index adb996b7612..7f446f1c159 100644 --- a/mobile/src/session/mobile-native-chat-image-send.ts +++ b/mobile/src/session/mobile-native-chat-image-send.ts @@ -70,6 +70,7 @@ export async function pasteMobileNativeChatImagePaths({ terminal, text, enter: false, + nativeChat: true, ...clientField }, // The remaining budget covers the reconnect wait too; a fresh post-connect diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index ccdacfd7b3e..ff42d38940a 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -61,6 +61,39 @@ describe('sendMobileNativeChatMessage', () => { ).resolves.toBe(false) }) + it('reports an interactive prompt rejection for guarded native-chat sends', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { + send: { + accepted: false, + refusedReason: 'interactive-prompt' + } + }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + sendMobileNativeChatMessageWithOutcome({ + client, + terminal: 'term', + text: 'hello', + guardInteractivePrompt: true + }) + ).resolves.toBe('interactive-prompt') + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { + terminal: 'term', + text: 'hello', + enter: true, + nativeChat: true + }, + { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } + ) + }) + it('returns false when the RPC fails', async () => { const client = { sendRequest: vi.fn().mockRejectedValue(new Error('disconnected')) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index ce432c3efb9..1b6c0a03002 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -1,7 +1,10 @@ import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' -import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' +import { + getTerminalSendRpcRefusedReason, + isTerminalSendRpcAccepted +} from '../terminal/terminal-send-rpc-response' type MobileTerminalClient = { id: string @@ -24,6 +27,7 @@ type MobileNativeChatSendArgs = { text: string enter?: boolean clearInputFirst?: boolean + guardInteractivePrompt?: boolean /** Exact host launch draft this submitting write resolves when accepted. */ resolvedLaunchDraft?: { text: string; createdAt: number } mobileClient?: MobileTerminalClient @@ -35,7 +39,7 @@ type MobileNativeChatSendArgs = { /** 'unknown' = the RPC failed without proof the request never reached the * desktop (ack loss after a write, or a cutover that cannot tell whether the * frame was written) — callers must not present it as a definite send failure. */ -export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' +export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' | 'interactive-prompt' /** Without an explicit timeout `sendRequest` waits for reconnect indefinitely, and * the composer holds `sending` (send arrow dimmed, no error) for as long as it @@ -66,6 +70,7 @@ export async function sendMobileNativeChatMessageWithOutcome( terminal: args.terminal, text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text, enter: args.enter ?? true, + ...(args.guardInteractivePrompt ? { nativeChat: true } : {}), ...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}), ...(args.mobileClient ? { client: args.mobileClient } : {}) }, @@ -74,7 +79,12 @@ export async function sendMobileNativeChatMessageWithOutcome( // pins the composer for twice as long. { timeoutMs, budgetSpansConnect: true } ) - return isTerminalSendRpcAccepted(response) ? 'accepted' : 'rejected' + if (isTerminalSendRpcAccepted(response)) { + return 'accepted' + } + return getTerminalSendRpcRefusedReason(response) === 'interactive-prompt' + ? 'interactive-prompt' + : 'rejected' } catch (error) { // Why: a logical relay↔direct cutover rejects the in-flight send without // knowing whether its frame reached the wire (the desktop may have delivered @@ -106,6 +116,7 @@ export async function clearMobileNativeChatInput(args: { client: RpcClient terminal: string clearInput: string + guardInteractivePrompt?: boolean mobileClient?: MobileTerminalClient deadline?: number }): Promise { @@ -121,6 +132,7 @@ export async function clearMobileNativeChatInput(args: { terminal: args.terminal, text: args.clearInput, enter: false, + ...(args.guardInteractivePrompt ? { nativeChat: true } : {}), ...(args.mobileClient ? { client: args.mobileClient } : {}) }, { timeoutMs, budgetSpansConnect: true } diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 0ceda5714da..1d480c362ee 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -35,6 +35,7 @@ describe('useMobileNativeChatMessageSend', () => { let api: Send | null = null const acceptSend = vi.fn() const holdUnconfirmedSend = vi.fn() + const restoreRejectedDraft = vi.fn() const onCommandSend = vi.fn() const commandSendRef = { current: onCommandSend } const agentRef = { current: null as string | null } @@ -56,7 +57,7 @@ describe('useMobileNativeChatMessageSend', () => { captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never, readSeededLaunchDraftSeed, clearDraftForSend: () => {}, - restoreRejectedDraft: () => {}, + restoreRejectedDraft, acceptSend, holdUnconfirmedSend, onSendError @@ -70,14 +71,19 @@ describe('useMobileNativeChatMessageSend', () => { const sentArgs = (): { clearInputFirst?: boolean + guardInteractivePrompt?: boolean resolvedLaunchDraft?: { text: string; createdAt: number } } => sendWithOutcome.mock.calls[0]![0] as { clearInputFirst?: boolean + guardInteractivePrompt?: boolean resolvedLaunchDraft?: { text: string; createdAt: number } } - const clearArgs = (): { clearInput?: string } => - (clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string } + const clearArgs = (): { clearInput?: string; guardInteractivePrompt?: boolean } => + (clearInputWrite.mock.calls[0]?.[0] ?? {}) as { + clearInput?: string + guardInteractivePrompt?: boolean + } beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -87,6 +93,7 @@ describe('useMobileNativeChatMessageSend', () => { clearInputWrite.mockResolvedValue(true) acceptSend.mockReset() holdUnconfirmedSend.mockReset() + restoreRejectedDraft.mockReset() onCommandSend.mockReset() commandSendRef.current = onCommandSend onSendError = vi.fn() @@ -106,6 +113,7 @@ describe('useMobileNativeChatMessageSend', () => { await api!.send('hello') }) expect(clearArgs().clearInput).toBe(buildAgentTuiClearInputForText(DRAFT)) + expect(clearArgs().guardInteractivePrompt).toBe(true) }) it('issues the burst as its OWN write, before the body', async () => { @@ -193,6 +201,7 @@ describe('useMobileNativeChatMessageSend', () => { await api!.answerQuestion('1') }) expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + expect(sentArgs().guardInteractivePrompt).toBe(false) }) it('creates an optimistic echo for an ordinary chat send', async () => { @@ -201,9 +210,25 @@ describe('useMobileNativeChatMessageSend', () => { await api!.send('hello') }) expect(acceptSend).toHaveBeenCalledTimes(1) + expect(sentArgs().guardInteractivePrompt).toBe(true) expect(onCommandSend).not.toHaveBeenCalled() }) + it('restores the draft without an optimistic echo when the host finds a prompt', async () => { + sendWithOutcome.mockResolvedValue('interactive-prompt') + mount(() => null) + + let accepted = true + await act(async () => { + accepted = await api!.send('hello') + }) + + expect(accepted).toBe(false) + expect(restoreRejectedDraft).toHaveBeenCalledTimes(1) + expect(acceptSend).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Finish the terminal prompt before sending') + }) + // The STA-3332 "Queued forever" regression: command sends dispatch into the // agent's TUI and never echo as user turns, so they must not create a pending // bubble that no transcript match can ever retire. @@ -213,6 +238,7 @@ describe('useMobileNativeChatMessageSend', () => { await api!.send('/clear') }) expect(acceptSend).not.toHaveBeenCalled() + expect(sentArgs().guardInteractivePrompt).toBe(false) expect(onCommandSend).toHaveBeenCalledWith('/clear') }) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 1d59e25431c..8fb5e47f2a6 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -89,6 +89,8 @@ export function useMobileNativeChatMessageSend(args: { const origin = captureSendOrigin(text) const agent = agentRef.current const recordCommand = commandSendRef.current + const classification = classifyMobileNativeChatSend(agent, text) + const guardInteractivePrompt = syncComposer && classification === 'chat' // Why: the lease collapses one render after `connState`, so a question-card // answer (which reaches this send directly) would otherwise burn the whole // 15s heal+send budget waiting on a socket that is already gone. @@ -137,6 +139,7 @@ export function useMobileNativeChatMessageSend(args: { client, terminal: handle, clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text), + guardInteractivePrompt, deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } @@ -154,6 +157,7 @@ export function useMobileNativeChatMessageSend(args: { client, terminal: handle, text, + guardInteractivePrompt, // Why: pre-clear only when nothing was deliberately pasted first. The heal // above fires only for terminals a mobile image paste marked, so a desktop // launch-draft prefill parked on the input line would otherwise glue onto @@ -183,7 +187,6 @@ export function useMobileNativeChatMessageSend(args: { // TUI, not the conversation — the transcript never echoes it as a user // turn, so an optimistic bubble would sit at "Queued" forever and the // unconfirmed hold could never observe a landing. - const classification = classifyMobileNativeChatSend(agent, text) if (outcome === 'unknown') { if (classification === 'chat') { // Why: an ack-lost send usually WAS delivered (issue seen on cellular @@ -194,6 +197,13 @@ export function useMobileNativeChatMessageSend(args: { } return 'unknown' } + if (outcome === 'interactive-prompt') { + if (syncComposer) { + restoreRejectedDraft(origin, text) + } + onSendError('Finish the terminal prompt before sending') + return 'rejected' + } if (outcome === 'rejected') { if (syncComposer) { restoreRejectedDraft(origin, text) diff --git a/mobile/src/terminal/terminal-send-rpc-response.ts b/mobile/src/terminal/terminal-send-rpc-response.ts index 454c5e32972..ab1d60e0328 100644 --- a/mobile/src/terminal/terminal-send-rpc-response.ts +++ b/mobile/src/terminal/terminal-send-rpc-response.ts @@ -13,3 +13,12 @@ export function isTerminalSendRpcAccepted(response: RpcResponse): boolean { } return response.result.send.accepted === true } + +export function getTerminalSendRpcRefusedReason(response: RpcResponse): string | null { + if (!response.ok || !isRecord(response.result) || !isRecord(response.result.send)) { + return null + } + return typeof response.result.send.refusedReason === 'string' + ? response.result.send.refusedReason + : null +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0c339475b68..5db1c698064 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -336,6 +336,12 @@ import { type BrowserTabInfo, type BrowserScreencastResult } from '../../shared/runtime-types' +import { + TERMINAL_WAIT_BLOCKED_SENTINEL_RE, + detectTerminalWaitBlockedReason, + findActionableTerminalWaitBlockedSignal, + isKnownReadyPromptPreview +} from '../../shared/terminal-wait-blocked-detection' import { LINEAR_SEARCH_MAX_LIMIT, LINEAR_WRITE_BODY_CAP, @@ -10676,10 +10682,7 @@ export class OrcaRuntimeService { // A spawn published (or admission pending) this generation already // attaches the provider stream; a replacement under a reused id must not // read as the discovered never-attached session it replaced. - if ( - this.spawnPublishedPtys.has(ptyId) || - this.pendingPtyRegistrationIncarnations.has(ptyId) - ) { + if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { return false } // SSH panes have their own lease/reattach machinery. @@ -16436,6 +16439,13 @@ export class OrcaRuntimeService { return { handle, isRunningAgent, status: null } } + getTerminalWaitBlockedReason(handle: string): RuntimeTerminalWaitBlockedReason | null { + const ptyId = this.getTerminalAgentStatusPtyId(handle) + return detectTerminalWaitBlockedReason( + this.getTerminalAgentStatusSnapshot(handle, ptyId).waitText + ) + } + private getTerminalAgentStatusPtyId(handle: string): string { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -36178,210 +36188,6 @@ function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { return null } -function isKnownReadyPromptPreview(preview: string): boolean { - const normalized = preview.toLowerCase() - const readyIndex = findKnownReadyPromptIndex(normalized) - if (readyIndex === null) { - return false - } - const blockedSignal = findTerminalWaitBlockedSignal(normalized) - if (blockedSignal !== null && blockedSignal.index > readyIndex) { - return false - } - return true -} - -function detectTerminalWaitBlockedReason(preview: string): RuntimeTerminalWaitBlockedReason | null { - const normalized = preview.toLowerCase() - return findActionableTerminalWaitBlockedSignal(normalized)?.reason ?? null -} - -function findActionableTerminalWaitBlockedSignal( - normalized: string -): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - const blockedSignal = findTerminalWaitBlockedSignal(normalized) - if (blockedSignal === null) { - return null - } - const dismissedModalIndex = findDismissedStartupModalIndex(normalized) - // Why: a live prompt after the modal means it was dismissed → signal no longer actionable, even mid-run (Cursor never reports idle via OSC title). - return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index - ? null - : blockedSignal -} - -// Why: a live prompt (idle OR busy) proves the startup modal was dismissed, so a mid-run Cursor lane stops reporting stale trust hits. -function findDismissedStartupModalIndex(normalized: string): number | null { - const indexes = [ - findCodexReadyPromptIndex(normalized), - findAntigravityReadyPromptIndex(normalized), - findCursorActivePromptIndex(normalized) - ].filter((index): index is number => index !== null) - return indexes.length > 0 ? Math.max(...indexes) : null -} - -function findKnownReadyPromptIndex(normalized: string): number | null { - const indexes = [ - findCodexReadyPromptIndex(normalized), - findAntigravityReadyPromptIndex(normalized), - findCursorReadyPromptIndex(normalized) - ].filter((index): index is number => index !== null) - return indexes.length > 0 ? Math.max(...indexes) : null -} - -// Why: match the banner's last occurrence to skip the trust dialog's own "Cursor Agent" text; "→" is cursor-agent's persistent input prompt. -function findCursorActivePromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('cursor agent') - if (headerIndex === -1) { - return null - } - return normalized.includes('→', headerIndex) ? headerIndex : null -} - -// Why: cursor-agent emits no idle OSC title; infer idle from the tail (braille spinner = busy, its absence = idle). -const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ - -function findCursorReadyPromptIndex(normalized: string): number | null { - const activeIndex = findCursorActivePromptIndex(normalized) - if (activeIndex === null) { - return null - } - return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex -} - -function findCodexReadyPromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('openai codex') - if (headerIndex === -1) { - return null - } - const readySegment = normalized.slice(headerIndex) - // Why: Codex prints permissions only in YOLO mode; the stable ready header is OpenAI Codex + model + directory. - return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null -} - -function findAntigravityReadyPromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('antigravity cli') - if (headerIndex === -1) { - return null - } - let lineStart = headerIndex - let modelIndex: number | null = null - let promptIndex: number | null = null - - // Why: ready previews can include echoed paste after the header; scan line bounds directly instead of splitting the whole tail. - for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { - if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { - continue - } - let trimmedStart = lineStart - let trimmedEnd = cursor - while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { - trimmedStart += 1 - } - while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { - trimmedEnd -= 1 - } - if (lineStart > headerIndex && trimmedStart < trimmedEnd) { - if (modelIndex === null && normalized.startsWith('gemini', trimmedStart)) { - modelIndex = trimmedStart - } - if ( - promptIndex === null && - trimmedEnd - trimmedStart === 1 && - normalized.charCodeAt(trimmedStart) === 62 - ) { - promptIndex = trimmedStart - } - } - lineStart = cursor + 1 - } - - return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null -} - -function isTerminalWaitWhitespace(value: string, index: number): boolean { - const code = value.charCodeAt(index) - return code === 32 || (code >= 9 && code <= 13) -} - -const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = - /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust/i - -function findTerminalWaitBlockedSignal( - normalized: string -): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - // Why: one combined negative scan over the up-to-256 KiB tail avoids a dozen full-tail searches when no prompt can match. - if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { - return null - } - const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] - const updateIndex = normalized.lastIndexOf('update available') - if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { - candidates.push({ reason: 'codex-update-prompt', index: updateIndex }) - } - const cwdIndex = normalized.lastIndexOf('choose working directory to') - if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { - candidates.push({ reason: 'codex-cwd-prompt', index: cwdIndex }) - } - const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') - if ( - modelMigrationIndex !== -1 && - normalized.includes('press enter to continue', modelMigrationIndex) - ) { - candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) - } - const hooksIndex = normalized.lastIndexOf('hooks need review') - if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { - candidates.push({ reason: 'codex-hooks-review-prompt', index: hooksIndex }) - } - const trustIndex = Math.max( - normalized.lastIndexOf('do you trust'), - normalized.lastIndexOf('trust this'), - normalized.lastIndexOf('trusted workspace') - ) - const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex) - if ( - trustIndex !== -1 && - (trustSegment.includes('workspace') || - trustSegment.includes('folder') || - trustSegment.includes('directory') || - trustSegment.includes('repo')) - ) { - candidates.push({ reason: 'codex-trust-workspace', index: trustIndex }) - } - const interactivePromptIndex = Math.max( - normalized.lastIndexOf('press enter to confirm'), - normalized.lastIndexOf('press enter to continue'), - normalized.lastIndexOf('press enter to view'), - normalized.lastIndexOf('press enter to insert'), - normalized.lastIndexOf('press t to trust') - ) - const interactivePromptContext = - interactivePromptIndex === -1 - ? '' - : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) - const hasCodexInteractiveContext = - interactivePromptContext.includes('codex') || - interactivePromptContext.includes('permission') || - interactivePromptContext.includes('sandbox') || - interactivePromptContext.includes('trust') || - interactivePromptContext.includes('hook') - if (interactivePromptIndex !== -1 && hasCodexInteractiveContext) { - const contextStart = Math.max(0, interactivePromptIndex - 600) - const hasSpecificPromptInContext = candidates.some( - (candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex - ) - if (!hasSpecificPromptInContext) { - candidates.push({ reason: 'codex-interactive-prompt', index: interactivePromptIndex }) - } - } - return candidates.length > 0 - ? candidates.reduce((latest, candidate) => - candidate.index > latest.index ? candidate : latest - ) - : null -} - function buildTerminalWaitResult( handle: string, condition: RuntimeTerminalWaitCondition, diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index bd6cad49a32..973b3dacafe 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -366,8 +366,13 @@ async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder): } } -function getTerminalSendGuardRefusedReason(error: unknown): 'no-agent' | 'permission' | undefined { +function getTerminalSendGuardRefusedReason( + error: unknown +): 'no-agent' | 'permission' | 'interactive-prompt' | undefined { const message = error instanceof Error ? error.message : String(error) + if (message.includes('terminal_guard_interactive_prompt')) { + return 'interactive-prompt' + } if (message.includes('terminal_guard_permission')) { return 'permission' } @@ -869,6 +874,7 @@ const TerminalSend = TerminalHandle.extend({ text: OptionalString, enter: z.unknown().optional(), interrupt: z.unknown().optional(), + nativeChat: z.literal(true).optional(), resolvedLaunchDraft: z .object({ text: z.string(), @@ -1205,6 +1211,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ !isTerminalQueryReply(params.text) || params.enter === true || params.interrupt === true || + params.nativeChat === true || params.requireAgentStatus !== undefined || params.client?.type !== 'mobile' || !queryReplyClientId || @@ -1276,23 +1283,34 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } } } - // Why: recheck permission/no-agent state immediately before accepting the PTY write. + // Why: guarded sends recheck terminal identity and prompt state immediately before each PTY write. const assertSendPreconditions = - params.requireAgentStatus === 'sendable' + params.requireAgentStatus === 'sendable' || params.nativeChat === true ? async (ptyId?: string): Promise => { - await assertTerminalAgentSendable({ - runtime, - handle: params.terminal, - assertWritable: () => { - assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) - if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - throw new Error('terminal_guard_not_writable') - } + const assertWritable = (): void => { + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) + if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + throw new Error('terminal_guard_not_writable') } - }) + } + if (params.requireAgentStatus === 'sendable') { + await assertTerminalAgentSendable({ + runtime, + handle: params.terminal, + assertWritable + }) + } else { + assertWritable() + } + if ( + params.nativeChat === true && + runtime.getTerminalWaitBlockedReason(params.terminal) !== null + ) { + throw new Error('terminal_guard_interactive_prompt') + } } : undefined - if (params.requireAgentStatus === 'sendable') { + if (assertSendPreconditions) { try { await assertSendPreconditions?.(leaf?.ptyId ?? undefined) } catch (error) { diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index e9fb481aae0..65d047538e3 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -615,6 +615,40 @@ describe('terminal send RPC', () => { expect(beginMobileInputFloor).not.toHaveBeenCalled() }) + it('refuses native-chat sends while the terminal has an interactive prompt', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + getTerminalWaitBlockedReason: vi.fn().mockReturnValue('codex-interactive-prompt'), + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'hello', + enter: true, + nativeChat: true, + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { + send: { + handle: 'terminal-1', + accepted: false, + bytesWritten: 0, + refusedReason: 'interactive-prompt' + } + } + }) + expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(runtime.beginMobileInputFloor).not.toHaveBeenCalled() + }) + it('allows guarded terminal sends when the agent is sendable', async () => { const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 4eac2b5739c..06ac0adac22 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -36,6 +36,8 @@ const mocks = vi.hoisted(() => ({ sendNativeChatMessageVerified: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn(), + draft: 'hello', + toastInfo: vi.fn(), draftScopeKeys: [] as string[], clearNativeChatLaunchDraft: vi.fn(), markNativeChatLaunchDraftAdopted: vi.fn() @@ -61,6 +63,9 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ vi.mock('@/lib/agent-paste-draft', () => ({ getSettingsForAgentTabRuntimeOwner: () => ({}) })) +vi.mock('sonner', () => ({ + toast: { info: (...args: unknown[]) => mocks.toastInfo(...args) } +})) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), sendNativeChatMessageVerified: (...args: unknown[]) => @@ -85,7 +90,7 @@ vi.mock('@/lib/native-chat-telemetry', () => ({ vi.mock('./use-native-chat-draft', () => ({ useNativeChatDraft: (scopeKey: string) => { mocks.draftScopeKeys.push(scopeKey) - return { draft: 'hello', setDraft: mocks.setDraft } + return { draft: mocks.draft, setDraft: mocks.setDraft } } })) vi.mock('./native-chat-draft-cache', () => ({ @@ -142,6 +147,7 @@ describe('NativeChatComposer', () => { clearNativeChatModelEnrichmentForTests() mocks.fieldProps = null mocks.modelSwitchOutcome = 'applied' + mocks.draft = 'hello' mocks.draftScopeKeys.length = 0 mocks.confirmationObserver = null mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => { @@ -234,6 +240,59 @@ describe('NativeChatComposer', () => { expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') }) + it('keeps a chat draft and reveals an active Codex picker instead of sending', () => { + const onOptimisticSend = vi.fn() + const onSwitchToTerminal = vi.fn() + render( + + [ + 'Select Model and Effort', + 'gpt-5.3-codex-spark', + 'Press enter to confirm or esc to cancel' + ].join('\n') + } + onOptimisticSend={onOptimisticSend} + onSwitchToTerminal={onSwitchToTerminal} + /> + ) + + act(() => mocks.fieldProps?.onSend?.()) + + expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() + expect(onOptimisticSend).not.toHaveBeenCalled() + expect(mocks.setDraft).not.toHaveBeenCalled() + expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled() + expect(mocks.toastInfo).toHaveBeenCalledWith('Finish the terminal prompt before sending') + expect(onSwitchToTerminal).toHaveBeenCalledOnce() + }) + + it('allows an intentional slash command while a Codex picker is open', () => { + mocks.draft = '/model' + const onSwitchToTerminal = vi.fn() + render( + + ['gpt-5.3-codex-spark', 'Press enter to confirm or esc to cancel'].join('\n') + } + onSwitchToTerminal={onSwitchToTerminal} + /> + ) + + act(() => mocks.fieldProps?.onSend?.()) + + expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/model', undefined) + expect(onSwitchToTerminal).not.toHaveBeenCalled() + }) + it('retires the launch-draft seed once a send clears the TUI input line', () => { render( string | null) | undefined, + onSwitchToTerminal: (() => void) | undefined +): boolean { + const screen = classification === 'chat' ? readTerminalScreen?.() : null + if (!screen || detectTerminalWaitBlockedReason(screen) === null) { + return false + } + toast.info( + translate( + 'components.native-chat.composer.finishTerminalPrompt', + 'Finish the terminal prompt before sending' + ) + ) + onSwitchToTerminal?.() + return true +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 53c5af930ad..cb0dbd33b26 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14746,7 +14746,8 @@ "max": "Max", "on": "On", "off": "Off" - } + }, + "finishTerminalPrompt": "Finish the terminal prompt before sending" }, "tool": { "running": "Running…", diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 6e012e60b42..99c22a2e7ef 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -604,7 +604,7 @@ export type RuntimeTerminalSend = { handle: string accepted: boolean bytesWritten: number - refusedReason?: 'no-agent' | 'permission' + refusedReason?: 'no-agent' | 'permission' | 'interactive-prompt' } export type RuntimeTerminalAgentStatusState = 'working' | 'permission' | 'idle' | null diff --git a/src/shared/terminal-wait-blocked-detection.ts b/src/shared/terminal-wait-blocked-detection.ts new file mode 100644 index 00000000000..690360cfc5d --- /dev/null +++ b/src/shared/terminal-wait-blocked-detection.ts @@ -0,0 +1,197 @@ +import type { RuntimeTerminalWaitBlockedReason } from './runtime-types' + +export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = + /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust/i + +export function detectTerminalWaitBlockedReason( + preview: string +): RuntimeTerminalWaitBlockedReason | null { + return findActionableTerminalWaitBlockedSignal(preview.toLowerCase())?.reason ?? null +} + +export function isKnownReadyPromptPreview(preview: string): boolean { + const normalized = preview.toLowerCase() + const readyIndex = findKnownReadyPromptIndex(normalized) + if (readyIndex === null) { + return false + } + const blockedSignal = findTerminalWaitBlockedSignal(normalized) + return blockedSignal === null || blockedSignal.index <= readyIndex +} + +export function findActionableTerminalWaitBlockedSignal( + normalized: string +): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { + const blockedSignal = findTerminalWaitBlockedSignal(normalized) + if (blockedSignal === null) { + return null + } + const dismissedModalIndex = findDismissedStartupModalIndex(normalized) + // Why: a live prompt after the modal means it was dismissed, including mid-run Cursor prompts. + return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index + ? null + : blockedSignal +} + +function findDismissedStartupModalIndex(normalized: string): number | null { + const indexes = [ + findCodexReadyPromptIndex(normalized), + findAntigravityReadyPromptIndex(normalized), + findCursorActivePromptIndex(normalized) + ].filter((index): index is number => index !== null) + return indexes.length > 0 ? Math.max(...indexes) : null +} + +function findKnownReadyPromptIndex(normalized: string): number | null { + const indexes = [ + findCodexReadyPromptIndex(normalized), + findAntigravityReadyPromptIndex(normalized), + findCursorReadyPromptIndex(normalized) + ].filter((index): index is number => index !== null) + return indexes.length > 0 ? Math.max(...indexes) : null +} + +function findCursorActivePromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('cursor agent') + if (headerIndex === -1) { + return null + } + return normalized.includes('→', headerIndex) ? headerIndex : null +} + +const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ + +function findCursorReadyPromptIndex(normalized: string): number | null { + const activeIndex = findCursorActivePromptIndex(normalized) + if (activeIndex === null) { + return null + } + return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex +} + +function findCodexReadyPromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('openai codex') + if (headerIndex === -1) { + return null + } + const readySegment = normalized.slice(headerIndex) + return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null +} + +function findAntigravityReadyPromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('antigravity cli') + if (headerIndex === -1) { + return null + } + let lineStart = headerIndex + let modelIndex: number | null = null + let promptIndex: number | null = null + + for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { + if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { + continue + } + let trimmedStart = lineStart + let trimmedEnd = cursor + while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { + trimmedStart += 1 + } + while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { + trimmedEnd -= 1 + } + if (lineStart > headerIndex && trimmedStart < trimmedEnd) { + if (modelIndex === null && normalized.startsWith('gemini', trimmedStart)) { + modelIndex = trimmedStart + } + if ( + promptIndex === null && + trimmedEnd - trimmedStart === 1 && + normalized.charCodeAt(trimmedStart) === 62 + ) { + promptIndex = trimmedStart + } + } + lineStart = cursor + 1 + } + + return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null +} + +function isTerminalWaitWhitespace(value: string, index: number): boolean { + const code = value.charCodeAt(index) + return code === 32 || (code >= 9 && code <= 13) +} + +function findTerminalWaitBlockedSignal( + normalized: string +): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { + if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { + return null + } + const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] + const updateIndex = normalized.lastIndexOf('update available') + if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { + candidates.push({ reason: 'codex-update-prompt', index: updateIndex }) + } + const cwdIndex = normalized.lastIndexOf('choose working directory to') + if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { + candidates.push({ reason: 'codex-cwd-prompt', index: cwdIndex }) + } + const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') + if ( + modelMigrationIndex !== -1 && + normalized.includes('press enter to continue', modelMigrationIndex) + ) { + candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) + } + const hooksIndex = normalized.lastIndexOf('hooks need review') + if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { + candidates.push({ reason: 'codex-hooks-review-prompt', index: hooksIndex }) + } + const trustIndex = Math.max( + normalized.lastIndexOf('do you trust'), + normalized.lastIndexOf('trust this'), + normalized.lastIndexOf('trusted workspace') + ) + const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex) + if ( + trustIndex !== -1 && + (trustSegment.includes('workspace') || + trustSegment.includes('folder') || + trustSegment.includes('directory') || + trustSegment.includes('repo')) + ) { + candidates.push({ reason: 'codex-trust-workspace', index: trustIndex }) + } + const interactivePromptIndex = Math.max( + normalized.lastIndexOf('press enter to confirm'), + normalized.lastIndexOf('press enter to continue'), + normalized.lastIndexOf('press enter to view'), + normalized.lastIndexOf('press enter to insert'), + normalized.lastIndexOf('press t to trust') + ) + const interactivePromptContext = + interactivePromptIndex === -1 + ? '' + : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) + const hasCodexInteractiveContext = + interactivePromptContext.includes('codex') || + interactivePromptContext.includes('permission') || + interactivePromptContext.includes('sandbox') || + interactivePromptContext.includes('trust') || + interactivePromptContext.includes('hook') + if (interactivePromptIndex !== -1 && hasCodexInteractiveContext) { + const contextStart = Math.max(0, interactivePromptIndex - 600) + const hasSpecificPromptInContext = candidates.some( + (candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex + ) + if (!hasSpecificPromptInContext) { + candidates.push({ reason: 'codex-interactive-prompt', index: interactivePromptIndex }) + } + } + return candidates.length > 0 + ? candidates.reduce((latest, candidate) => + candidate.index > latest.index ? candidate : latest + ) + : null +}