Files
orca/src/cli/terminal-format.test.ts
T
b3e0a33fa4 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>
2026-09-10 23:44:23 -07:00

255 lines
7.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
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', () => {
expect(
formatTerminalFocus({
focus: {
handle: 'term_stale',
tabId: 'tab-stale',
worktreeId: 'worktree-1',
navigated: false
}
})
).toBe(
'Focus request for terminal term_stale was superseded or host navigation was skipped (tab tab-stale).'
)
expect(
formatTerminalFocus({
focus: { handle: 'term_winner', tabId: 'tab-winner', worktreeId: 'worktree-1' }
})
).toBe('Focused terminal term_winner (tab tab-winner).')
})
})
describe('formatTerminalClose', () => {
it('prints "PTY killed." only for a confirmed kill', () => {
expect(
formatTerminalClose({ close: { handle: 'term_local', tabId: 'tab-1', ptyKilled: true } })
).toBe('Closed terminal term_local. PTY killed.')
})
it('says the remote process was not confirmed stopped instead of claiming a kill', () => {
expect(
formatTerminalClose({
close: {
handle: 'term_remote',
tabId: 'tab-1',
ptyKilled: false,
ptyStopVerdict: 'unverifiable',
ptyStopReason: 'its SSH provider is no longer registered'
}
})
).toBe(
'Closed terminal term_remote. The PTY was not confirmed stopped: its SSH provider is no longer registered.'
)
})
it('names a PTY known to be live', () => {
expect(
formatTerminalClose({
close: {
handle: 'term_live',
tabId: 'tab-1',
ptyKilled: false,
ptyStopVerdict: 'live'
}
})
).toBe('Closed terminal term_live. The PTY is live.')
})
})
describe('formatTerminalSend', () => {
it('exposes the provider and healthy delivery observation', () => {
expect(
formatTerminalSend({
send: {
handle: 'term_worker',
accepted: true,
bytesWritten: 8,
prompt: {
requestId: 'prompt-healthy',
stages: ['input_accepted', 'turn_started'],
provider: 'codex',
observation: 'supported',
processIncarnation: 'inc-1',
generation: 1,
baselineWorkingSequence: 0
}
}
})
).toBe(
[
'Prompt prompt-healthy on term_worker: input_accepted -> turn_started.',
'provider: codex',
'delivery observation: supported'
].join('\n')
)
})
it.each([
{
observation: 'permission' as const,
warning: 'Resolve the permission prompt in the terminal',
nextStep: '--retry-request prompt-unhealthy'
},
{
observation: 'incarnation_replaced' as const,
warning: 'the terminal process was replaced',
nextStep: 'Inspect the current terminal before sending a new prompt'
}
])('warns and gives a next step for $observation', ({ observation, warning, nextStep }) => {
const output = formatTerminalSend({
send: {
handle: 'term_worker',
accepted: true,
bytesWritten: 8,
prompt: {
requestId: 'prompt-unhealthy',
stages: ['input_accepted'],
provider: 'codex',
observation,
processIncarnation: 'inc-1',
generation: 1,
baselineWorkingSequence: 0
}
}
})
expect(output).toContain(`provider: codex`)
expect(output).toContain(`delivery observation: ${observation}`)
expect(output).toContain(`warning: delivery was not observed`)
expect(output).toContain(warning)
expect(output).toContain(nextStep)
})
it.each([
{ provider: 'claude' as const, expected: 'no turn start was observed' },
{ provider: 'unsupported' as const, expected: 'this provider cannot report delivery' },
{ provider: 'old-host' as const, expected: 'predates durable prompt receipts' }
])('warns per provider when delivery was not observed ($provider)', ({ provider, expected }) => {
const output = formatTerminalSend({
send: {
handle: 'term_worker',
accepted: true,
bytesWritten: 8,
prompt: {
requestId: 'prompt-unobserved',
stages: ['input_accepted'],
provider,
observation: 'unsupported',
processIncarnation: 'inc-1',
generation: 1,
baselineWorkingSequence: 0
}
}
})
expect(output).toContain(expected)
})
it('names the next command when a supported send never reached turn_started', () => {
const output = formatTerminalSend({
send: {
handle: 'term_worker',
accepted: true,
bytesWritten: 8,
prompt: {
requestId: 'prompt-swallowed',
stages: ['input_accepted'],
provider: 'claude',
observation: 'supported',
processIncarnation: 'inc-1',
generation: 1,
baselineWorkingSequence: 0
}
}
})
expect(output).toContain('no turn start was observed')
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)'
)
})
})