From 30f2bc60f935f2b97be3cb7961cde79a4f7cc4ed Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:01:45 +0900 Subject: [PATCH] fix(antigravity): recognize non-Gemini tui-idle prompts (#21231) * fix(antigravity): recognize non-Gemini tui-idle prompts * fix(antigravity): reject stale composer caret in model picker * fix(antigravity): do not treat a wrap continuation caret as ready An unsent composer can show `> draft` then an indented `>`. That continuation is not an empty input box, so tui-idle must stay false. * test(antigravity): align later bare-caret status expectation --------- Co-authored-by: Neil --- .../antigravity-readiness-evidence.md | 48 +++++++------ .../antigravity-readiness-transcripts.test.ts | 16 ++--- ...l-handles-and-agent-status-part-03.spec.ts | 4 +- .../runtime/terminal-wait-detection.test.ts | 69 +++++++++++-------- src/main/runtime/terminal-wait-detection.ts | 38 +++++++--- 5 files changed, 105 insertions(+), 70 deletions(-) diff --git a/docs/reference/antigravity-readiness-evidence.md b/docs/reference/antigravity-readiness-evidence.md index 0010fa76ded..2700730fccf 100644 --- a/docs/reference/antigravity-readiness-evidence.md +++ b/docs/reference/antigravity-readiness-evidence.md @@ -10,10 +10,10 @@ Real transcripts now exist. They were recorded from a live `agy` on macOS with `src/main/runtime/__fixtures__/`. `src/main/runtime/antigravity-readiness-transcripts.test.ts` replays them through the runtime. -**Headline: on real output the current detector is inverted.** It refuses a genuinely ready screen -and accepts a live model picker. The five attempts argued about which extra condition to add; none -of them had noticed that the condition they all shared — a line beginning with the model name — -never matches a real Antigravity ready screen at all. +**Headline: the captured ready screen needs a bare-caret rule, and an active model picker must veto +that stale caret.** The earlier detector refused the genuine ready screen and accepted a live model +picker. The shipped attempt-six rule accepts the bare composer caret, while the active-picker guard +keeps a retained caret from satisfying `tui-idle` until `/model` exits. ## Versions @@ -68,19 +68,20 @@ painted **on the same physical lines as the logo**. What Orca derives is: ▄▀▀ ▀▀▄ ~ ``` -The detector requires `normalized.startsWith('gemini', trimmedStart)` on a trimmed line. The -trimmed line starts with `▀`. It never matches. Measured three ways on the real screen: +The earlier detector required `normalized.startsWith('gemini', trimmedStart)` on a trimmed line. The +trimmed line starts with `▀`, so that rule never matched. The shipped detector uses the bare +composer caret instead. Measured three ways on the real screen: | Input | `isKnownReadyPromptPreview` | | ------------------------------------------------------ | --------------------------- | -| Real ready screen | `false` | +| Real ready screen | `true` | | The same screen with the logo glyphs stripped | `true` | -| Real ready screen followed by the live `/model` picker | `true` | +| Real ready screen followed by the live `/model` picker | `false` | -So the logo — decoration, and suppressible with `AGY_CLI_HIDE_LOGO` — is what decides readiness -today, and the live dialog is what supplies the model line the ready screen could not. +So the logo — decoration, and suppressible with `AGY_CLI_HIDE_LOGO` — no longer decides readiness, +and the live dialog cannot reuse the stale composer caret as a ready signal. -### 2. The dialog is what satisfies the model rule +### 2. The dialog used to satisfy the model rule `/model` prints its options one per line: @@ -91,9 +92,9 @@ Gemini 3.1 Pro ``` Those lines _do_ begin with `Gemini`, and a bare `>` composer line sits earlier in the same tail -from before the picker opened. Both halves of the rule are satisfied **while a dialog owns the -screen**, and the pane reads ready. This is the false-ready hazard the last three attempts were -each trying to close, reproduced from a real capture. +from before the picker opened. Both halves of the old rule were satisfied **while a dialog owned the +screen**, and the pane read ready. The shipped detector now recognizes the active `Switch Model` +surface and rejects that stale caret until it sees `Exited /model command`. ### 3. `>` is the dialog selection marker, not only the composer caret @@ -233,29 +234,32 @@ expressed against the model/caret positions, which is what 1.2 and 1.3b just inv | X4 | Banner-to-caret distance | ~8 derived lines on a 120x40 PTY; the banner falls outside the 6-line preview window, so only the full retained tail can see it | | X5 | Pane title on the trust screen versus ready | Identical: none | -## Can attempt six be written? +## Attempt six is shipped Yes — but not as a variation on any of the five. Every one of them refined a predicate over `\n`-delimited lines, and that is the layer where the evidence says the information is not. -What the captures support: +What the captures support and the shipped detector now does: - **The one stable, dialog-free ready marker is a line whose entire trimmed content is `>`.** It is present in every ready capture and absent from every dialog capture, because a dialog's `>` always carries its selected row's label. This is a much narrower rule than any attempt used, and it is the only one that survived contact with the transcripts. -- **Drop the model-row requirement.** It matches dialogs and not ready screens. Keeping it inverted - the detector. +- **Drop the model-row requirement.** The model rows match dialogs and not the ready screen, so + keeping that requirement inverted the detector. - **Do not require an account row.** It is optional by environment variable and carries no email for API-key users. +- **Veto an active model picker.** `Switch Model` followed by a labeled selection row means the + bare caret belongs to the composer behind the picker; readiness resumes after `Exited /model + command`. - **Do not anchor on `headerIndex`.** The banner is printed once and never reprinted. - **The blocked-signal path already works** for the trust dialog: `antigravity-dialog-trust-workspace.txt` is correctly refused today, by wording, not by structure. -What is still unknown and should be captured before shipping: the sign-in, theme, privacy and -update dialogs, and any ready screen where the composer is not idle (accept-edits and plan mode, -which PRs #15840 and #15852 describe from a screenshot). A bare-`>` rule is only as good as the -claim that those modes still end on a bare `>`; that claim is untested. +What is still unknown and should be captured: the sign-in, theme, privacy and update dialogs, and +any ready screen where the composer is not idle (accept-edits and plan mode, which PRs #15840 and +#15852 describe from a screenshot). A bare-`>` rule is only as good as the claim that those modes +still end on a bare `>`; that claim is untested. The honest summary is that this is a screen-shaped problem being solved with line-shaped tools. A rule over the derived tail can be made much better than what ships today, but the durable fix is to diff --git a/src/main/runtime/antigravity-readiness-transcripts.test.ts b/src/main/runtime/antigravity-readiness-transcripts.test.ts index 3ac7707565f..1bc8ea272ed 100644 --- a/src/main/runtime/antigravity-readiness-transcripts.test.ts +++ b/src/main/runtime/antigravity-readiness-transcripts.test.ts @@ -6,8 +6,8 @@ * Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name * themselves as skipped until someone can reach them. * - * Four cases are pinned as KNOWN DEFECT: on real output the shipped detector refuses the ready - * screen and accepts the live model picker. Those assert what it does, not what it should. + * One case is pinned as a KNOWN DEFECT: the shipped detector refuses a ready screen whose retained + * tail ends on the error block. That asserts what it does, not what it should. * * Capture protocol: docs/reference/agent-pty-transcript-capture.md * What each transcript decides: docs/reference/antigravity-readiness-evidence.md @@ -66,15 +66,13 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [ name: 'antigravity-ready-api-key-gemini-model', capture: 'B', what: 'ready screen, API-key identity — the account row reads "Gemini API key", not an email', - expectReady: true, - knownDefect: 'refused: the model row never starts a line, the logo shares it' + expectReady: true }, { name: 'antigravity-ready-account-info-hidden', capture: 'B', what: 'ready screen with AGY_CLI_HIDE_ACCOUNT_INFO=1 — no account row at all', - expectReady: true, - knownDefect: 'refused: same line-start defect, and no account row exists to require' + expectReady: true }, { name: 'antigravity-dialog-trust-workspace', @@ -86,8 +84,7 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [ name: 'antigravity-dialog-model-picker', capture: 'C', what: 'model picker owning the screen', - expectReady: false, - knownDefect: "accepted: the picker's own `Gemini 3.x Flash` rows satisfy the model rule" + expectReady: false }, { name: 'antigravity-dialog-command-palette', @@ -114,8 +111,7 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [ name: 'antigravity-dialog-dismissed', capture: 'D', what: 'the screen immediately after the model picker is dismissed', - expectReady: true, - knownDefect: 'refused: the banner is not reprinted and no model row starts a line' + expectReady: true }, // Not captured: this machine's agy has no OAuth session and offers only Gemini models, and // reaching the rest would mean signing the operator out or deleting their config. See diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-03.spec.ts index 1f2f5ebb5eb..a7deb718557 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-03.spec.ts @@ -236,7 +236,7 @@ describe('OrcaRuntimeService', () => { await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(false) }) - it('rejects a later Antigravity header with a prompt but no model line', async () => { + it('recognizes a later Antigravity header with a prompt but no model line', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), @@ -263,7 +263,7 @@ describe('OrcaRuntimeService', () => { 100 ) - await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(false) + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true) }) it('uses the latest Antigravity header when checking readiness', async () => { diff --git a/src/main/runtime/terminal-wait-detection.test.ts b/src/main/runtime/terminal-wait-detection.test.ts index e52344c5dc0..78d4945f2b2 100644 --- a/src/main/runtime/terminal-wait-detection.test.ts +++ b/src/main/runtime/terminal-wait-detection.test.ts @@ -296,22 +296,16 @@ describe('detectTerminalWaitBlockedReason on non-Codex agents', () => { // 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. +// The detector recognizes a ready screen by the Antigravity header and a lone '>' caret. Model +// names are not part of the signal: the logo can prefix the row, and Antigravity can run models +// other than Gemini. Dialog selections keep their labels after '>', so they remain distinguishable. 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', - '>' + '> Yes, I trust this folder' ] const LIVE_DIALOGS_UNDER_THE_HEADER: { name: string; lines: string[]; reason: string | null }[] = @@ -329,7 +323,7 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { 'Do you trust the files in this folder?', '1. Yes, I trust this folder', '2. No, exit', - '>' + '> Yes, I trust this folder' ], reason: 'agent-trust-workspace' }, @@ -341,7 +335,7 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { '~/orca/workspaces/orca/agy-dispatch-issue', '1. Yes', '2. No', - '>' + '> Yes' ], reason: 'agent-trust-workspace' } @@ -374,6 +368,19 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { expect(detectTerminalWaitBlockedReason(waitText)).toBe('agent-interactive-prompt') }) + it('rejects a stale composer caret while the Antigravity model picker is active', () => { + const waitText = waitTextFor([ + 'Antigravity CLI 1.2.0', + '>', + 'Switch Model', + '> Gemini 3.8 Flash', + 'Gemini 3.7 Flash (current)', + 'Keyboard: ↑/↓ Navigate · enter Select · esc Go Back' + ]) + + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + // 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', () => { @@ -389,10 +396,7 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { 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)', () => { + it('recognizes a non-Gemini ready screen', () => { const waitText = waitTextFor([ 'Antigravity CLI 1.0.3', 'user@example.com (Antigravity Business)', @@ -401,15 +405,24 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { '>' ]) + expect(isKnownReadyPromptPreview(waitText)).toBe(true) + }) + + it('rejects a visible unsent draft whose wrap continuation is a bare caret', () => { + const waitText = waitTextFor([ + 'Antigravity CLI 1.2.1', + 'Gemini 3.7 Flash (Low)', + '────────────────────────────────────────', + '> abc', + ' >', + '────────────────────────────────────────', + 'Gemini 3.7 Flash · low' + ]) + 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. + // Ratchet: these dialogs must remain unready because their selection row is not a bare caret. const SILENT_STARTUP_DIALOGS: { name: string; lines: string[] }[] = [ { name: 'an update banner', @@ -419,7 +432,7 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { 'A new version is available', '~/orca/workspaces/orca/agy-dispatch-issue', 'Press enter to continue', - '>' + '> Continue' ] }, { @@ -431,7 +444,7 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { '~/orca/workspaces/orca/agy-dispatch-issue', '1. Open browser', '2. Paste an API key', - '>' + '> Open browser' ] }, { @@ -443,7 +456,7 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { '~/orca/workspaces/orca/agy-dispatch-issue', '1. Claude Sonnet 4.5', '2. GPT-5.1', - '>' + '> Claude Sonnet 4.5' ] }, { @@ -455,7 +468,7 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { '~/orca/workspaces/orca/agy-dispatch-issue', '1. Accept', '2. Decline', - '>' + '> Accept' ] }, { @@ -467,7 +480,7 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { '~/orca/workspaces/orca/agy-dispatch-issue', '1. Dark', '2. Light', - '>' + '> Dark' ] } ] @@ -486,7 +499,7 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { const waitText = waitTextFor([ ...dialog.lines.slice(0, -1), 'contact support@antigravity.dev for help', - '>' + '> Selected option' ]) expect(isKnownReadyPromptPreview(waitText)).toBe(false) diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 08bd1d1d512..ebfcc5eaccf 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -50,6 +50,15 @@ export function isKnownReadyPromptPreview(preview: string): boolean { if (readyIndex === null) { return false } + const antigravityReadyIndex = findAntigravityReadyPromptIndex(normalized) + const modelPickerIndex = findActiveAntigravityModelPickerIndex(normalized) + if ( + antigravityReadyIndex !== null && + modelPickerIndex !== null && + modelPickerIndex > antigravityReadyIndex + ) { + return false + } const blockedSignal = findTerminalWaitBlockedSignal(normalized) if (blockedSignal !== null && blockedSignal.index > readyIndex) { return false @@ -133,10 +142,10 @@ function findAntigravityReadyPromptIndex(normalized: string): number | null { return null } let lineStart = headerIndex - let modelIndex: number | null = null let promptIndex: number | null = null + let previousNonEmpty: { start: number; end: number } | null = null - // Why: ready previews can include echoed paste after the header; scan line bounds directly instead of splitting the whole tail. + // Why: a column-0 caret is ready; an indented `>` under `> draft` is a wrap, not an empty box. for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { continue @@ -150,21 +159,34 @@ function findAntigravityReadyPromptIndex(normalized: string): number | null { trimmedEnd -= 1 } if (lineStart > headerIndex && trimmedStart < trimmedEnd) { - if (modelIndex === null && normalized.startsWith('gemini', trimmedStart)) { - modelIndex = trimmedStart - } if ( - promptIndex === null && trimmedEnd - trimmedStart === 1 && - normalized.charCodeAt(trimmedStart) === 62 + normalized.charCodeAt(trimmedStart) === 62 && + trimmedStart === lineStart && + !( + previousNonEmpty !== null && + normalized.charCodeAt(previousNonEmpty.start) === 62 && + previousNonEmpty.end - previousNonEmpty.start > 1 + ) ) { promptIndex = trimmedStart } + previousNonEmpty = { start: trimmedStart, end: trimmedEnd } } lineStart = cursor + 1 } - return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null + return promptIndex +} + +// Why: the model picker keeps the ready composer's bare caret in scrollback while its selected row +// is labeled, so that stale caret must not satisfy tui-idle until the picker emits its exit marker. +function findActiveAntigravityModelPickerIndex(normalized: string): number | null { + const pickerIndex = normalized.lastIndexOf('switch model') + if (pickerIndex === -1 || normalized.lastIndexOf('antigravity cli') > pickerIndex) { + return null + } + return normalized.lastIndexOf('exited /model command') > pickerIndex ? null : pickerIndex } export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE =