fix(runtime): agent-neutral wait-blocked reasons (#19749)

* fix(runtime): agent-neutral wait-blocked reasons and non-Gemini Antigravity readiness

Reported by a user via the in-app help menu (report "not captured", 1.4.198).

The trust/interactive/update/cwd prompt matchers are agent-agnostic - they
match on dialog wording and never inspect the pane's agent - yet emitted
hardcoded codex-* reasons. Those reached users verbatim in worker receipts
(local-worker-start, federation), two automation surfaces, and raw CLI
output, so an Antigravity user was told they had a Codex problem.

findAntigravityReadyPromptIndex also required the model line to start with
the literal "gemini". Antigravity CLI is not Gemini-only, so a non-Gemini
session never registered as ready, stale trust text was never superseded,
and the pane stayed blocked - which is why dispatch --inject answered
agent_prompt_blocked.

Add agent-neutral reasons additively (codex-* members kept on the wire per
docs/reference/remote-wire-compatibility.md, with a legacy alias for older
hosts) and decide Antigravity readiness structurally: header, then
model/account rows, then the prompt caret.

codex-model-migration-prompt and codex-hooks-review-prompt stay Codex-named
- both key on Codex's own wording.

* fix(runtime): finish the agent-neutral rename, revert the Antigravity readiness rewrite

Review follow-up on this branch. Splits the two halves of the original commit:
the reason rename lands, the Antigravity readiness detector goes back to
merge-base until someone captures a real transcript.

Rename half:

- 'hooks need review' + 'press enter to confirm' inspects no agent, so it now
  publishes agent-hooks-review-prompt. That was the last agent-agnostic codex-*
  emission left, and it is the one the original report was about: a Claude Code
  user hitting a hooks dialog still read "codex-hooks-review-prompt".
- The legacy alias is applied at all three surfaces that render a raw reason,
  not just the CLI. describeTerminalWaitBlockedReason() is the single formatter;
  the worker and federation "Agent startup blocked:" receipts use it too. Kept
  one-directional: nothing consumes agent-* -> codex-*, since an old client
  renders with its own shipped code.
- Restores the compat note deleted at the permission-choices site. The Rule 1
  citation is correct - remote-wire-compatibility.md names this enum by name.

Antigravity half, reverted:

findAntigravityReadyPromptIndex goes back to merge-base (header + a 'gemini'
model line + a lone '>' caret) and antigravity-ready-prompt-index.ts is removed.

Executing both builds against constructed tails, the rewrite read a live
startup dialog as ready. Adding the account row from this repo's own ready-screen
fixture to five silent startup dialogs (sign-in, model picker, theme picker,
privacy notice, update banner) flipped all five from unready to ready; so did any
narration line containing an email address, with no account row at all. Readiness
is what gates typing the task prompt into the pane, so that path types a task
prompt into a live authentication dialog. Merge-base returns unready for all ten.

The rewrite also did not reliably fix the wedge it targeted: with no account row
and a non-Gemini model - a personal or API-key user - it still returns unready.

No real Antigravity transcript exists in this repo. The cursor-agent rules are
derived from captures under src/main/runtime/__fixtures__; Antigravity has no
equivalent, and every attempt so far has been tuned against a hand-written
5-line fixture. A false negative (the agent waits) is safer than a false positive
(we type into an auth dialog), so this ships the known behaviour.

Reverting restores a pre-existing gap, not a regression: a non-Gemini Antigravity
session wedges on merge-base too. Closing it needs a captured ready screen and a
captured dismissed-dialog screen, for a personal/API-key account as well as a
Business one.

Tests:

- Ten ratchet fixtures pin the shapes any replacement detector must refuse - the
  five silent dialogs with an account row, and each with a narrated email. All
  ten fail against the reverted rewrite.
- Vacuous tests rewritten so they fail without the code they cover: the CLI
  alias tests asserted only the absence of a suffix, and the worker receipt test
  asserted the raw token. Tests that are characterization rather than a guard
  now say so on the line above.

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
OrcaWin
2026-09-10 23:44:23 -07:00
committed by GitHub
co-authored by m4air Neil
parent 26db907895
commit b3e0a33fa4
16 changed files with 539 additions and 39 deletions
+82 -1
View File
@@ -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 <seconds>')
})
})
// 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)'
)
})
})
+6 -2
View File
@@ -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')
}
@@ -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', () => {
@@ -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'
})
})
@@ -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'
})
})
@@ -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(
@@ -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}).`
)
}
@@ -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()
}
@@ -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}).`
@@ -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' }
})
})
@@ -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)
@@ -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)
})
}
})
+19 -9
View File
@@ -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
+9
View File
@@ -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 = {
@@ -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)
}
)
})
@@ -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<string, RuntimeTerminalWaitBlockedReason>([
['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
}