mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(native-chat): guard sends during terminal prompts
This commit is contained in:
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -14746,7 +14746,8 @@
|
||||
"max": "Max",
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
}
|
||||
},
|
||||
"finishTerminalPrompt": "Finish the terminal prompt before sending"
|
||||
},
|
||||
"tool": {
|
||||
"running": "Running…",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user