mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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 <neil@stably.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user