fix: use current Antigravity screens for waits and delivery

This commit is contained in:
Neil
2026-09-19 00:58:37 -07:00
parent 9fc2c5bea6
commit dfbb928816
9 changed files with 306 additions and 32 deletions
@@ -1,5 +1,27 @@
# Antigravity readiness: what the transcripts show
## 2026-09-19: ordinary wait and delivery integration
The ordinary wait paths now consult the same current-screen classifier for
Antigravity, including immediate checks, polling, title callbacks and the queued
message delivery gate. Snapshot reads reuse the execution host's existing terminal
model/provider path and pending-read deduplication. Cached screens are rejected
after output, process generation changes or queued headless reflow/writes.
A hidden rebuilt Orca with installed agy reproduced the previous failure: an empty
composer reported the dismissed trust prompt, then timed out after an app restart.
With this integration, the same daemon terminal returns ready. Typing the exact
Plan placeholder makes the wait time out; clearing it returns ready again. Screenshots
and wait results were inspected together. No model generation was needed for this
check; successful authenticated worker turns remain unverified.
Runtime tests replay recorded output through the normal wait path, exercise a
trust dialog before its recorded alternate-screen teardown, then replay the ready
screen. They also cover drafts, working output, cache invalidation on new output
and reflow, and queued delivery retry after a draft becomes empty. Narrow/wrapped
layouts and absent banners remain unrecognized rather than claimed ready. Real
SSH/Windows execution and disconnect freshness still require validation.
## 2026-09-19: live 1.2.7 mode captures and visible-screen fallback
New recordings under `src/main/runtime/__fixtures__/`:
@@ -24,8 +46,8 @@ shortcut footer, and rejects separately projected draft text. The adopted-termin
visible-screen fallback uses it. Captured-screen tests and runtime fallback tests
cover ready, working, dialog, and draft cases, including mocked SSH snapshots.
**This is not a complete readiness fix.** The regular retained-output matcher still
has the defects below. Narrow wrapping, a scrolled-away banner, and older layouts
**This was initially a fallback-only fix.** The ordinary integration above now
bypasses the defective retained-output matcher for recognized Antigravity panes. Narrow wrapping, a scrolled-away banner, and older layouts
without the shortcut footer require further evidence and integration. The older
`antigravity-composer-multiline-unsent.txt` recording was reused from PR #20027 with
its original metadata; it was not recaptured on 1.2.7.
@@ -0,0 +1,130 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
function capture(name: string): string {
const raw = readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8')
const teardown =
name === 'antigravity-dialog-trust-workspace' ? raw.lastIndexOf('\x1b[?1049l') : -1
// Replay the recorded live dialog before capture shutdown leaves its alternate screen.
return teardown === -1 ? raw : raw.slice(0, teardown)
}
describe('Antigravity ordinary waits use the current screen', () => {
it.concurrent.each([
['antigravity-ready-default-127', true],
['antigravity-ready-plan-127', true],
['antigravity-ready-accept-edits-127', true],
['antigravity-plan-hint-as-draft-127', false],
['antigravity-composer-multiline-unsent', false],
['antigravity-dialog-model-picker', false],
['antigravity-busy-mid-turn', false]
] as const)('%s ready=%s', async (name, ready) => {
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'agy',
foregroundProcess: 'agy',
data: ''
})
runtime.seedHeadlessTerminal(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', { cols: 120, rows: 40 })
runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, capture(name), Date.now())
const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 3500 })
await (ready
? expect(waiting).resolves.toMatchObject({ satisfied: true })
: expect(waiting).rejects.toThrow('timeout'))
})
it('forgets a trust dialog after the real ready-screen redraw', async () => {
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'agy',
foregroundProcess: 'agy',
data: ''
})
runtime.seedHeadlessTerminal(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', { cols: 120, rows: 40 })
runtime.onPtyData(
TRANSCRIPT_PANE_PTY_ID,
capture('antigravity-dialog-trust-workspace'),
Date.now()
)
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 3500 })
).resolves.toMatchObject({ satisfied: false, blockedReason: 'agent-trust-workspace' })
runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, capture('antigravity-ready-default-127'), Date.now())
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 3500 })
).resolves.toMatchObject({ satisfied: true })
}, 10000)
it('invalidates a ready screen before draft output finishes parsing', async () => {
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'agy',
foregroundProcess: 'agy',
data: ''
})
runtime.seedHeadlessTerminal(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', { cols: 120, rows: 40 })
runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, capture('antigravity-ready-plan-127'), Date.now())
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 3500 })
).resolves.toMatchObject({ satisfied: true })
runtime.onPtyData(
TRANSCRIPT_PANE_PTY_ID,
capture('antigravity-plan-hint-as-draft-127'),
Date.now()
)
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2500 })
).rejects.toThrow('timeout')
}, 10000)
it('invalidates a ready screen when the terminal grid reflows', async () => {
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'agy',
foregroundProcess: 'agy',
data: ''
})
runtime.seedHeadlessTerminal(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', { cols: 120, rows: 40 })
runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, capture('antigravity-ready-plan-127'), Date.now())
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 3500 })
).resolves.toMatchObject({ satisfied: true })
runtime.reflowHeadlessTerminalToPtyGrid(TRANSCRIPT_PANE_PTY_ID, 20, 40)
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2500 })
).rejects.toThrow('timeout')
}, 10000)
it('rechecks queued delivery after a draft becomes an empty composer', async () => {
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'agy',
foregroundProcess: 'agy',
data: ''
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these protected methods are exercised by the real delivery path; only the final delivery action is spied on.
const delivery = runtime as unknown as {
checkDeliverySettledAndArmRecheck(leaf: { tabId: string; leafId: string }): boolean
deliverPendingMessagesForLeaf(leaf: unknown): void
}
const deliver = vi.spyOn(delivery, 'deliverPendingMessagesForLeaf').mockImplementation(() => {})
const leaf = { tabId: 'tab-1', leafId: '11111111-1111-4111-8111-111111111111' }
runtime.seedHeadlessTerminal(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', { cols: 120, rows: 40 })
runtime.onPtyData(
TRANSCRIPT_PANE_PTY_ID,
capture('antigravity-plan-hint-as-draft-127'),
Date.now()
)
expect(delivery.checkDeliverySettledAndArmRecheck(leaf)).toBe(false)
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 3500 })
).rejects.toThrow('timeout')
expect(deliver).not.toHaveBeenCalled()
runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, capture('antigravity-ready-plan-127'), Date.now())
await vi.waitFor(() => expect(deliver).toHaveBeenCalled(), { timeout: 4500 })
}, 10000)
})
@@ -103,6 +103,13 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc
// Why: the primary OSC-title signal can't fire for daemon-hosted terminals (no PTY data through the runtime), so this fallback polls the renderer-synced tab title + foreground-process quiescence; self-cancels when the OSC path fires.
protected isTuiIdleSatisfiedForLeaf(leaf: RuntimeLeafRecord): boolean {
const screen = this.getTerminalScreenReadiness(
leaf.ptyId,
buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
)
if (screen) {
return screen.ready
}
return isTuiIdleSatisfied({
record: leaf,
rendererTitle: leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title ?? null,
@@ -188,6 +195,13 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc
}
protected isTuiIdleSatisfiedForPty(pty: RuntimePtyWorktreeRecord): boolean {
const screen = this.getTerminalScreenReadiness(
pty.ptyId,
buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
)
if (screen) {
return screen.ready
}
return isTuiIdleSatisfied({
record: pty,
readPositiveBodyEvidence: () =>
@@ -330,6 +330,7 @@ export class OrcaRuntimeWithRuntimeId {
)
protected readonly terminalIdlePolls = new RuntimeTerminalIdlePolls({
getScreenReadiness: (ptyId, text) => this.getTerminalScreenReadiness(ptyId, text),
intervalMs: TUI_IDLE_POLL_INTERVAL_MS,
quiescenceMs: TUI_IDLE_QUIESCENCE_MS,
getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null,
@@ -344,6 +345,7 @@ export class OrcaRuntimeWithRuntimeId {
protected readonly terminalWait = new RuntimeTerminalWaitController(
{
getScreenReadiness: (ptyId, text) => this.getTerminalScreenReadiness(ptyId, text),
defaultTimeoutMs: TUI_IDLE_DEFAULT_TIMEOUT_MS,
getLivePty: (handle) => this.getLivePtyForHandle(handle),
getLiveLeaf: (handle) => this.getLiveLeafForHandle(handle),
@@ -9,9 +9,38 @@ import {
} from './orca-runtime-postlude'
import { projectTerminalVisibleLines } from './orca-runtime-terminal-projection'
import { HeadlessEmulator } from '../daemon/headless-emulator'
import {
classifyTerminalScreenReadiness,
type TerminalScreenReadiness
} from './terminal-screen-readiness'
import { withTimeout } from './runtime-async-boundaries'
export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptureProviderTerminalBuffer {
protected getTerminalScreenReadiness(
ptyId: string | null | undefined,
retainedText: string
): TerminalScreenReadiness | null {
const agent = this.getPaneAgentForTuiIdle(ptyId)
if (
!ptyId ||
(agent ? agent !== 'antigravity' : !retainedText.toLowerCase().includes('antigravity cli'))
) {
return null
}
const cached = this.providerVisibleStateByPtyId.get(ptyId)
if (
cached?.generation === this.getPtyLifecycleGeneration(ptyId) &&
cached.sequence >= this.getPtyOutputSequence(ptyId) &&
(!cached.headlessWriteChain ||
cached.headlessWriteChain === this.headlessTerminals.get(ptyId)?.writeChain)
) {
return classifyTerminalScreenReadiness({ tail: cached.lines, draft: cached.draft })
}
// A pending or unreachable screen cannot prove that typing is safe.
void this.readVisibleTerminalState(ptyId).catch(() => {})
return { ready: false, blockedReason: null }
}
protected async visibleSnapshotPreview(ptyId: string, preview: string): Promise<string> {
const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId)
const providerModeUnknown =
@@ -39,11 +68,22 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur
return pending.promise
}
let entry: { generation: number; promise: Promise<RuntimeVisibleTerminalState | null> }
const promise = this.loadVisibleTerminalState(ptyId).finally(() => {
if (this.providerVisibleStateReadsByPtyId.get(ptyId) === entry) {
this.providerVisibleStateReadsByPtyId.delete(ptyId)
}
})
const promise = this.loadVisibleTerminalState(ptyId)
.then((state) => {
if (
state &&
state.generation === this.getPtyLifecycleGeneration(ptyId) &&
state.sequence >= this.getPtyOutputSequence(ptyId)
) {
this.providerVisibleStateByPtyId.set(ptyId, state)
}
return state
})
.finally(() => {
if (this.providerVisibleStateReadsByPtyId.get(ptyId) === entry) {
this.providerVisibleStateReadsByPtyId.delete(ptyId)
}
})
entry = { generation, promise }
this.providerVisibleStateReadsByPtyId.set(ptyId, entry)
return promise
@@ -63,6 +103,8 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur
if (
cached?.generation === generation &&
outputSequence <= cached.sequence &&
(!cached.headlessWriteChain ||
cached.headlessWriteChain === this.headlessTerminals.get(ptyId)?.writeChain) &&
(!trackedMode || trackedMode.isAlternateScreen === cached.isAlternateScreen)
) {
return cached
@@ -125,15 +167,18 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur
return null
}
const generation = this.getPtyLifecycleGeneration(ptyId)
await state.writeChain
const writeChain = state.writeChain
await writeChain
if (
this.headlessTerminals.get(ptyId) !== state ||
state.writeChain !== writeChain ||
this.getPtyLifecycleGeneration(ptyId) !== generation
) {
return null
}
return {
...projectTerminalVisibleLines(state.emulator),
headlessWriteChain: writeChain,
isAlternateScreen: state.emulator.isAlternateScreen,
sequence: state.outputSequence,
generation
+37 -19
View File
@@ -1,3 +1,4 @@
import type { ReadTerminalScreenReadiness } from './terminal-screen-readiness'
import { isShellProcess, type AgentStatus } from '../../shared/agent-detection'
import type { RuntimeTerminalWait } from '../../shared/runtime-types'
import {
@@ -34,6 +35,7 @@ import type { TerminalWaiter } from './runtime-terminal-contracts'
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
type RuntimeTerminalIdlePollDependencies = {
getScreenReadiness?: ReadTerminalScreenReadiness
intervalMs: number
quiescenceMs: number
getTabTitle(tabId: string): string | null
@@ -114,7 +116,10 @@ export class RuntimeTerminalIdlePolls {
let startedForegroundPoll = false
try {
const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
const blockedReason = detectTerminalWaitBlockedReason(waitText)
const screen = this.deps.getScreenReadiness?.(leaf.ptyId, waitText)
const blockedReason = screen
? screen.blockedReason
: detectTerminalWaitBlockedReason(waitText)
if (blockedReason) {
this.stop(entry)
this.deps.resolve(
@@ -124,19 +129,24 @@ export class RuntimeTerminalIdlePolls {
return
}
if (
isTuiIdleSatisfied({
record: leaf,
rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId),
readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText),
agent,
firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId),
quiescenceMs: this.deps.quiescenceMs
})
screen
? screen.ready
: isTuiIdleSatisfied({
record: leaf,
rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId),
readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText),
agent,
firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId),
quiescenceMs: this.deps.quiescenceMs
})
) {
this.stop(entry)
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
return
}
if (screen) {
return
}
if (
leaf.lastAgentStatus === null &&
quietForegroundProcessProvesTuiIdle(agent) &&
@@ -180,7 +190,10 @@ export class RuntimeTerminalIdlePolls {
let startedForegroundPoll = false
try {
const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
const blockedReason = detectTerminalWaitBlockedReason(waitText)
const screen = this.deps.getScreenReadiness?.(pty.ptyId, waitText)
const blockedReason = screen
? screen.blockedReason
: detectTerminalWaitBlockedReason(waitText)
if (blockedReason) {
this.stop(entry)
this.deps.resolve(
@@ -190,20 +203,25 @@ export class RuntimeTerminalIdlePolls {
return
}
if (
isTuiIdleSatisfied({
record: pty,
readPositiveBodyEvidence: () =>
this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' ||
isKnownReadyPromptPreview(waitText),
agent,
firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId),
quiescenceMs: this.deps.quiescenceMs
})
screen
? screen.ready
: isTuiIdleSatisfied({
record: pty,
readPositiveBodyEvidence: () =>
this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' ||
isKnownReadyPromptPreview(waitText),
agent,
firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId),
quiescenceMs: this.deps.quiescenceMs
})
) {
this.stop(entry)
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
return
}
if (screen) {
return
}
if (
pty.lastAgentStatus === null &&
quietForegroundProcessProvesTuiIdle(agent) &&
@@ -115,6 +115,7 @@ export type RuntimeHeadlessTerminal = {
}
export type RuntimeVisibleTerminalState = {
headlessWriteChain?: Promise<void>
lines: string[]
draft?: string
isAlternateScreen: boolean
+21 -4
View File
@@ -1,3 +1,4 @@
import type { ReadTerminalScreenReadiness } from './terminal-screen-readiness'
import type {
RuntimeTerminalWait as RuntimeTerminalWaitResult,
RuntimeTerminalWaitCondition
@@ -23,6 +24,7 @@ import type { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls'
import type { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry'
type RuntimeTerminalWaitDependencies = {
getScreenReadiness?: ReadTerminalScreenReadiness
defaultTimeoutMs: number
getLivePty(handle: string): { pty: RuntimePtyWorktreeRecord } | null
getLiveLeaf(handle: string): { leaf: RuntimeLeafRecord }
@@ -44,6 +46,10 @@ export class RuntimeTerminalWait {
/** Why one helper per record kind: every satisfaction site must rank the same way,
* or the immediate check and the poll disagree about the same pane. */
private ptySatisfied(pty: RuntimePtyWorktreeRecord, waitText: string): boolean {
const screen = this.deps.getScreenReadiness?.(pty.ptyId, waitText)
if (screen) {
return screen.ready
}
return isTuiIdleSatisfied({
record: pty,
readPositiveBodyEvidence: () =>
@@ -55,6 +61,10 @@ export class RuntimeTerminalWait {
}
private leafSatisfied(leaf: RuntimeLeafRecord, waitText: string): boolean {
const screen = this.deps.getScreenReadiness?.(leaf.ptyId, waitText)
if (screen) {
return screen.ready
}
return isTuiIdleSatisfied({
record: leaf,
rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId),
@@ -65,6 +75,11 @@ export class RuntimeTerminalWait {
})
}
private blockedReason(ptyId: string | null | undefined, text: string) {
const screen = this.deps.getScreenReadiness?.(ptyId, text)
return screen ? screen.blockedReason : detectTerminalWaitBlockedReason(text)
}
async wait(
handle: string,
options?: {
@@ -84,7 +99,8 @@ export class RuntimeTerminalWait {
pty.pty.tailPartialLine,
pty.pty.preview
)
const ptyBlockedReason = detectTerminalWaitBlockedReason(ptyWaitText)
const ptyBlockedReason =
condition === 'tui-idle' ? this.blockedReason(pty.pty.ptyId, ptyWaitText) : null
if (condition === 'tui-idle' && ptyBlockedReason) {
return buildPtyTerminalWaitBlockedResult(handle, condition, pty.pty, ptyBlockedReason)
}
@@ -130,7 +146,7 @@ export class RuntimeTerminalWait {
live.pty.tailPartialLine,
live.pty.preview
)
const blockedReason = detectTerminalWaitBlockedReason(livePtyWaitText)
const blockedReason = this.blockedReason(live.pty.ptyId, livePtyWaitText)
if (blockedReason) {
this.waiters.resolve(
waiter,
@@ -153,7 +169,8 @@ export class RuntimeTerminalWait {
}
const leafWaitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
const leafBlockedReason = detectTerminalWaitBlockedReason(leafWaitText)
const leafBlockedReason =
condition === 'tui-idle' ? this.blockedReason(leaf.ptyId, leafWaitText) : null
if (condition === 'tui-idle' && leafBlockedReason) {
return buildTerminalWaitBlockedResult(handle, condition, leaf, leafBlockedReason)
}
@@ -215,7 +232,7 @@ export class RuntimeTerminalWait {
live.leaf.tailPartialLine,
live.leaf.preview
)
const blockedReason = detectTerminalWaitBlockedReason(liveLeafWaitText)
const blockedReason = this.blockedReason(live.leaf.ptyId, liveLeafWaitText)
if (blockedReason) {
this.waiters.resolve(
waiter,
+26 -1
View File
@@ -1,4 +1,8 @@
import { isKnownReadyPromptPreview } from './terminal-wait-detection'
import {
detectTerminalWaitBlockedReason,
isKnownReadyPromptPreview
} from './terminal-wait-detection'
import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types'
const ANTIGRAVITY_FRAME = /^─{8,}$/
const ANTIGRAVITY_EMPTY_COMPOSERS = new Set([
@@ -25,3 +29,24 @@ export function isKnownReadyTerminalScreen(screen: { tail: string[]; draft?: str
ANTIGRAVITY_FRAME.test(rows.at(-4) ?? '')
)
}
export type TerminalScreenReadiness = {
ready: boolean
blockedReason: RuntimeTerminalWaitBlockedReason | null
}
export type ReadTerminalScreenReadiness = (
ptyId: string | null | undefined,
retainedText: string
) => TerminalScreenReadiness | null
export function classifyTerminalScreenReadiness(screen: {
tail: string[]
draft?: string
}): TerminalScreenReadiness {
const ready = isKnownReadyTerminalScreen(screen)
return {
ready,
blockedReason: ready ? null : detectTerminalWaitBlockedReason(screen.tail.join('\n'))
}
}