diff --git a/src/cli/terminal-format.test.ts b/src/cli/terminal-format.test.ts index 42c036471eb..294fbc09cee 100644 --- a/src/cli/terminal-format.test.ts +++ b/src/cli/terminal-format.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from 'vitest' -import { formatTerminalClose, formatTerminalFocus, formatTerminalSend } from './terminal-format' +import type { + RuntimeTerminalShow, + RuntimeTerminalWait, + RuntimeTerminalWaitBlockedReason +} from '../shared/runtime-terminal-contracts' +import { + formatTerminalClose, + formatTerminalFocus, + formatTerminalSend, + formatTerminalShow, + formatTerminalWait +} from './terminal-format' describe('formatTerminalFocus', () => { it('distinguishes superseded navigation from a winning focus', () => { @@ -171,3 +182,73 @@ describe('formatTerminalSend', () => { expect(output).toContain('--retry-request prompt-swallowed --wait-submit ') }) }) + +// Why: an older host still publishes the codex-* tokens for dialogs its matcher never proved were +// Codex's, so a Gemini/Cursor/Antigravity user reads a Codex label unless the CLI names the neutral one. +describe('blocked-reason rendering against a mixed-version host', () => { + function showResult(reason?: RuntimeTerminalWaitBlockedReason): { + terminal: RuntimeTerminalShow + } { + return { + terminal: { + handle: 'term_agy', + ptyId: 'pty-1', + paneRuntimeId: 1, + rendererGraphEpoch: 1, + worktreeId: 'worktree-1', + worktreePath: '/tmp/w', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'Antigravity', + connected: true, + writable: true, + lastOutputAt: null, + preview: 'Do you trust the files in this folder?', + agentWait: { source: 'prompt-text', reason } + } + } + } + + function waitResult(blockedReason: RuntimeTerminalWaitBlockedReason): { + wait: RuntimeTerminalWait + } { + return { + wait: { + handle: 'term_agy', + condition: 'tui-idle', + satisfied: false, + status: 'running', + exitCode: null, + blockedReason + } + } + } + + // Why one assertion over every reason: a test that only asserts the *absence* of an alias suffix + // passes when the aliasing code is deleted, so each case is paired with a legacy token that must + // gain one. + it.each([ + ['codex-trust-workspace', 'codex-trust-workspace (agent-trust-workspace)'], + ['codex-update-prompt', 'codex-update-prompt (agent-update-prompt)'], + ['codex-cwd-prompt', 'codex-cwd-prompt (agent-cwd-prompt)'], + ['codex-hooks-review-prompt', 'codex-hooks-review-prompt (agent-hooks-review-prompt)'], + ['codex-interactive-prompt', 'codex-interactive-prompt (agent-interactive-prompt)'], + // This build published these itself, so there is nothing to reinterpret. + ['agent-trust-workspace', 'agent-trust-workspace'], + ['codex-model-migration-prompt', 'codex-model-migration-prompt'] + ] as const)('renders %s as %s on both wait and show', (reason, rendered) => { + expect(formatTerminalWait(waitResult(reason)).split('\n').at(-1)).toBe( + `blockedReason: ${rendered}` + ) + expect(formatTerminalShow(showResult(reason))).toContain( + `agentWait: ${rendered} (via prompt-text)` + ) + }) + + it('still describes a wait with no reason at all', () => { + expect(formatTerminalShow(showResult(undefined))).toContain( + 'agentWait: interactive prompt (via prompt-text)' + ) + }) +}) diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 46c26556889..06d897828fe 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -1,5 +1,6 @@ import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict' import { structuredChatPtyWriteRefusalCopy } from '../shared/agent-session-pty-write-refusal-copy' +import { describeTerminalWaitBlockedReason } from '../shared/terminal-wait-blocked-reason-legacy-alias' import { formatListingHostScope, type WithAnnotatedHostScope } from './omitted-host-scope-selectors' import type { RuntimeTerminalClose, @@ -118,7 +119,10 @@ function formatAgentWait(agentWait: RuntimeTerminalShow['agentWait']): string { if (!agentWait) { return 'none' } - return `${agentWait.reason ?? 'interactive prompt'} (via ${agentWait.source})` + if (!agentWait.reason) { + return `interactive prompt (via ${agentWait.source})` + } + return `${describeTerminalWaitBlockedReason(agentWait.reason)} (via ${agentWait.source})` } export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string { @@ -278,7 +282,7 @@ export function formatTerminalWait(result: { wait: RuntimeTerminalWait }): strin `exitCode: ${result.wait.exitCode ?? 'null'}` ] if (result.wait.blockedReason) { - lines.push(`blockedReason: ${result.wait.blockedReason}`) + lines.push(`blockedReason: ${describeTerminalWaitBlockedReason(result.wait.blockedReason)}`) } return lines.join('\n') } diff --git a/src/main/runtime/orca-runtime-tail-wait-memo.test.ts b/src/main/runtime/orca-runtime-tail-wait-memo.test.ts index f61431b7b86..5ce8d9710cd 100644 --- a/src/main/runtime/orca-runtime-tail-wait-memo.test.ts +++ b/src/main/runtime/orca-runtime-tail-wait-memo.test.ts @@ -134,7 +134,7 @@ describe('onPtyData tail wait memoization', () => { '' ) expect(blocked.fromTail).toBe(true) - expect(blocked.signal?.reason).toBe('codex-update-prompt') + expect(blocked.signal?.reason).toBe('agent-update-prompt') }) it('does not rebuild or repeatedly scan an ordinary saturated tail', () => { diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts index 6fed887e741..7c0310554ef 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts @@ -247,7 +247,7 @@ describe('OrcaRuntimeService', () => { runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 1_000 }) ).resolves.toMatchObject({ satisfied: false, - blockedReason: 'codex-interactive-prompt' + blockedReason: 'agent-interactive-prompt' }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts index e829be07d9f..682661f0fce 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts @@ -146,11 +146,11 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', satisfied: false, status: 'running', - blockedReason: 'codex-hooks-review-prompt' + blockedReason: 'agent-hooks-review-prompt' }) }) - it('returns a blocked wait result for Codex update prompts', async () => { + it('returns an agent-neutral blocked wait result for update prompts', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), @@ -177,11 +177,11 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', satisfied: false, status: 'running', - blockedReason: 'codex-update-prompt' + blockedReason: 'agent-update-prompt' }) }) - it('returns a blocked wait result for Codex workspace trust prompts', async () => { + it('returns an agent-neutral blocked wait result for workspace trust prompts', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), @@ -203,7 +203,7 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', satisfied: false, status: 'running', - blockedReason: 'codex-trust-workspace' + blockedReason: 'agent-trust-workspace' }) }) @@ -270,7 +270,7 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('timeout') }) - it('returns a blocked wait result for Codex cwd selection prompts', async () => { + it('returns an agent-neutral blocked wait result for cwd selection prompts', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), @@ -297,7 +297,7 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', satisfied: false, status: 'running', - blockedReason: 'codex-cwd-prompt' + blockedReason: 'agent-cwd-prompt' }) }) @@ -359,11 +359,11 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', satisfied: false, status: 'running', - blockedReason: 'codex-hooks-review-prompt' + blockedReason: 'agent-hooks-review-prompt' }) }) - it('returns a blocked wait result for generic Codex interactive prompts', async () => { + it('returns an agent-neutral blocked wait result for generic interactive prompts', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), @@ -390,7 +390,7 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', satisfied: false, status: 'running', - blockedReason: 'codex-interactive-prompt' + blockedReason: 'agent-interactive-prompt' }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index a2d45395b26..ecf2ab53678 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts @@ -623,7 +623,7 @@ describe('OrcaRuntimeService', () => { runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 100 }) ).resolves.toMatchObject({ satisfied: false, - blockedReason: 'codex-trust-workspace' + blockedReason: 'agent-trust-workspace' }) serializeProviderBuffer.mockImplementationOnce(() => new Promise(() => {})) await expect( diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index dbf6a5f4b5e..8d9d92547c8 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -1,4 +1,5 @@ import type { TuiAgent } from '../../../../../../shared/tui-agent' +import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import { buildDispatchPreamble } from '../../../../orchestration/preamble' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { defineMethod } from '../../../core' @@ -222,7 +223,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ } throw new Error( wait.blockedReason - ? `Agent startup blocked: ${wait.blockedReason}` + ? `Agent startup blocked: ${describeTerminalWaitBlockedReason(wait.blockedReason)}` : `Agent did not become ready (${wait.status}).` ) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts index aee45e25259..c9e0f077bc1 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts @@ -476,9 +476,15 @@ describe('orchestration RPC methods', () => { ) }) - it.each(['codex-update-prompt', 'codex-trust-workspace'] as const)( + // Why the second column: an older host still publishes the codex-* token, and this receipt + // reaches the user verbatim -- so it names the neutral spelling the same way the CLI does. + it.each([ + ['codex-update-prompt', 'codex-update-prompt (agent-update-prompt)'], + ['codex-trust-workspace', 'codex-trust-workspace (agent-trust-workspace)'], + ['agent-trust-workspace', 'agent-trust-workspace'] + ] as const)( 'returns a truthful readiness failure for %s', - async (blockedReason) => { + async (blockedReason, expectedReason) => { setup() mockCurrentWorkerStart() vi.mocked(runtime.waitForTerminal).mockResolvedValueOnce({ @@ -500,7 +506,7 @@ describe('orchestration RPC methods', () => { expect(result).toMatchObject({ state: 'failed', failedStage: 'agent_readiness', - lastError: `Agent startup blocked: ${blockedReason}` + lastError: `Agent startup blocked: ${expectedReason}` }) expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index ec188695f5c..f8cd1033c97 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -1,4 +1,5 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import type { OrchestrationDb } from '../../../../orchestration/db' import type { RunRow, TaskRow } from '../../../../orchestration/types' import { resolveDispatchCreator } from '../runs/dispatch-creator' @@ -186,7 +187,7 @@ export async function startLocalWorker(args: { } throw new Error( wait.blockedReason - ? `Agent startup blocked: ${wait.blockedReason}` + ? `Agent startup blocked: ${describeTerminalWaitBlockedReason(wait.blockedReason)}` : structuredSession ? `Setup did not finish before the structured worker started (${wait.status}).` : `Agent did not become ready (${wait.status}).` diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 987bdbb15b7..173c3482b14 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -299,7 +299,7 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { }) await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ - agentWait: { source: 'prompt-text', reason: 'codex-trust-workspace' } + agentWait: { source: 'prompt-text', reason: 'agent-trust-workspace' } }) }) diff --git a/src/main/runtime/terminal-tail-sentinel-index.test.ts b/src/main/runtime/terminal-tail-sentinel-index.test.ts index 2b33b2770ea..cd8bf2e68fb 100644 --- a/src/main/runtime/terminal-tail-sentinel-index.test.ts +++ b/src/main/runtime/terminal-tail-sentinel-index.test.ts @@ -193,7 +193,7 @@ describe('terminal tail sentinel index', () => { expect(tailMayContainBlockedSignal(seeded)).toBe(true) const state = computeTerminalTailWaitState(seeded, '', '') expect(state.fromTail).toBe(true) - expect(state.signal?.reason).toBe('codex-update-prompt') + expect(state.signal?.reason).toBe('agent-update-prompt') const clean = ['boot log', 'no prompt here', 'trailing'] expect(tailMayContainBlockedSignal(clean)).toBe(false) diff --git a/src/main/runtime/terminal-wait-detection.test.ts b/src/main/runtime/terminal-wait-detection.test.ts index eda02e60bbb..e52344c5dc0 100644 --- a/src/main/runtime/terminal-wait-detection.test.ts +++ b/src/main/runtime/terminal-wait-detection.test.ts @@ -98,12 +98,12 @@ const LIVE_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = '2. Trust all and continue', 'Press enter to confirm or esc to go back' ], - reason: 'codex-hooks-review-prompt' + reason: 'agent-hooks-review-prompt' }, { name: 'trust workspace', lines: ['Do you trust this workspace directory?', '1. Yes', '2. No'], - reason: 'codex-trust-workspace' + reason: 'agent-trust-workspace' }, { name: 'update', @@ -113,7 +113,7 @@ const LIVE_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = '2. Skip', 'Press enter to continue' ], - reason: 'codex-update-prompt' + reason: 'agent-update-prompt' }, { name: 'cwd selection', @@ -123,7 +123,7 @@ const LIVE_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = ' Current = your current working directory', ' Press enter to continue' ], - reason: 'codex-cwd-prompt' + reason: 'agent-cwd-prompt' }, { name: 'model migration', @@ -142,7 +142,7 @@ const LIVE_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = '2. No, continue without permissions', 'Press enter to confirm or esc to cancel' ], - reason: 'codex-interactive-prompt' + reason: 'agent-interactive-prompt' }, { name: 'permission required', @@ -153,7 +153,7 @@ const LIVE_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = 'Allow always', 'Reject' ], - reason: 'codex-interactive-prompt' + reason: 'agent-interactive-prompt' } ] @@ -195,6 +195,301 @@ describe('detectTerminalWaitBlockedReason live prompts', () => { 'Press enter to confirm' ]) - expect(detectTerminalWaitBlockedReason(waitText)).toBe('codex-hooks-review-prompt') + expect(detectTerminalWaitBlockedReason(waitText)).toBe('agent-hooks-review-prompt') }) }) + +// Why: these matchers never inspect the pane's agent, so a Codex-named reason on a non-Codex screen +// reaches the user verbatim through the CLI and the worker receipt's "Agent startup blocked:" line. +describe('detectTerminalWaitBlockedReason on non-Codex agents', () => { + const NON_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = [ + { + name: 'an Antigravity workspace trust dialog', + lines: [ + 'Antigravity CLI 1.0.3', + 'Do you trust the files in this folder?', + '1. Yes, I trust this folder', + '2. No, exit' + ], + reason: 'agent-trust-workspace' + }, + { + name: 'a Claude Code trusted-workspace dialog', + lines: [ + 'Claude Code', + 'Trusted workspace?', + 'This directory has not been opened before.', + '1. Yes, proceed', + '2. No, exit' + ], + reason: 'agent-trust-workspace' + }, + { + name: 'a Gemini CLI update banner', + lines: [ + 'Gemini CLI', + 'Update available! 1.4.0 -> 1.5.0', + '1. Update now', + '2. Skip', + 'Press enter to continue' + ], + reason: 'agent-update-prompt' + }, + { + name: 'a Gemini CLI permission dialog', + lines: [ + 'Gemini CLI', + 'Permission required', + 'Running this tool requires permission', + 'Allow once', + 'Allow always', + 'Reject' + ], + reason: 'agent-interactive-prompt' + }, + { + name: 'a Claude Code hooks review dialog', + lines: [ + 'Claude Code', + 'Hooks need review', + 'PreToolUse:Bash .claude/hooks/guard.sh', + 'Press enter to confirm' + ], + reason: 'agent-hooks-review-prompt' + }, + { + name: 'an Antigravity sandbox confirmation', + lines: [ + 'Antigravity CLI 1.0.3', + 'This action runs outside the sandbox.', + 'Press enter to confirm or esc to go back' + ], + reason: 'agent-interactive-prompt' + } + ] + + // Why: the reason was previously picked by looking for 'codex' in 600 chars of scrollback, so any + // agent that merely narrated about Codex handed its user a Codex label. + it('does not borrow a Codex label from scrollback that only mentions Codex', () => { + const waitText = waitTextFor([ + 'Antigravity CLI 1.0.3', + 'I read src/codex-notes.md for you.', + 'This action runs outside the sandbox.', + 'Press enter to confirm or esc to go back' + ]) + + expect(waitText.toLowerCase()).toContain('codex') + expect(detectTerminalWaitBlockedReason(waitText)).toBe('agent-interactive-prompt') + }) + + for (const prompt of NON_CODEX_PROMPTS) { + it(`reports an agent-neutral reason for ${prompt.name}`, () => { + const waitText = waitTextFor(prompt.lines) + const reason = detectTerminalWaitBlockedReason(waitText) + + expect(waitText.toLowerCase()).not.toContain('codex') + expect(reason).toBe(prompt.reason) + expect(reason?.startsWith('codex-')).toBe(false) + }) + } +}) + +// Antigravity readiness, and what this file does NOT claim about it. +// +// The detector recognizes a ready screen by header + a 'gemini'-prefixed model line + a lone '>' +// caret. That is narrow: an Antigravity user on a non-Gemini model never reaches ready and the pane +// wedges. Widening it was attempted and reverted -- every candidate rule was tuned against the +// constructed fixtures below, and the last one let a live sign-in dialog read as ready (the +// orchestrator then types the task prompt into an authentication dialog, which is strictly worse +// than a timeout). No real Antigravity transcript exists in this repo; the cursor-agent rules are +// derived from captures under src/main/runtime/__fixtures__ and Antigravity has no equivalent. +// Widening the model rule needs one first. See the ratchet at the bottom of this block for the +// shapes any replacement has to refuse. +describe('Antigravity readiness does not absorb its own startup dialog', () => { + const TRUST_DIALOG_WITH_CARET = [ + 'Antigravity CLI 1.0.3', + 'Do you trust the files in this folder?', + '1. Yes, I trust this folder', + '2. No, exit', + '>' + ] + + const LIVE_DIALOGS_UNDER_THE_HEADER: { name: string; lines: string[]; reason: string | null }[] = + [ + { + name: 'a bare trust dialog', + lines: TRUST_DIALOG_WITH_CARET, + reason: 'agent-trust-workspace' + }, + { + name: 'a trust dialog with an ordinary sentence in it', + lines: [ + 'Antigravity CLI 1.0.3', + 'This workspace has not been opened before.', + 'Do you trust the files in this folder?', + '1. Yes, I trust this folder', + '2. No, exit', + '>' + ], + reason: 'agent-trust-workspace' + }, + { + name: 'a trust dialog printing the folder on its own line', + lines: [ + 'Antigravity CLI 1.0.3', + 'Do you trust the files in this folder?', + '~/orca/workspaces/orca/agy-dispatch-issue', + '1. Yes', + '2. No', + '>' + ], + reason: 'agent-trust-workspace' + } + ] + + for (const dialog of LIVE_DIALOGS_UNDER_THE_HEADER) { + it(`reports ${dialog.name} drawn under the header and stays unready`, () => { + const waitText = waitTextFor(dialog.lines) + + expect(detectTerminalWaitBlockedReason(waitText)).toBe(dialog.reason) + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + } + + // Discriminating: the Gemini model line and caret satisfy readiness, so only the dialog sitting + // *below* them keeps this unready. Drop the ordering rule and this goes green-to-red. + it('keeps reporting a dialog that opens after a Gemini ready screen', () => { + const waitText = waitTextFor([ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Gemini 3.5 Flash (High)', + '~/orca/workspaces/orca/agy-dispatch-issue', + '>', + 'Permission required', + 'Allow once', + 'Reject' + ]) + + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + expect(detectTerminalWaitBlockedReason(waitText)).toBe('agent-interactive-prompt') + }) + + // Discriminating: a stale dialog above a reprinted Gemini ready screen must stop being reported, + // which is the whole point of the dismissed-modal rule. + it('clears once a Gemini ready screen replaces the dialog', () => { + const waitText = waitTextFor([ + ...TRUST_DIALOG_WITH_CARET, + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Gemini 3.5 Flash (High)', + '>' + ]) + + expect(isKnownReadyPromptPreview(waitText)).toBe(true) + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + }) + + // Characterization, not a guard: records the wedge this file has not fixed. An Antigravity user on + // a non-Gemini model has no 'gemini' line, so readiness never resolves and the wait times out. + // Flipping this to true is the goal of the follow-up, and needs a captured transcript first. + it('does not yet recognize a non-Gemini ready screen (known wedge)', () => { + const waitText = waitTextFor([ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Claude Sonnet 4.5 (High)', + '~/orca/workspaces/orca/agy-dispatch-issue', + '>' + ]) + + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + + // Ratchet, not a guard of today's code: these pass now only because none of them prints a 'gemini' + // model line. They exist so the next attempt to widen the model rule has to refuse them -- the + // reverted attempt accepted all five as ready on the strength of the account row alone (and an + // 'x@y.z' anywhere in the dialog body did just as well), and readiness is what gates typing the + // task prompt into the pane. A replacement must rest on positive evidence that the agent's input + // prompt is accepting input, not on absence-of-dialog plus an account row. + const SILENT_STARTUP_DIALOGS: { name: string; lines: string[] }[] = [ + { + name: 'an update banner', + lines: [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'A new version is available', + '~/orca/workspaces/orca/agy-dispatch-issue', + 'Press enter to continue', + '>' + ] + }, + { + name: 'a sign-in dialog', + lines: [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Sign in to continue', + '~/orca/workspaces/orca/agy-dispatch-issue', + '1. Open browser', + '2. Paste an API key', + '>' + ] + }, + { + name: 'a model picker', + lines: [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Select a model', + '~/orca/workspaces/orca/agy-dispatch-issue', + '1. Claude Sonnet 4.5', + '2. GPT-5.1', + '>' + ] + }, + { + name: 'a privacy notice', + lines: [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'We collect usage data to improve the product', + '~/orca/workspaces/orca/agy-dispatch-issue', + '1. Accept', + '2. Decline', + '>' + ] + }, + { + name: 'an onboarding theme picker', + lines: [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Welcome! Choose a theme', + '~/orca/workspaces/orca/agy-dispatch-issue', + '1. Dark', + '2. Light', + '>' + ] + } + ] + + for (const dialog of SILENT_STARTUP_DIALOGS) { + it(`refuses ${dialog.name} whose wording names no blocked reason, account row and all`, () => { + const waitText = waitTextFor(dialog.lines) + + // No blocked-signal rule matches, so the ordering defense cannot reach these: readiness has to + // refuse them on its own or the orchestrator types into a live dialog. + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + + it(`refuses ${dialog.name} that merely narrates an email address`, () => { + const waitText = waitTextFor([ + ...dialog.lines.slice(0, -1), + 'contact support@antigravity.dev for help', + '>' + ]) + + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + } +}) diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index ed957d1fe2d..cae25e8bfa6 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -231,11 +231,11 @@ function findBlockedSignalInLiveWindow( 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 }) + candidates.push({ reason: 'agent-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 }) + candidates.push({ reason: 'agent-cwd-prompt', index: cwdIndex }) } const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') if ( @@ -246,7 +246,8 @@ function findBlockedSignalInLiveWindow( } 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 }) + // Why neutral: this matcher never inspects the agent -- 'hooks need review' is not Codex-only wording. + candidates.push({ reason: 'agent-hooks-review-prompt', index: hooksIndex }) } const trustIndex = Math.max( normalized.lastIndexOf('do you trust'), @@ -261,7 +262,8 @@ function findBlockedSignalInLiveWindow( trustSegment.includes('directory') || trustSegment.includes('repo')) ) { - candidates.push({ reason: 'codex-trust-workspace', index: trustIndex }) + // Why neutral: this matcher never inspects the agent -- every TUI agent ships a workspace-trust dialog. + candidates.push({ reason: 'agent-trust-workspace', index: trustIndex }) } const interactivePromptIndex = Math.max( normalized.lastIndexOf('press enter to confirm'), @@ -274,19 +276,22 @@ function findBlockedSignalInLiveWindow( interactivePromptIndex === -1 ? '' : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) - const hasCodexInteractiveContext = + // Why 'codex' only widens detection and never names the reason: the sole Codex evidence here is + // that word somewhere in 600 chars of scrollback, which an agent narrating about Codex satisfies + // on any pane -- enough to suspect a dialog, not enough to label a non-Codex user's pane. + const hasInteractiveDialogContext = interactivePromptContext.includes('codex') || interactivePromptContext.includes('permission') || interactivePromptContext.includes('sandbox') || interactivePromptContext.includes('trust') || interactivePromptContext.includes('hook') - if (interactivePromptIndex !== -1 && hasCodexInteractiveContext) { + if (interactivePromptIndex !== -1 && hasInteractiveDialogContext) { 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 }) + candidates.push({ reason: 'agent-interactive-prompt', index: interactivePromptIndex }) } } const cursorApprovalIndex = findCursorApprovalPromptIndex(normalized) @@ -303,8 +308,13 @@ function findBlockedSignalInLiveWindow( permissionSegment.includes(choice) ).length if (decisionCount >= 2) { - // Why: preserve the existing remote receipt value for mixed-version clients. - candidates.push({ reason: 'codex-interactive-prompt', index: permissionPromptIndex }) + // Why neutral: an approval dialog with named choices identifies no agent; older hosts publish + // 'codex-interactive-prompt' here and clients alias the two. Rule 1 additive member -- + // remote-wire-compatibility.md names RuntimeTerminalWaitBlockedReason as Rule 1 because no + // consumer switches exhaustively on it. + // Why alias rather than drop the old spelling: preserve the existing remote receipt value for + // mixed-version clients -- an older host still publishes codex-* on this path. + candidates.push({ reason: 'agent-interactive-prompt', index: permissionPromptIndex }) } } return candidates.length > 0 diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index db1c3751ba8..ad392a2b9a0 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -330,6 +330,10 @@ export type RuntimeTerminalClose = { export type RuntimeTerminalWaitCondition = 'exit' | 'tui-idle' +// Why both spellings: the codex-* members were published by every host before the agent-neutral +// rename, so they are permanent — a client still has to read them off an older host. This build +// keeps a codex-* reason only where the matched wording is plausibly Codex's own; every matcher +// that inspects no agent publishes the agent-* spelling. export type RuntimeTerminalWaitBlockedReason = | 'codex-update-prompt' | 'codex-trust-workspace' @@ -337,6 +341,11 @@ export type RuntimeTerminalWaitBlockedReason = | 'codex-model-migration-prompt' | 'codex-hooks-review-prompt' | 'codex-interactive-prompt' + | 'agent-update-prompt' + | 'agent-trust-workspace' + | 'agent-cwd-prompt' + | 'agent-hooks-review-prompt' + | 'agent-interactive-prompt' | 'agent-approval-prompt' export type RuntimeTerminalWait = { diff --git a/src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts b/src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts new file mode 100644 index 00000000000..2e3e7e4687c --- /dev/null +++ b/src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + agentNeutralTerminalWaitBlockedReason, + describeTerminalWaitBlockedReason +} from './terminal-wait-blocked-reason-legacy-alias' +import type { RuntimeTerminalWaitBlockedReason } from './runtime-terminal-contracts' + +describe('agentNeutralTerminalWaitBlockedReason', () => { + it.each([ + ['codex-update-prompt', 'agent-update-prompt'], + ['codex-trust-workspace', 'agent-trust-workspace'], + ['codex-cwd-prompt', 'agent-cwd-prompt'], + ['codex-hooks-review-prompt', 'agent-hooks-review-prompt'], + ['codex-interactive-prompt', 'agent-interactive-prompt'] + ] as const)('renames %s published by an older host to %s', (legacy, neutral) => { + expect(agentNeutralTerminalWaitBlockedReason(legacy)).toBe(neutral) + }) + + // Why no alias: this build still publishes it, so aliasing it would rename a live reason rather + // than reinterpret an older host's -- and 'codex just got an upgrade' does name Codex. + it('leaves the agent-specific codex-model-migration-prompt alone', () => { + expect(agentNeutralTerminalWaitBlockedReason('codex-model-migration-prompt')).toBeNull() + }) + + it.each(['agent-approval-prompt', 'agent-trust-workspace', 'agent-hooks-review-prompt'] as const)( + 'reports no alias for the already-neutral %s', + (reason) => { + expect(agentNeutralTerminalWaitBlockedReason(reason)).toBeNull() + } + ) + + // Why: the reason is JSON off the wire with no enum to validate it, and an object-literal lookup + // would answer these from Object.prototype -- the CLI would then print a function to the user. + it.each(['constructor', 'toString', 'valueOf', '__proto__', 'hasOwnProperty'])( + 'reports no alias for the prototype key %s', + (reason) => { + expect( + agentNeutralTerminalWaitBlockedReason(reason as RuntimeTerminalWaitBlockedReason) + ).toBeNull() + } + ) +}) + +// Why one formatter: the CLI's wait/show output and the worker and federation "Agent startup +// blocked:" receipts all render this token, and only the CLI used to alias it. +describe('describeTerminalWaitBlockedReason', () => { + it('names the neutral spelling beside a legacy token', () => { + expect(describeTerminalWaitBlockedReason('codex-trust-workspace')).toBe( + 'codex-trust-workspace (agent-trust-workspace)' + ) + }) + + it.each(['agent-trust-workspace', 'codex-model-migration-prompt'] as const)( + 'renders %s unannotated', + (reason) => { + expect(describeTerminalWaitBlockedReason(reason)).toBe(reason) + } + ) +}) diff --git a/src/shared/terminal-wait-blocked-reason-legacy-alias.ts b/src/shared/terminal-wait-blocked-reason-legacy-alias.ts new file mode 100644 index 00000000000..50ce940e608 --- /dev/null +++ b/src/shared/terminal-wait-blocked-reason-legacy-alias.ts @@ -0,0 +1,34 @@ +import type { RuntimeTerminalWaitBlockedReason } from './runtime-terminal-contracts' + +// Why: hosts older than the agent-neutral spellings still publish the codex-* tokens for dialogs +// their matcher never proved were Codex's, so a paired client renders the neutral equivalent +// instead of showing a Codex label to a Gemini/Cursor/Antigravity user. +// Why a Map: the reason arrives off the wire unvalidated, and a plain object would answer +// 'constructor' or 'toString' from Object.prototype and print a function to the user. +// Why one-directional: nothing consumes an agent-* -> codex-* mapping. A new host's agent-* token +// reaching an old client is rendered by that client's shipped code, which this build cannot change. +const LEGACY_CODEX_REASON_ALIASES = new Map([ + ['codex-update-prompt', 'agent-update-prompt'], + ['codex-trust-workspace', 'agent-trust-workspace'], + ['codex-cwd-prompt', 'agent-cwd-prompt'], + ['codex-hooks-review-prompt', 'agent-hooks-review-prompt'], + ['codex-interactive-prompt', 'agent-interactive-prompt'] +]) + +/** Neutral spelling for a reason an older host published, or null when it is already neutral or agent-specific. */ +export function agentNeutralTerminalWaitBlockedReason( + reason: RuntimeTerminalWaitBlockedReason +): RuntimeTerminalWaitBlockedReason | null { + return LEGACY_CODEX_REASON_ALIASES.get(reason) ?? null +} + +/** + * A blocked reason as shown to a user: the token the host published, plus the neutral spelling when + * the host predates it. Why append and not replace: the raw token is what scripts parse. + */ +export function describeTerminalWaitBlockedReason( + reason: RuntimeTerminalWaitBlockedReason +): string { + const neutral = agentNeutralTerminalWaitBlockedReason(reason) + return neutral ? `${reason} (${neutral})` : reason +}