test(runtime): capture what a turn ending, a residual window and a two-row composer really do

Attempt six shipped with three rules, two open captures and one mutation-dead
clause. All three are addressed against a live `agy` — which self-updated to
banner 1.2.1 mid-investigation, so the whole rule set is now confirmed across a
version bump it did not know about.

**The rule clause decided nothing.** Deleting "the line above the caret is a run
of dashes" failed no test: every capture ending on a bare `>` also had the
composer rule above it. `antigravity-composer-multiline-unsent.txt` is the state
that separates them — `abc` typed, then shift+enter, leaving an empty second
composer row, so the tail ends on a bare `>` under `> abc`. Without the clause
that reads ready, and a delivered prompt is appended to the operator's own text:
a mis-send, not a wedge. The `>= 8` floor is still mutation-dead and is now
documented as unpinned rather than defended.

**The residual window is a capture, not an argument.** Chunk-level timing puts it
at ~116 ms between a frame park and the first spinner tick.
`antigravity-busy-residual-window.txt` lands in it. Its sibling,
`...-no-spinner.txt`, kills the proposed braille clause outright rather than
merely subsuming it: a turn's first frame draws the spinner row with no glyph, so
that capture carries zero braille codepoints while the turn is live, and even an
unbounded braille scan would call it ready. The gap stays pinned and open.

**A finished turn was mischaracterised, and the capture wins.** The refusal has
nothing to do with the turn ending in an error. `agy` ends a turn on one of two
frames, and the spinner-erase touch-up walks back down over the composer box with
bare newlines without redrawing it, so the retained tail loses rows the real
terminal still holds. Twelve real turns ended that way 3 times and on a full
repaint 9 times; neither state is transient.
`antigravity-turn-ended-composer-repainted.txt` is committed as the pair, because
either capture alone is misleading — the same trap §8 already records, hit again.

Also adds the adversarial matrix run against the live binary: seven dialogs, four
grid widths, type-ahead, and pane content carrying box-drawing glyphs or the
banner string. Line-wrapping does not split the rule, because `agy` draws it at
the grid width; below ~17 columns the banner gate fails first, and a scrollback
long enough to evict the banner fails the same safe way.
This commit is contained in:
Neil
2026-09-11 01:26:13 -07:00
parent 77c60cc2ce
commit ec2bcb2d37
16 changed files with 645 additions and 96 deletions
+253 -61
View File
@@ -20,14 +20,18 @@ asks only where the caret row sits relative to the composer's rule and to the en
## Versions
| Thing | Value |
| ------------------------- | ----------------------------- |
| `agy --version` | `1.1.25` |
| Banner printed by the TUI | `Antigravity CLI 1.2.0` |
| Captured | 2026-09-10, macOS, 120x40 PTY |
| Round | `agy --version` | Banner printed by the TUI | Captured |
| ------ | --------------- | ------------------------- | -------------------------------- |
| first | `1.1.25` | `Antigravity CLI 1.2.0` | 2026-09-10, macOS, 120x40 PTY |
| re-run | `1.2.0` | `Antigravity CLI 1.2.1` | 2026-09-11, macOS, varying grids |
The binary and its own banner disagree. Any rule keyed to a version string must read the banner,
not `--version`, and must tolerate the two disagreeing.
The binary and its own banner disagree, and they disagree **by a different amount in each round**:
`agy` self-updated between the two capture sessions, and `--version` still trails the banner by a
patch. Any rule keyed to a version string must read the banner, not `--version`, must tolerate the
two disagreeing, and must expect the pair to move under it without warning.
That the whole rule set below reproduces unchanged across a version bump it did not know about is
the only durability evidence this surface has.
## What the captures are
@@ -40,7 +44,19 @@ not `--version`, and must tolerate the two disagreeing.
| `antigravity-dialog-command-palette.txt` | Slash-command palette, live and unanswered |
| `antigravity-dialog-dismissed.txt` | `/model` picker dismissed with esc, then settled |
| `antigravity-busy-mid-turn.txt` | A real turn, recording stopped while the spinner was live |
| `antigravity-busy-turn-ended.txt` | The same turn after it ended and the composer returned |
| `antigravity-busy-turn-ended.txt` | The same turn, ended on `agy`'s spinner-erase touch-up |
Six more were recorded on 1.2.1 to answer the two open questions and the adversarial cases:
| Fixture | What it is |
| ------------------------------------------------- | ----------------------------------------------------------------- |
| `antigravity-turn-ended-composer-repainted.txt` | The same turn, ended on a **full composer repaint** instead |
| `antigravity-busy-residual-window.txt` | Mid-turn, stopped inside the residual window, spinner glyph live |
| `antigravity-busy-residual-window-no-spinner.txt` | Mid-turn, stopped on a turn's first frame — **no braille at all** |
| `antigravity-ready-narrow-20-columns.txt` | Idle on a 20-column PTY |
| `antigravity-ready-narrow-10-columns.txt` | Idle on a 10-column PTY, where the banner row truncates |
| `antigravity-composer-typed-caret.txt` | A bare `>` typed into the composer and not submitted |
| `antigravity-composer-multiline-unsent.txt` | A multi-line composer holding unsent text — see §10 |
## What could not be captured, and why
@@ -58,15 +74,38 @@ operator's account state or configuration, which is out of bounds.
Each remains as a named, skipping case in the suite so it is visible rather than forgotten.
### Still needed — the two open questions on this surface
### The two open questions, and what the re-capture round answered
Both are about the busy lane, and both are the reason a rule was deliberately **not** written. Each
is a capture the recorder could take, not a case it is barred from reaching.
Both were about the busy lane, and both were the reason a rule was deliberately **not** written.
One is now answered outright; the other is answered far enough to show it was the wrong question.
| Still needed | What it would settle |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A successful turn completion** | This account's key cannot produce one — every turn ends `Agent execution terminated due to error`, which is what `antigravity-busy-turn-ended.txt` records. So it is unknown whether a finished, successful turn returns the composer to the bottom of the tail (→ reads ready) or leaves result text below it the way the error block does (→ wedges until the next repaint). **Both are consistent with every capture we have.** |
| **A capture ending _inside_ the residual window** | Recording stopped between a frame park and the next spinner tick, so the transcript's own last row is the bare caret with the spinner still live above it. That would fix the line bound for a busy rule **empirically**, which is the only thing missing (§9). Without it, any "read the last N lines" rule is a guess about N, and guessing is what produced attempts one through five. |
| Question | Answer |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A capture ending _inside_ the residual window** | **Obtained**, twice — `antigravity-busy-residual-window.txt` and `antigravity-busy-residual-window-no-spinner.txt`. The window is ~116 ms wide and lands between a frame park and the first spinner tick; §9 now cites the captures instead of arguing. It did **not** close the gap: it refuted the only clause proposed for it. |
| **A successful turn completion** | **Not obtained** — see below. But the question turned out not to be the one that mattered: whether a finished turn leaves the composer at the bottom of the tail is decided by **which frame `agy` happens to end on**, not by whether the turn succeeded. §9a. |
#### Why a successful turn is still not capturable here
`agy models` fetches the model list over the network fine, so the credential is live — but the
generation endpoint rejects it:
```
Error 401 … Expected OAuth 2 access token, login cookie or other valid authentication
credential … reason:ACCESS_TOKEN_TYPE_UNSUPPORTED
service:generativelanguage.googleapis.com
```
The `GEMINI_API_KEY` on this machine is not a key type `generativelanguage` accepts for
`StreamGenerateContent`. Three routes were tried and all are barred or out of bounds:
| Route | Result |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Another model from `agy models` | All eleven are Gemini and all route to the same endpoint; same 401 |
| `AGY_ADC_AUTH=1` against existing ADC | Same 401 — the endpoint rejects that token type too |
| Signing in, or editing `settings.json` | Out of bounds. `modelProvider` is pinned there, and reaching any other provider means mutating the operator's config |
**Nothing was faked to stand in for it.** What replaced it is §9a: a turn that ends is
characterised by its final frame, and both final frames were captured from real turns.
## What the transcripts show
@@ -243,53 +282,164 @@ because there is no bare caret in the tail to match. A constructed input that ke
and only edits the status text is not faithful to a live turn — a live turn has a spinner row
repainting _below_ the composer.
**The residual window, and the clause it implies.** Between a frame park and the next spinner tick
the tail does end on the bare caret and is indistinguishable from idle. The gap is one tick
interval. Any readiness path gated on sustained quiescence is safe, because ticks keep arriving and
the pane is never quiet; a path that only inspects retained text is not. For those paths the
evidence supports one clause, and only one:
**The residual window, and the clause it looked like it implied.** Between a frame park and the next
spinner tick the tail does end on the bare caret and is indistinguishable from idle. Any readiness
path gated on sustained quiescence is safe, because ticks keep arriving and the pane is never quiet;
a path that only inspects retained text is not. The clause that reading suggested was:
> **A braille glyph (U+2800–U+28FF) on the last visible line of the retained tail means working.**
That predicate already exists in this file for cursor-agent (`CURSOR_BUSY_SPINNER_RE`) and should be
reused rather than reinvented. It must be scoped to the **last visible line**, not the whole tail:
The predicate already exists in this file for cursor-agent (`CURSOR_BUSY_SPINNER_RE`), and scoping
it to the **last visible line** rather than the whole tail looked mandatory:
`antigravity-busy-mid-turn.txt` prints `⣾ Signing in...` during its failed first launch, which a
whole-tail scan would read as working forever.
whole-tail scan would read as working forever. The next subsection is why neither scoping works.
Nothing else in the capture distinguishes the two states. The hint row (`esc to cancel` versus
`? for shortcuts`) is erased by the park in both cases, the park offsets are identical, and
`ESC[?25l`/`ESC[?25h` fencing appears around every repaint, idle or busy.
#### …but that clause, implemented, is dead code — and does not close the residual
#### The residual window, captured — and the braille clause refuted twice over
Implemented as written and mutation-checked, the clause changes no verdict, and **reverting it
fails to make any test fail**. It is subsumed by the caret rule:
The window is real, it is ~116 ms wide, and it is now a fixture rather than an argument. Driving a
turn through the recorder and logging every chunk's arrival gives the frame timeline:
- The caret rule requires the last visible line to **trim to exactly `>`**. Trimming strips only
codes 32 and 9–13, and no braille codepoint is in that set, so a braille glyph on that line
always survives to make the trimmed span longer than one character. Braille on the last visible
line therefore implies the caret rule has already returned `null`.
- And in the residual window it is meant to close, the last visible line **is** the bare caret and
carries no braille, so the clause never fires there. A tail of
`banner / rule / "> …prompt" / "⣟ Generating..." / rule / ">"` still reads ready with the
clause present.
| t (ms after launch) | What arrived |
| ------------------- | ------------------------------------------------------------------------------------- |
| 5251 | Full frame repaint: prompt echo, spinner row, rule, caret, rule, hint — then the park |
| 5367 | First spinner tick (`CR ESC[2A ⣯ …`), which splices the composer away |
So the residual is real but this clause is not its fix. Closing it by text alone requires reading
**more than the last line**, and the bound for that is unpinned — the `Signing in` row above is
exactly why an unbounded scan is unsafe, and no capture ends in the residual window to calibrate
against. Attempt six therefore pins the gap as a failing-when-fixed test
(`KNOWN GAP: the window between a frame park and the next tick still reads ready`) rather than
shipping a clause that cannot reach it.
Stopping the recorder at 5290 ms lands between them. `antigravity-busy-residual-window.txt` is that
capture, and its derived tail is:
**Decision: the gap stays pinned and open.** Not because it does not matter, but because closing it
by text alone means picking a line bound that no capture calibrates, and a rule without a fixture
behind it is the thing this whole document exists to stop. The exposure is one inter-tick interval,
visible only to text-only callers; and the `⣾ Signing in...` row proves a whole-tail rule would be
actively wrong rather than merely unproven. The capture that would close it is listed under
[Still needed](#still-needed--the-two-open-questions-on-this-surface).
```
────────────────────────── (120 cols)
> In about 80 words, explain what a pseudoterminal is.
⣷ Generating...
────────────────────────── (120 cols)
>
```
Callers gated on sustained quiescence are unaffected in any case: spinner ticks keep arriving, so
the pane is never quiet.
The detector answers **ready**. Ground truth is a live turn. That is the gap, reproduced.
**The braille clause is refuted, and in two independent ways.** Scoped to the last visible line it
is dead code, for the reason the previous draft gave: the caret rule already requires that line to
trim to exactly `>`, trimming strips only codes 32 and 9–13, so a braille glyph on it always makes
the trimmed span longer than one character. Implemented and mutation-checked, reverting it fails no
test.
Widening it to the whole tail does not save it either, and this is the part only a capture could
show. **A turn's first frame draws the spinner row with no glyph in it.**
`antigravity-busy-residual-window-no-spinner.txt` is a capture stopped in that window, and it
contains **zero braille codepoints in the entire transcript** while the turn is live:
```
────────────────────────── (120 cols)
> In about 80 words, explain what a pseudoterminal is.
Generating...
────────────────────────── (120 cols)
>
```
So the clause that was previously called "subsumed" is in fact also **insufficient**: even an
unbounded braille scan — the unsafe version, the one the `⣾ Signing in...` row already rules out —
would call this capture ready. Both captures are asserted in
`KNOWN GAP: no braille rule reaches the residual window, at any line bound`, which fails the moment
a re-capture quietly acquires a spinner glyph.
**What the captures did calibrate, and why it is still not enough.** In both, the spinner row is the
third non-blank line from the end: caret, rule, spinner. So the line bound a busy rule would need is
now measured rather than guessed. But a bound is not a rule. Every capture here is of a turn that
produced **no output before dying**; a streaming turn grows the region between the two rules, and
nothing in this fixture set says by how much or in what shape. Picking a predicate that fits six
captures of an empty turn is how attempts one through five were built.
**Decision: the gap stays pinned and open** — now with the capture that was said to be missing, and
with the proposed fix empirically dead rather than merely subsumed. The exposure is one inter-tick
interval, visible only to text-only callers. Callers gated on sustained quiescence are unaffected:
ticks keep arriving, so the pane is never quiet.
### 9a. A turn that has ended reads ready or not by which frame `agy` stopped on
This replaces what the previous draft recorded as a single pinned defect, and it is the one place a
capture contradicted the document's own reading. `antigravity-busy-turn-ended.txt` was described as
refused _because the turn ended in an error_ and the error block sits below the composer. **That is
wrong on both halves.** On the screen the operator sees, the error block is in the transcript
region and the composer box is drawn beneath it, exactly as any result would be. And the refusal has
nothing to do with the error.
`agy` ends a turn with one of two final frames:
| Final frame | Bytes | Derived tail ends on | Verdict |
| ---------------------- | ------------------------------------------------------------------ | -------------------- | ------------- |
| Spinner-erase touch-up | `ESC[?25l \n ESC[2A ESC[K \n\n\n\n ? for shortcuts ESC[2A ESC[13D` | the result text | **not ready** |
| Full composer repaint | `… rule / ? for shortcuts … CR ESC[2A ESC[2C ESC[?25h` | the bare caret | ready |
The touch-up moves the cursor up past the composer box, erases the spinner row, and then **walks
back down over the box with bare newlines without redrawing it** — it relies on the real terminal
still holding those rows. Orca's retained tail does not: a newline from a mid-buffer cursor drops
everything below it, so the composer rows `agy` never re-emitted are lost, and the final `ESC[2A`
truncates back above the hint row too. The full repaint re-emits the box, so nothing is lost.
**Which frame ends a turn is not deterministic.** Twelve real turns were captured — six with varying
prompt lengths and six repetitions of one identical prompt:
| Batch | ended on full repaint (ready) | ended on touch-up (not ready) |
| -------------------------------- | ----------------------------- | ----------------------------- |
| Six different prompts | 5 | 1 |
| Six runs of one identical prompt | 4 | 2 |
Neither state is transient: a capture left running for ~5 s after the touch-up receives nothing
further, so the pane stays refused until something else repaints it.
`antigravity-turn-ended-composer-repainted.txt` is committed alongside
`antigravity-busy-turn-ended.txt` for exactly this reason. **One of them alone is misleading**, and
the first round happened to capture only the rarer one — the same six-file coincidence trap §8
records, hit a second time on a different fact.
So the answer to "does a finished turn return the composer to the bottom?" is: **usually, and not
reliably**, and the failure is a stalled `tui-idle` wait rather than a prompt typed into a dialog.
Whether a _successful_ turn changes those odds is unknown and uncapturable here — but it cannot
change the mechanism, because the touch-up frame is what the TUI emits when a turn stops, not what
it emits when a turn fails.
### 10. Two of the three rules were mutation-dead, and one still is
The three clauses were mutation-checked against the whole corpus by deleting each and re-running
the suite. Deleting a clause that no capture decides is silent, and silence is the failure mode this
document exists to catch.
| Mutation | Tests it kills |
| ------------------------------------------------ | ----------------------------------------------------------------- |
| Drop the `antigravity cli` gate | 2 |
| Drop the "last line trims to exactly `>`" clause | 2 |
| Drop the "line above is a rule" clause | **0**, until `antigravity-composer-multiline-unsent.txt` — then 1 |
| Weaken the rule floor from ≥ 8 glyphs to ≥ 1 | **0**, and still 0 |
**The rule clause was inert.** Every capture that ended on a bare `>` also had the composer rule
directly above it, and no capture ended on a bare `>` without one — so the clause the last section
presents as load-bearing decided nothing, and deleting it broke no test. By this document's own
standard that is a rule with no fixture behind it.
The capture that fixes it is a composer with **unsent text on more than one row**. Typing `abc`,
then shift+enter (`ESC[13;2u`, which `agy` negotiates at startup), leaves an empty second composer
row, and the derived tail is:
```
────────────────────────── (120 cols)
> abc
>
```
The last content line is a bare `>`; the line above it is `> abc`, not a rule. Ground truth is
**not ready** — the operator has text in the composer, and a prompt delivered here is appended to
it. Without the rule clause this reads ready, which is a **mis-send, not a wedge**: the one failure
direction the asymmetry exists to prevent. With it, the pane is correctly refused.
**The ≥ 8 floor is still mutation-dead, and is documented as such rather than defended.** No capture
distinguishes it from ≥ 1, because `agy` draws the rule at the grid width and the banner gate fails
before the grid gets narrow enough to matter (see the adversarial section). It is kept as a cheap
guard against a stray box-drawing glyph in prose, and it is the one number in the detector that no
transcript pins. If it is ever in the way, delete it — nothing here is evidence for it.
## Confirmed / refuted, by attempt
@@ -375,20 +525,62 @@ on wording, and it is the only thing that names a reason for the refusal.
Four screens could not be captured, plus two composer modes. In every one of them the rule
reports **not ready**, which stalls a `tui-idle` wait rather than typing into a live dialog:
| Unknown | Behaviour |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Business/OAuth ready screen | Ready, and correctly so — the rule reads no identity at all, so the account type cannot change the verdict |
| Non-Gemini model row | Ready, same reason: no model text is read |
| Sign-in dialog | Not ready, _unless_ it happens to end on a rule + bare `>`. Drawn on the alternate screen (§5), which has no composer |
| Theme / privacy / update banner | Not ready: a banner owning the screen puts rows under the composer |
| Accept-edits and plan mode | Not ready: PRs #15840/#15852 describe a mode banner on the composer row, which is not a bare `>`. A wedge, not a mis-send |
| A live turn (spinner running) | **Not ready**, and now capture-backed: `antigravity-busy-mid-turn.txt`'s tail ends on `⣟ Generating...`, with no bare caret anywhere in it (§9) |
| The gap between a frame park and the next spinner tick | **Ready — a real open gap.** One tick wide, unreachable for quiescence-gated callers, pinned as a KNOWN GAP test (§9) |
| A turn that has ended | **Not ready** — pinned KNOWN DEFECT. `antigravity-busy-turn-ended.txt`'s tail ends on the error block, not the composer. See below |
| Unknown | Behaviour |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Business/OAuth ready screen | Ready, and correctly so — the rule reads no identity at all, so the account type cannot change the verdict |
| Non-Gemini model row | Ready, same reason: no model text is read |
| Sign-in dialog | Not ready, _unless_ it happens to end on a rule + bare `>`. Drawn on the alternate screen (§5), which has no composer |
| Theme / privacy / update banner | Not ready: a banner owning the screen puts rows under the composer |
| Accept-edits and plan mode | Not ready: PRs #15840/#15852 describe a mode banner on the composer row, which is not a bare `>`. A wedge, not a mis-send |
| A live turn (spinner running) | **Not ready**, and now capture-backed: `antigravity-busy-mid-turn.txt`'s tail ends on `⣟ Generating...`, with no bare caret anywhere in it (§9) |
| The gap between a frame park and the next spinner tick | **Ready — a real open gap.** ~116 ms wide, unreachable for quiescence-gated callers, now pinned by two captures (§9) |
| A turn that has ended | **Ready or not ready, by which frame `agy` stopped on.** ~3 of 12 real turns ended refused, and the state persists. A wedge, not a mis-send (§9a) |
The two that cost a wedge — the mode banners, and any 1.1.x that does not draw the composer box —
are the price of the asymmetry: a false negative stalls one wait, a false positive types a user's
prompt into a live dialog.
The ones that cost a wedge — the mode banners, the touch-up turn ending, and any 1.1.x that does not
draw the composer box — are the price of the asymmetry: a false negative stalls one wait, a false
positive types a user's prompt into a live dialog.
### Adversarial validation against the live binary
Every row below was run against `agy` 1.2.1 through the recorder, not reasoned about. Six became
committed fixtures; the rest are reproducible from `config/scripts/capture-agent-pty-transcript.mjs`
with the `--send`/`--duration`/`--cols` arguments named.
| Situation | Ground truth | Detector | Evidence |
| --------------------------------------------------- | ------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Mid-turn, spinner ticking | working | not ready | `antigravity-busy-mid-turn.txt` |
| Mid-turn, inside the residual window | working | **ready** | `antigravity-busy-residual-window.txt` — the KNOWN GAP (§9) |
| Mid-turn, residual window on a turn's first frame | working | **ready** | `antigravity-busy-residual-window-no-spinner.txt` — and no braille to see (§9) |
| Turn ended on a full composer repaint | ready | ready | `antigravity-turn-ended-composer-repainted.txt` |
| Turn ended on the spinner-erase touch-up | ready | **not ready** | `antigravity-busy-turn-ended.txt` — the KNOWN DEFECT (§9a) |
| `/model` picker open | dialog | not ready | `antigravity-dialog-model-picker.txt` |
| `/help`, `/agents`, `/mcp`, `/` palette open | dialog | not ready | re-run on 1.2.1; all four refused |
| `/model` dismissed with esc | ready | ready | `antigravity-dialog-dismissed.txt`, re-confirmed on 1.2.1 |
| `/add-dir` with no argument (local command, no LLM) | ready | ready | result row then rule + caret — the shape a completed turn takes |
| Idle on an 80 / 40 / 20-column PTY | ready | ready | `antigravity-ready-narrow-20-columns.txt`; see below on wrapping |
| Idle on a 10-column PTY | ready | **not ready** | `antigravity-ready-narrow-10-columns.txt` — banner truncates to `Antigrav` |
| A submitted prompt whose text is `antigravity cli` | ready | not ready | refused for the §9a reason, not the text — the gate is already satisfied by the banner, so echoing the string changes nothing |
| A submitted prompt of box-drawing glyphs | ready | ready | the glyphs land on the echoed `> …` row; a user cannot put them on a _bare_ caret row, because the row they type on starts `> ` |
| Scrollback long enough to evict the banner | ready | **not ready** | constructed over the real ready capture; pinned by `refuses a ready pane once scrollback has evicted the banner` |
| Type-ahead in the composer (`hello there`) | not ready | not ready | the caret row is not a bare `>` |
| A single `>` typed into the composer | not ready | not ready | `antigravity-composer-typed-caret.txt` — the row reads `> >` |
| A multi-line composer holding unsent text | not ready | not ready | `antigravity-composer-multiline-unsent.txt` — **the only capture rule 3 decides** (§10) |
Two of these answer worries directly:
**Line-wrapping does not split the rule.** `agy` redraws the composer rule at the PTY's own width,
so it is 80 glyphs on an 80-column grid and 20 on a 20-column one — it never emits a rule wider than
the grid for the terminal to wrap. The `≥ 8` floor is therefore never the binding clause: below
about 17 columns the **banner gate** fails first, because `Antigravity CLI` truncates before the
rule loses its eighth glyph. Both failures point the safe way — a wedge, not a mis-send.
**A long scrollback that evicts the banner also fails safe.** The `antigravity cli` gate reads the
whole retained tail, which is bounded by `MAX_TAIL_LINES` / `MAX_TAIL_CHARS`. Inserting 2 200 filler
lines between the banner and the composer of the real ready capture — which is where a session's
output actually accumulates — drops the banner out of the window while leaving the composer rows
intact, and the verdict flips to **not ready**: a wedge. `agy` never reprints the banner (§5, §3.2), so this is permanent for that
pane rather than self-healing, and it is the strongest argument for moving readiness onto the
emulator (below).
### Why this is still not the emulator, and what it would take
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T07:48:45.591Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "agy TUI 1.2.1; the residual window on a turn's FIRST frame, which draws the spinner row with no glyph yet. The whole transcript contains zero braille codepoints. Ground truth: working.",
"exitCode": 0
}
@@ -0,0 +1,33 @@
[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?uAccessing workspace:
/private/tmp/agycap/w1
Do you trust the contents of this project?
Antigravity CLI requires permission to read, edit, and execute files here.
> Yes, I trust this folder
No, exit
↑/↓ Navigate · enter ConfirmGemini 3.7 Flash · low[>4m[=0;1u[?1049l[>4;2m[=1;1u[?u[0 q

▄▀▀▄ Antigravity CLI 1.2.1
▀▀▀▀▀▀ Gemini API key
▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▄▀▀ ▀▀▄ /private/tmp/agycap/w1
▄▀▀ ▀▀▄
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h[?25lIn a
 G[?25h[?25lbout[?25h[?25l80 wor[?25h[?25lds, e[?25h[?25lxplai[?25h[?25ln wh[?25h[?25lat a[?25h[?25l pse[?25h[?25ludo[?25h[?25lterm[?25h[?25linal[?25h[?25l[?25h[?25l
? for shortcuts[?25h[?25lM
> In about 80 words, explain what a pseudoterminal is.
Generating...
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
esc to cancelGemini 3.7 Flash · low␍[?25h
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T07:48:51.141Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "agy TUI 1.2.1; the residual window. Recording stopped 40 ms after a frame park and before the next spinner tick, so the tail ends on the bare caret while the turn is still running. Ground truth: working.",
"exitCode": 0
}
@@ -0,0 +1,32 @@
[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?u
▄▀▀▄
▀▀▀▀▀▀
▀▀▀▀▀▀▀▀
▄▀▀ ▀▀▄
▄▀▀ ▀▀▄
Welcome to the Antigravity CLI. You are currently not signed in.
No authentication methods available.
Press ctrl+c or ctrl+d twice to exit.[>4m[=0;1u[?1049l[>4;2m[=1;1u[?u[0 q␍
▄▀▀▄ Antigravity CLI 1.2.1
▀▀▀▀▀▀ Gemini API key
▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▄▀▀ ▀▀▄ /private/tmp/agycap/w1
▄▀▀ ▀▀▄
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h[?25lIn
 G[?25h[?25l about[?25h[?25l 80 w[?25h[?25lords,[?25h[?25l expl[?25h[?25lain w[?25h[?25lhat a[?25h[?25lpse[?25h[?25ludote[?25h[?25lrmina[?25h[?25l[?25h[?25l
? for shortcuts[?25h[?25lM
> In about 80 words, explain what a pseudoterminal is.
⣷ Generating...
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
esc to cancelGemini 3.7 Flash · low␍[?25h
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T08:13:59.393Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "agy TUI 1.2.1 with 'abc' typed into the composer, then shift+enter (ESC[13;2u), leaving an empty second composer row. The tail's last content line is a bare '>' whose line above is '> abc', not the composer rule. Ground truth: not ready \u2014 a prompt sent here is appended to the operator's unsubmitted text. This is the only capture that separates the rule clause from the caret clause.",
"exitCode": 0
}
@@ -0,0 +1,25 @@
[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?u
▄▀▀▄
▀▀▀▀▀▀
▀▀▀▀▀▀▀▀
▄▀▀ ▀▀▄
▄▀▀ ▀▀▄
Welcome to the Antigravity CLI. You are currently not signed in.
⣾ Signing in...␍ ⣷ [>4m[=0;1u[?1049l[>4;2m[=1;1u[?u[0 q␍
▄▀▀▄ Antigravity CLI 1.2.1
▀▀▀▀▀▀ Gemini API key
▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▄▀▀ ▀▀▄ /tmp/agycap/w1
▄▀▀ ▀▀▄
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h[?25lab[?25h[?25lc[?25h[?25l
 G[?25h[?25l
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 Gemini 3.7 Flash · low␍[?25h
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T07:52:46.719Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "agy TUI 1.2.1 with a single '>' typed into the composer and not submitted, so the caret row reads '> >'. Ground truth: not ready \u2014 a prompt would be appended to the user's text.",
"exitCode": 0
}
@@ -0,0 +1,24 @@
[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?u
▄▀▀▄
▀▀▀▀▀▀
▀▀▀▀▀▀▀▀
▄▀▀ ▀▀▄
▄▀▀ ▀▀▄
Welcome to the Antigravity CLI. You are currently not signed in.
No authentication methods available.
Press ctrl+c or ctrl+d twice to exit.[>4m[=0;1u[?1049l[>4;2m[=1;1u[?u[0 q␍
▄▀▀▄ Antigravity CLI 1.2.1
▀▀▀▀▀▀ Gemini API key
▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▄▀▀ ▀▀▄ /private/tmp/agycap/w1
▄▀▀ ▀▀▄
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h[?25l>
 G[?25h
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T07:52:09.963Z",
"platform": "darwin",
"command": ["agy"],
"cols": 10,
"rows": 10,
"note": "agy TUI 1.2.1 idle on a 10-column PTY, where the banner row truncates to 'Antigrav'. Ground truth: ready.",
"exitCode": 0
}
@@ -0,0 +1,10 @@
[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q␍
Antigrav
Gemini A
Gemini 3
/private
──────────
>
──────────
? for shor␍[?25h
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T07:52:03.825Z",
"platform": "darwin",
"command": ["agy"],
"cols": 20,
"rows": 12,
"note": "agy TUI 1.2.1 idle on a 20-column PTY. agy redraws the composer rule at the grid width, so it is 20 glyphs and never wraps. Ground truth: ready.",
"exitCode": 0
}
@@ -0,0 +1,23 @@
[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?u
▄▀▀▄
▀▀▀▀▀▀
▀▀▀▀▀▀▀▀
▄▀▀ ▀▀▄
▄▀▀ ▀▀▄
Welcome to the Anti
No authentication m
Press ctrl+c or ct[?7lr[?7h[>4m[=0;1u[?1049l[>4;2m[=1;1u[?u[0 q␍ ▄▀▀ ▀▀▄
▄▀▀ ▀▀▄
Antigravity CLI 1.
Gemini API key
Gemini 3.7 Flash (
/private/tmp/agyca
────────────────────
>
────────────────────
? for shortcuts[?25h
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T07:56:09.513Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "agy TUI 1.2.1; the same failed turn as antigravity-busy-turn-ended, but the capture ends after agy's full composer repaint instead of after the spinner-erase touch-up. Ground truth: ready. Which of the two frames ends a turn is not deterministic.",
"exitCode": 0
}
@@ -0,0 +1,54 @@
[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?u
▄▀▀▄
▀▀▀▀▀▀
▀▀▀▀▀▀▀▀
▄▀▀ ▀▀▄
▄▀▀ ▀▀▄
Welcome to the Antigravity CLI. You are currently not signed in.
No authentication methods available.
Press ctrl+c or ctrl+d twice to exit.[>4m[=0;1u[?1049l[>4;2m[=1;1u[?u[0 q␍
▄▀▀▄ Antigravity CLI 1.2.1
▀▀▀▀▀▀ Gemini API key
▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▄▀▀ ▀▀▄ /private/tmp/agycap/w1
▄▀▀ ▀▀▄
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h[?25lwh
 G[?25h[?25lat is[?25h[?25l a pt[?25h[?25l[?25h[?25l
? for shortcuts[?25h[?25lM
> what is a pty
⣾ Generating...
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
esc to cancelGemini 3.7 Flash · low␍[?25h[?25l␍⣷
[?25h[?25l␍⣯ Generating...
[?25h[?25l␍⣟
[?25h[?25l␍⡿
[?25h[?25l␍⢿
[?25h[?25l␍
⚠ agent executor error: generating and executing: Error 401, Message: Request had invalid authentication credentials.
Expected OAuth 2 access token, login cookie or other valid authentication credential. See
https://developers.google.com/identity/sign-in/web/devconsole-project., Status: UNAUTHENTICATED, Details:
[map[@type:type.googleapis.com/google.rpc.ErrorInfo
metadata:map[method:google.ai.generativelanguage.v1beta.GenerativeService.StreamGenerateContent
service:generativelanguage.googleapis.com] reason:ACCESS_TOKEN_TYPE_UNSUPPORTED]]
Error ID: 00000000-0000-4000-8000-000000000000-2

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h
@@ -3,8 +3,8 @@
*
* Five detector attempts were tuned against a five-line screen someone typed from memory, and
* three of them shipped worse behaviour than the bug they replaced. Nothing here asserts what
* Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name
* themselves as skipped until someone can reach them.
* Antigravity prints: the transcripts do. Fourteen are recorded from a live `agy` across two
* versions; the rest name themselves as skipped until someone can reach them.
*
* Two things have to be right for a replay to mean anything, and both were wrong before:
* the transcript must be fed to an emulator of the grid it was recorded on, and it must stop
@@ -18,7 +18,7 @@ import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { createTranscriptPane } from './agent-transcript-pane-test-harness'
import { extractLastOscTitle } from '../../shared/osc-title-extraction'
import { isKnownReadyPromptPreview } from './terminal-wait-detection'
import { MAX_TAIL_LINES } from './terminal-tail-limits'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
@@ -46,6 +46,8 @@ const ANTIGRAVITY_COMMAND = 'agy'
// String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw
// control byte in source, which is unreadable and survives badly in diffs.
const ESC = String.fromCharCode(27)
/** The braille spinner range, as `CURSOR_BUSY_SPINNER_RE` in terminal-wait-detection scans it. */
const BUSY_SPINNER_GLYPH_RE = /[\u2800-\u28ff]/
type TranscriptCase = {
/** Fixture basename; `<name>.txt` under `__fixtures__/`. */
@@ -102,12 +104,73 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [
},
{
// Expected ready because the turn is over and the composer is back on screen. The captured
// turn ends in a backend error, which is the only ending this account's key can produce.
// turn ends in a backend error, which is the only ending this account's key can produce —
// but the ending that matters is the FRAME, not the error. See the pair below.
name: 'antigravity-busy-turn-ended',
capture: 'E',
what: 'the turn has ended and the composer has returned, process still alive',
what: 'a turn ended on agy’s spinner-erase touch-up frame, process still alive',
expectReady: true,
knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it'
knownDefect:
'refused: the touch-up frame moves up past the composer and re-emits the region with bare newlines, so the retained tail loses the composer rows it never redrew'
},
{
// The same failed turn, captured after agy's full composer repaint instead. Which of the two
// frames a turn ends on is not deterministic: six identical runs ended four this way and two
// the other. Committing the pair is what stops the defect above reading as "errors only".
name: 'antigravity-turn-ended-composer-repainted',
capture: 'E',
what: 'the same turn, ended on a full composer repaint instead of the touch-up',
expectReady: true
},
{
// The residual window, captured rather than argued: the recorder stopped 40 ms after a frame
// park and before the first spinner tick.
name: 'antigravity-busy-residual-window',
capture: 'E',
what: 'mid-turn, stopped between a frame park and the next tick — the tail ends on the bare caret',
expectReady: false,
knownDefect:
'accepted: nothing below the last line distinguishes this from idle, and the spinner row is two lines up'
},
{
// Why the second residual capture: a turn's FIRST frame draws the spinner row with no glyph,
// so this transcript contains zero braille codepoints anywhere. It refutes the proposed
// "braille means working" clause by capture instead of by argument.
name: 'antigravity-busy-residual-window-no-spinner',
capture: 'E',
what: 'mid-turn on a turn’s first frame, with no braille codepoint anywhere in the capture',
expectReady: false,
knownDefect:
'accepted: there is no spinner glyph in this transcript for any braille rule to see'
},
{
name: 'antigravity-ready-narrow-20-columns',
capture: 'F',
what: 'idle on a 20-column PTY — agy redraws the rule at the grid width, so it never wraps',
expectReady: true
},
{
name: 'antigravity-ready-narrow-10-columns',
capture: 'F',
what: 'idle on a 10-column PTY, where the banner row truncates to "Antigrav"',
expectReady: true,
knownDefect:
'refused: the banner gate loses "antigravity cli" before the rule loses its eighth glyph, so the grid floor is never the binding clause'
},
{
name: 'antigravity-composer-typed-caret',
capture: 'F',
what: 'a single ">" typed into the composer and not submitted, so the caret row reads "> >"',
expectReady: false
},
{
// The only capture that separates the two caret clauses. Everything else that ends on a bare
// `>` also has the composer rule directly above it, so the rule clause was inert against the
// whole corpus — mutating it away failed no test until this landed.
name: 'antigravity-composer-multiline-unsent',
capture: 'F',
what: 'a multi-line composer holding unsent text — the tail ends on a bare ">" whose line above is "> abc"',
expectReady: false
},
{
name: 'antigravity-dialog-dismissed',
@@ -302,41 +365,71 @@ describe('Antigravity readiness, decided by captured transcripts', () => {
expect(verdict.ready).toBe(false)
}, 20_000)
// KNOWN GAP, pinned so it is visible rather than argued about. Between a frame park and the
// next spinner tick the retained tail really does end on the bare caret, and no committed
// capture ends there, so nothing distinguishes it from idle by text alone.
// KNOWN GAP, now pinned by two real captures rather than a constructed screen. Between a frame
// park and the next spinner tick the retained tail ends on the bare caret and nothing below the
// last line separates it from idle. `antigravity-busy-residual-window.txt` and
// `antigravity-busy-residual-window-no-spinner.txt` are recordings stopped inside that window.
//
// The clause proposed for this — "a braille glyph on the last visible line means working" — is
// asserted here to NOT close it, because it cannot: in this window the last visible line IS the
// bare caret and carries no braille. Braille on that line would make it not trim to `>`, which
// the caret rule already refuses, so the clause is subsumed and adding it would be dead code.
// Reverting it changes no test.
// The clause proposed for it — "a braille glyph means working" — is refuted here by capture,
// not by argument, and in two independent ways:
//
// WHAT WOULD CLOSE THIS: one capture that ends *inside* the residual window — recording stopped
// between a frame park and the next spinner tick, so the transcript's own last row is the bare
// caret with the spinner still live above it. That fixes the line bound empirically, which is
// the only thing missing. A fix has to read more than the last line, and picking "the last N
// lines" without a capture to fix N is how attempts one through five were built. The bound is
// not free to guess either: `antigravity-busy-mid-turn.txt` prints `⣾ Signing in...` during its
// failed first launch, so an unbounded scan would call a ready screen busy forever — the hazard
// is demonstrated, not hypothetical.
// 1. Scoped to the last visible line it is dead code. That line IS the bare caret, and a
// braille glyph on it would make the line not trim to `>`, which the caret rule already
// refuses. Reverting the clause fails no test.
// 2. Widened to the whole tail it still misses, because a turn's first frame draws the spinner
// row with no glyph at all: `antigravity-busy-residual-window-no-spinner.txt` contains zero
// braille codepoints while the turn is live.
//
// The line bound is now calibrated — in both captures the spinner row is the third non-blank
// line from the end (caret, rule, spinner) — but a bound is not a rule. Closing this needs a
// predicate that holds for a *streaming* turn too, and no capture of one exists: this account's
// key cannot complete a turn. The bound is not free to guess either: `antigravity-busy-mid-turn`
// prints `⣾ Signing in...` during its failed first launch, so an unbounded scan would call a
// ready screen busy forever.
//
// Paths gated on sustained quiescence are unaffected: ticks keep arriving, so the pane is never
// quiet. This is only reachable by a caller that inspects retained text alone, and it is one
// inter-tick interval wide.
it('KNOWN GAP: the window between a frame park and the next tick still reads ready', () => {
const rule = '─'.repeat(120)
const residual = [
'▄▟▟▄ Antigravity CLI 1.2.0',
rule,
'> In about 80 words, explain what a pseudoterminal is.',
'⣟ Generating...',
rule,
'>'
].join('\n')
expect(isKnownReadyPromptPreview(residual)).toBe(true)
// quiet. This is only reachable by a caller that inspects retained text alone.
it('KNOWN GAP: no braille rule reaches the residual window, at any line bound', () => {
const names = [
'antigravity-busy-residual-window',
'antigravity-busy-residual-window-no-spinner'
]
const captured = names.filter((name) => existsSync(fixturePath(name)))
if (captured.length === 0) {
return
}
for (const name of captured) {
const bytes = readFileSync(fixturePath(name), 'utf8')
// Why assert the glyph and not just the verdict: the "no spinner" capture is only evidence
// if it really carries no braille, and a re-capture could silently acquire one.
expect({ name, braille: BUSY_SPINNER_GLYPH_RE.test(bytes) }).toEqual({
name,
braille: !name.endsWith('no-spinner')
})
}
})
it('refuses a ready pane once scrollback has evicted the banner', async () => {
// The `antigravity cli` gate reads the whole retained tail, and the tail is bounded
// (MAX_TAIL_LINES / MAX_TAIL_CHARS). agy prints the banner once and never reprints it (§5),
// so a pane that outruns the window loses the gate permanently. Asserted because the
// direction matters: this has to be a wedge, never a mis-send.
const name = 'antigravity-ready-api-key-gemini-model'
if (!existsSync(fixturePath(name))) {
return
}
const ready = replayableLiveTranscript(name)
// Constructed scrollback over real capture bytes: the filler goes *between* the banner and
// the composer frame, which is where a session's output actually accumulates. Prepending it
// instead proves nothing — the window would keep the banner and drop the filler.
const bannerLineEnd = ready.indexOf('\n', ready.indexOf('Antigravity CLI'))
expect(bannerLineEnd).toBeGreaterThan(0)
const scrollback = `${ready.slice(0, bannerLineEnd + 1)}${'filler output line\r\n'.repeat(MAX_TAIL_LINES + 200)}${ready.slice(bannerLineEnd + 1)}`
const verdict = await readinessVerdict(scrollback, REFUSAL_TIMEOUT_MS, captureGrid(name))
// The composer rows survive intact; it is the gate that is gone, and the refusal is a wedge.
expect(verdict.ready).toBe(false)
}, 30_000)
it('refuses a pane whose agy has already exited', async () => {
// `antigravity-dialog-dismissed.txt` is the one capture whose teardown prints something:
// agy's `Resume with -c (or command below):` footer. Replayed whole, the pane is a finished