fix(native-chat): guard sends during terminal prompts

This commit is contained in:
Brennan Benson
2026-08-04 22:52:56 -07:00
parent eebaf47df0
commit 60de278ff2
16 changed files with 465 additions and 231 deletions
@@ -45,6 +45,7 @@ describe('pasteMobileNativeChatImagePaths', () => {
terminal: 'term-1',
text: '\x15',
enter: false,
nativeChat: true,
client: { id: 'device-9', type: 'mobile' }
}
})
@@ -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
@@ -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'))
+15 -3
View File
@@ -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<boolean> {
@@ -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 }
@@ -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')
})
@@ -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)
@@ -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
}
+14 -208
View File
@@ -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,
+31 -13
View File
@@ -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<void> => {
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) {
@@ -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' }),
@@ -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(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="codex"
readTerminalScreen={() =>
[
'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(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="codex"
readTerminalScreen={() =>
['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(
<NativeChatComposer
@@ -41,6 +41,7 @@ import type {
NativeChatComposerHandle,
NativeChatComposerProps
} from './native-chat-composer-types'
import { guardNativeChatTerminalPrompt } from './native-chat-terminal-prompt-guard'
export type {
NativeChatComposerHandle,
@@ -251,6 +252,9 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
return
}
const classification = classifySend(text)
if (guardNativeChatTerminalPrompt(classification, readTerminalScreen, onSwitchToTerminal)) {
return
}
// A parked launch draft must be cleared line-by-line before the body.
const { sendOptions } = resolveNativeChatLaunchDraftSend({
launchDraft,
@@ -322,6 +326,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
resolveTarget,
onOptimisticSend,
onSlashCommand,
onSwitchToTerminal,
sessionOptionsSurface,
terminalTabId,
trackPendingSend,
@@ -0,0 +1,22 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { detectTerminalWaitBlockedReason } from '../../../../shared/terminal-wait-blocked-detection'
export function guardNativeChatTerminalPrompt(
classification: string,
readTerminalScreen: (() => 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
}
+2 -1
View File
@@ -14746,7 +14746,8 @@
"max": "Max",
"on": "On",
"off": "Off"
}
},
"finishTerminalPrompt": "Finish the terminal prompt before sending"
},
"tool": {
"running": "Running…",
+1 -1
View File
@@ -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
@@ -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
}