mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+10
-10
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user