From 0e1695324547aef25bd4e0dee7e6fe2dac41b806 Mon Sep 17 00:00:00 2001 From: beattlekid Date: Sun, 20 Sep 2026 00:19:17 +0700 Subject: [PATCH] feat: add supervised Antigravity worker support --- .../antigravity-readiness-evidence.md | 18 ++-- .../agent-prompt-submission-runtime.test.ts | 39 ++++++- ...ent-prompt-submission-verification.test.ts | 9 ++ .../agent-prompt-submission-verification.ts | 6 +- .../agent-transcript-pane-test-harness.ts | 10 ++ .../antigravity-readiness-transcripts.test.ts | 45 +++----- .../antigravity-terminal-readiness.test.ts | 36 +++++++ .../runtime/antigravity-terminal-readiness.ts | 66 ++++++++++++ .../orca-runtime-resolve-terminal-pane.ts | 4 +- src/main/runtime/orca-runtime-runtime-id.ts | 4 +- ...ntime-start-tui-idle-visible-read-probe.ts | 18 +++- .../antigravity-worker-lifecycle.test.ts | 102 ++++++++++++++++++ .../worker/worker-launch-preferences.test.ts | 34 ++++++ .../worker/worker-release.test-support.ts | 32 ++++-- src/main/runtime/runtime-terminal-wait.ts | 35 +++++- .../runtime/terminal-wait-detection.test.ts | 7 +- src/main/runtime/terminal-wait-detection.ts | 11 +- ...gent-session-option-catalog-antigravity.ts | 37 +++++++ src/shared/agent-session-option-catalog.ts | 2 + .../tui-agent-startup-session-options.test.ts | 18 ++++ 20 files changed, 459 insertions(+), 74 deletions(-) create mode 100644 src/main/runtime/antigravity-terminal-readiness.test.ts create mode 100644 src/main/runtime/antigravity-terminal-readiness.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts create mode 100644 src/shared/agent-session-option-catalog-antigravity.ts diff --git a/docs/reference/antigravity-readiness-evidence.md b/docs/reference/antigravity-readiness-evidence.md index 0010fa76ded..7f444445bb1 100644 --- a/docs/reference/antigravity-readiness-evidence.md +++ b/docs/reference/antigravity-readiness-evidence.md @@ -1,7 +1,7 @@ # Antigravity readiness: what the transcripts show -`findAntigravityReadyPromptIndex` in `src/main/runtime/terminal-wait-detection.ts` decides whether -an Antigravity pane is ready for a prompt. It has been written five times, each version tuned +`findAntigravityReadyPromptIndex` in `src/main/runtime/antigravity-terminal-readiness.ts` decides +whether an Antigravity pane is ready for a prompt. Its predecessor was written five times, each version tuned against a five-line screen typed from memory into a `.spec.ts` fixture. Three of the first four were found worse than the bug they replaced, and the fifth was reverted. @@ -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. +**Resolved behavior:** readiness now requires the last retained or visible-screen row to be the +bare `>` composer. Model and account rows are deliberately ignored. Antigravity waits also take a +bounded visible-screen snapshot because cursor-addressed redraws can leave the retained byte tail +ending on an older response after the composer has returned. ## Versions @@ -177,7 +177,7 @@ Nothing else in the capture distinguishes the two states. The hint row (`esc to Evidence column names the fixture; all quoted text is from the committed transcripts. -### Attempt 1 — the rule at HEAD +### Attempt 1 — the pre-fix rule | # | Claim | Verdict | Evidence | | ---- | -------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -233,9 +233,9 @@ 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 -Yes — but not as a variation on any of the five. Every one of them refined a predicate over +Implemented, 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: diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 823bf21e0af..84f8666a9f1 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -475,7 +475,7 @@ describe('agent prompt submission runtime', () => { state: 'done' | 'working' stateStartedAt: number }, - launchAgent: 'kimi' | 'codex' = 'kimi' + launchAgent: 'antigravity' | 'kimi' | 'codex' = 'kimi' ): Promise<{ runtime: OrcaRuntimeService handle: string @@ -542,6 +542,43 @@ describe('agent prompt submission runtime', () => { expect(writes.filter((data) => data === '\r')).toHaveLength(1) }) + it('settles an Antigravity prompt when PreInvocation starts a new hook turn', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const hook = { state: 'done' as 'done' | 'working', stateStartedAt: 1_000 } + const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'antigravity') + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + if (data === '\r') { + vi.setSystemTime(3_000) + hook.state = 'working' + hook.stateStartedAt = 3_000 + } + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + acceptQueued: true, + requestId: 'antigravity-pre-invocation', + observationTimeoutMs: 20_000 + }) + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ + prompt: { + provider: 'antigravity', + observation: 'supported', + stages: ['input_accepted', 'turn_started'] + } + }) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + // Why: same-state pings keep refreshing receivedAt on a turn that started before the prompt; // only the pinned stateStartedAt separates that from a turn this prompt started. it('does not accept a hook row refreshed without a new working turn', async () => { diff --git a/src/main/runtime/agent-prompt-submission-verification.test.ts b/src/main/runtime/agent-prompt-submission-verification.test.ts index 3009289f3c4..aea3ffee9ef 100644 --- a/src/main/runtime/agent-prompt-submission-verification.test.ts +++ b/src/main/runtime/agent-prompt-submission-verification.test.ts @@ -4,6 +4,7 @@ import { AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS, type AgentPromptActivity, isAgentPromptStalledError, + isTerminalSendSettlementAgent, readAgentPromptWaitText, resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission @@ -292,12 +293,20 @@ describe('agent prompt submission verification', () => { }) it('gives hook-observed agents the longer effect window', () => { + expect(resolveAgentPromptEffectTimeoutMs('antigravity')).toBe( + AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS + ) expect(resolveAgentPromptEffectTimeoutMs('codex')).toBe(AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS) expect(resolveAgentPromptEffectTimeoutMs('kimi')).toBe(AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS) expect(resolveAgentPromptEffectTimeoutMs('claude')).toBe(AGENT_PROMPT_EFFECT_TIMEOUT_MS) expect(resolveAgentPromptEffectTimeoutMs(null)).toBe(AGENT_PROMPT_EFFECT_TIMEOUT_MS) }) + it('uses Antigravity PreInvocation hooks to settle prompt receipts', () => { + expect(isTerminalSendSettlementAgent('antigravity')).toBe(true) + expect(isTerminalSendSettlementAgent('gemini')).toBe(false) + }) + it('recognizes a stalled verdict from a message or a relayed error code', () => { expect(isAgentPromptStalledError(new Error('agent_prompt_stalled'))).toBe(true) expect(isAgentPromptStalledError({ code: 'agent_prompt_stalled' })).toBe(true) diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index 39dd8fa6c4e..ad7095abddc 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -5,7 +5,7 @@ import type { TuiAgent } from '../../shared/tui-agent' export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS const AGENT_PROMPT_EFFECT_POLL_MS = 50 -const HOOK_OBSERVED_TURN_START_AGENTS = new Set(['codex', 'kimi']) +const HOOK_OBSERVED_TURN_START_AGENTS = new Set(['antigravity', 'codex', 'kimi']) /** The prompt bytes are written before verification, so this only ever means "not observed". */ export const AGENT_PROMPT_STALLED_ERROR = 'agent_prompt_stalled' @@ -53,8 +53,8 @@ export function resolveAgentPromptEffectTimeoutMs(agent: TuiAgent | null | undef /** Only these providers expose a turn-start signal Orca can settle a prompt receipt against. */ export function isTerminalSendSettlementAgent( agent: TuiAgent | null | undefined -): agent is 'claude' | 'codex' { - return agent === 'claude' || agent === 'codex' +): agent is 'antigravity' | 'claude' | 'codex' { + return agent === 'antigravity' || agent === 'claude' || agent === 'codex' } export function isAgentPromptStalledError(error: unknown): boolean { diff --git a/src/main/runtime/agent-transcript-pane-test-harness.ts b/src/main/runtime/agent-transcript-pane-test-harness.ts index 4345e98fd93..5f0c20267d9 100644 --- a/src/main/runtime/agent-transcript-pane-test-harness.ts +++ b/src/main/runtime/agent-transcript-pane-test-harness.ts @@ -1,6 +1,7 @@ // One pane builder for every suite that replays a captured agent transcript through the runtime. import { vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' +import type { TuiAgent } from '../../shared/tui-agent' const TRANSCRIPT_PANE_LEAF_ID = '11111111-1111-4111-8111-111111111111' const TRANSCRIPT_PANE_TAB_ID = 'tab-1' @@ -11,6 +12,7 @@ export type TranscriptPaneOptions = { paneTitle: string foregroundProcess: string | null data: string + launchAgent?: TuiAgent /** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */ connectionId?: string /** Simulates a PTY controller whose foreground probe never settles. */ @@ -71,6 +73,14 @@ export async function createTranscriptPane( } ] }) + if (options.launchAgent) { + runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, TRANSCRIPT_PANE_WORKTREE_ID, null, { + tabId: TRANSCRIPT_PANE_TAB_ID, + leafId: TRANSCRIPT_PANE_LEAF_ID, + incarnationId: 'inc-1', + agentLaunchAuthority: { launchToken: 'transcript-launch', launchAgent: options.launchAgent } + }) + } // Why the guard: a restore seed is only applied to a never-written record, so the restore // cases must not write an empty chunk first. if (options.data.length > 0) { diff --git a/src/main/runtime/antigravity-readiness-transcripts.test.ts b/src/main/runtime/antigravity-readiness-transcripts.test.ts index 3ac7707565f..d4da85e8662 100644 --- a/src/main/runtime/antigravity-readiness-transcripts.test.ts +++ b/src/main/runtime/antigravity-readiness-transcripts.test.ts @@ -6,9 +6,6 @@ * 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. - * * Capture protocol: docs/reference/agent-pty-transcript-capture.md * What each transcript decides: docs/reference/antigravity-readiness-evidence.md */ @@ -51,14 +48,8 @@ type TranscriptCase = { /** Capture in docs/reference/antigravity-readiness-evidence.md. */ capture: string what: string - /** What a correct detector must answer. Not what the shipped one answers. */ + /** What the detector must answer. */ expectReady: boolean - /** - * Set where the shipped detector contradicts the transcript. The case then runs inverted, so - * CI pins the defect instead of going permanently red — and flips to failing the moment - * someone fixes it, which is exactly when these expectations need re-reading. - */ - knownDefect?: string } const TRANSCRIPTS: readonly TranscriptCase[] = [ @@ -66,15 +57,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 +75,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', @@ -102,20 +90,18 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [ expectReady: false }, { - // 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. + // The retained bytes contain an error footer, but the rendered capture still shows a spinner; + // fail closed until a live provider screen proves that the composer returned. name: 'antigravity-busy-turn-ended', capture: 'E', - what: 'the turn has ended and the composer has returned, process still alive', - expectReady: true, - knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it' + what: 'error-ended turn with an ambiguous rendered screen', + expectReady: false }, { 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 @@ -171,6 +157,7 @@ async function readinessVerdict( // capture carries the OSC bytes, so the pane wears whatever the CLI actually set. paneTitle: extractLastOscTitle(transcript) ?? ANTIGRAVITY_COMMAND, foregroundProcess: ANTIGRAVITY_COMMAND, + launchAgent: 'antigravity', data: transcript }) try { @@ -194,15 +181,7 @@ describe('Antigravity readiness, decided by captured transcripts', () => { const captured = existsSync(path) const label = `capture ${transcript.capture}: ${transcript.what}` - // A pinned defect asserts what the detector DOES, so CI is honest rather than permanently - // red; fixing the detector flips this case to failing, which is when these expectations - // need re-reading. The correct answer stays in `expectReady` and in the test's name. - const shipped = - transcript.knownDefect === undefined ? transcript.expectReady : !transcript.expectReady - const verdictName = - transcript.knownDefect === undefined - ? `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}` - : `${label} → must be ${transcript.expectReady ? 'ready' : 'not ready'}; KNOWN DEFECT, ${transcript.knownDefect}` + const verdictName = `${label}: ${transcript.expectReady ? 'ready' : 'not ready'}` it.skipIf(!captured)( verdictName, @@ -216,7 +195,7 @@ describe('Antigravity readiness, decided by captured transcripts', () => { // A silent dialog carries no blocked-signal wording, so the assertion is only that Orca // does not call the pane ready and type a prompt into a dialog that owns the screen. expect({ ready: verdict.ready, outcome: verdict.outcome }).toMatchObject({ - ready: shipped + ready: transcript.expectReady }) }, READY_TIMEOUT_MS + 10_000 diff --git a/src/main/runtime/antigravity-terminal-readiness.test.ts b/src/main/runtime/antigravity-terminal-readiness.test.ts new file mode 100644 index 00000000000..5e274dbe7bf --- /dev/null +++ b/src/main/runtime/antigravity-terminal-readiness.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + detectTerminalWaitBlockedReason, + isKnownReadyPromptPreview +} from './terminal-wait-detection' + +const HEADER = 'Antigravity CLI 1.2.0' + +describe('Antigravity terminal readiness', () => { + it('accepts the idle composer without requiring model or account rows', () => { + expect(isKnownReadyPromptPreview(`${HEADER}\nlogo glyphs custom provider\n>`)).toBe(true) + }) + + it.each([ + 'Signing in...', + 'Loading workspace...', + 'Initializing MCP servers...', + '> Gemini 3.7 Flash (current)', + 'unexpected startup state' + ])('fails closed while the last visible row is %j', (row) => { + expect(isKnownReadyPromptPreview(`${HEADER}\n${row}`)).toBe(false) + }) + + it('treats a last-row spinner as busy even when an older composer remains in the tail', () => { + expect(isKnownReadyPromptPreview(`${HEADER}\n>\nGenerating...`)).toBe(false) + }) + + it('refreshes a stale trust block after the composer appears without answering it', () => { + const trust = `${HEADER}\nDo you trust this workspace folder?\n> Yes, I trust this folder` + expect(detectTerminalWaitBlockedReason(trust)).toBe('agent-trust-workspace') + + const acceptedByUser = `${trust}\n${HEADER}\n>` + expect(detectTerminalWaitBlockedReason(acceptedByUser)).toBeNull() + expect(isKnownReadyPromptPreview(acceptedByUser)).toBe(true) + }) +}) diff --git a/src/main/runtime/antigravity-terminal-readiness.ts b/src/main/runtime/antigravity-terminal-readiness.ts new file mode 100644 index 00000000000..b5728f13a54 --- /dev/null +++ b/src/main/runtime/antigravity-terminal-readiness.ts @@ -0,0 +1,66 @@ +import { isTerminalWaitWhitespace } from './terminal-wait-tail-window' + +/** + * Antigravity paints its chrome with cursor addressing, so model/account rows are not stable + * line anchors. The idle composer is the only captured marker that survives every ready screen. + */ +export function findAntigravityReadyPromptIndex(normalized: string): number | null { + return findAntigravityComposerIndex(normalized, true) +} + +/** Visible-screen snapshots may omit the banner after a dialog closes. */ +export function isAntigravityReadyPromptSnapshot(text: string): boolean { + return findAntigravityComposerIndex(text.toLowerCase(), false) !== null +} + +function findAntigravityComposerIndex(normalized: string, requireHeader: boolean): number | null { + const headerIndex = normalized.lastIndexOf('antigravity cli') + const contentStart = headerIndex === -1 ? 0 : headerIndex + if (requireHeader && headerIndex === -1) { + return null + } + + let offset = 0 + let composerStart: number | null = null + let composerEnd = 0 + for (const line of normalized.split('\n')) { + const lineStart = offset + const lineEnd = offset + line.length + let trimmedStart = lineStart + let trimmedEnd = lineEnd + while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { + trimmedStart += 1 + } + while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { + trimmedEnd -= 1 + } + if (trimmedStart >= contentStart && trimmedEnd - trimmedStart === 1) { + if (normalized.charCodeAt(trimmedStart) === 62) { + composerStart = trimmedStart + composerEnd = trimmedEnd + } + } + offset = lineEnd + 1 + } + if (composerStart === null) { + return null + } + const suffix = normalized.slice(composerEnd) + const currentScreen = normalized.slice(contentStart) + // A trailing caret also appears on trust, sign-in, model, and onboarding menus. Those panes + // must remain blocked until the menu is gone; only the latest AGY screen can establish readiness. + if ( + /do you trust|sign in|select a model|collect usage|choose a theme|press enter to continue|\b[12]\.\s/.test( + currentScreen + ) + ) { + return null + } + return suffix.trim().length === 0 || /resume with -c|agy --conversation/i.test(suffix) + ? composerStart + : null +} + +export function hasAntigravityTerminalHeader(text: string): boolean { + return text.toLowerCase().includes('antigravity cli') +} diff --git a/src/main/runtime/orca-runtime-resolve-terminal-pane.ts b/src/main/runtime/orca-runtime-resolve-terminal-pane.ts index 51e81ee3d5d..1093319783a 100644 --- a/src/main/runtime/orca-runtime-resolve-terminal-pane.ts +++ b/src/main/runtime/orca-runtime-resolve-terminal-pane.ts @@ -232,7 +232,9 @@ export class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWithGetTermin opts: { limit?: number } = {} ): Promise { const visibleState = await this.readVisibleTerminalState(ptyId) - const projection = visibleState ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit)) + const projection = + visibleState ?? + (await this.readProviderTerminalTailLines(ptyId, opts.limit, { visibleScreenOnly: true })) if (projection.lines.length === 0) { return { ...read, source: 'screen-unavailable' } } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 77fd49e8899..ce6ec262a24 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -353,8 +353,8 @@ export class OrcaRuntimeWithRuntimeId { getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId), getFirstPartyAgentStatus: (ptyId) => (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, - startVisibleReadProbe: (waiter, waiterTimeoutMs) => - this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs) + startVisibleReadProbe: (waiter, waiterTimeoutMs, agent) => + this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs, agent) }, this.terminalWaiters, this.terminalIdlePolls diff --git a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts index aa630277078..bd8173a3944 100644 --- a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts +++ b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts @@ -24,6 +24,8 @@ import { buildTerminalWaitResult } from './terminal-wait-results' import { createSetupCompletionScanner } from './orchestration/setup-completion-signal' +import { isAntigravityReadyPromptSnapshot } from './antigravity-terminal-readiness' +import type { TuiAgent } from '../../shared/tui-agent' export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWithCreateAgentPromptRenderGate { /** One bounded look at the provider's screen for an adopted PTY whose retained @@ -31,7 +33,11 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith * screen already showing a settled prompt", and the poll above owns every * later transition. A provider screen that is still working when this fires * resolves through the poll, not here. */ - protected startTuiIdleVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void { + protected startTuiIdleVisibleReadProbe( + waiter: TerminalWaiter, + waiterTimeoutMs: number, + agent: TuiAgent | null + ): void { const settleMarginMs = Math.min( TUI_IDLE_VISIBLE_PROBE_SETTLE_MARGIN_MS, Math.max(1, Math.floor(waiterTimeoutMs / 3)) @@ -48,7 +54,7 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith return } void withTimeout( - this.readTerminal(waiter.handle, {}, { + this.readTerminal(waiter.handle, { screen: true }, { timeoutMs: providerTimeoutMs, retireOnTimeout: true, // Why: the ready banner stays in scrollback for the whole session, so @@ -66,9 +72,13 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith ) { return } - const snapshotText = projection.tail.join('\n') + const snapshotText = [...projection.tail, projection.draft ?? ''].join('\n') const blockedReason = detectTerminalWaitBlockedReason(snapshotText) - if (!blockedReason && !isKnownReadyPromptPreview(snapshotText)) { + const ready = + agent === 'antigravity' + ? isAntigravityReadyPromptSnapshot(snapshotText) + : isKnownReadyPromptPreview(snapshotText) + if (!blockedReason && !ready) { return } const result = this.buildTuiIdleProbeResult(waiter.handle, blockedReason) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts new file mode 100644 index 00000000000..f93df6e4d7a --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types' +import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' + +const READY_WAIT = { + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null +} satisfies RuntimeTerminalWait + +describe('Antigravity orchestration worker lifecycle', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => { + h.setup() + const readiness = h.deferred() + vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise) + + const pending = h.startWorker({ agent: 'antigravity' }) + await vi.waitFor(() => expect(h.runtime.waitForTerminal).toHaveBeenCalled()) + + expect(h.runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::worktree', + expect.objectContaining({ startupAgent: 'antigravity', surfaceOwner: false }) + ) + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + expect(h.db.listWorkerTerminalResources({})[0]?.resource).toMatchObject({ + ownership_state: 'owned', + terminal_handle: 'term_worker' + }) + + readiness.resolve(READY_WAIT) + await expect(pending).resolves.toEqual( + expect.objectContaining({ dispatchId: expect.any(String) }) + ) + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) + }) + + it('stops only the owned AGY terminal', async () => { + h.setup() + const { dispatchId } = await h.startWorker({ agent: 'antigravity' }) + + await expect( + h.call('orchestration.workerStop', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledOnce() + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + }) + + it('releases an owned AGY terminal and recovers a transient stale endpoint', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker('succeeded', { + agent: 'antigravity' + }) + vi.mocked(h.runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_pending', processAction: 'none' }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'releasing' + }) + + await expect(reconcileRequestedWorkerTerminalReleases(h.runtime)).resolves.toMatchObject({ + attempted: 1, + released: 1 + }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + release_state: 'released' + }) + expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(2) + expect(h.runtime.closeTerminal).toHaveBeenNthCalledWith(2, 'term_worker') + }) + + it('fails closed on a stale AGY handle and releases it on a fresh retry', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker('succeeded', { + agent: 'antigravity' + }) + vi.mocked(h.runtime.showTerminal).mockRejectedValueOnce(new Error('terminal_handle_stale')) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_unknown' }) + expect(h.runtime.closeTerminal).not.toHaveBeenCalled() + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'unknown' + }) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'released' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts index 1cf02efa012..7f885cb1a59 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts @@ -27,6 +27,40 @@ describe('orchestration worker launch preferences', () => { }) }) + it('passes an account-scoped Antigravity model and supported effort through the shared catalog', () => { + expect( + resolveWorkerLaunchPreferences({ + agent: 'antigravity', + model: 'gemini-3.1-pro-high', + effort: 'high' + }) + ).toEqual({ + preferences: { model: 'gemini-3.1-pro-high', effort: 'high' }, + receipt: { + requested: { + agent: 'antigravity', + model: 'gemini-3.1-pro-high', + effort: 'high' + }, + effective: { + agent: 'antigravity', + model: 'gemini-3.1-pro-high', + effort: 'high' + } + } + }) + }) + + it('rejects unsupported Antigravity effort values', () => { + expect(() => + resolveWorkerLaunchPreferences({ + agent: 'antigravity', + model: 'gemini-3.1-pro-high', + effort: 'xhigh' + }) + ).toThrow('does not support effort xhigh') + }) + it('does not invent an effort when only a model is requested', () => { expect( resolveWorkerLaunchPreferences({ agent: 'codex', model: 'gpt-5.6-sol' }).preferences diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts index a13ea320670..0eef66d466a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts @@ -3,6 +3,20 @@ import { ORCHESTRATION_METHODS } from '../../orchestration' import { eraseRpcMethods, type RpcContext } from '../../../core' import { OrchestrationDb } from '../../../../orchestration/db' import { OrcaRuntimeService } from '../../../../orca-runtime' +import type { TuiAgent } from '../../../../../../shared/tui-agent' + +type WorkerStartOptions = { terminal?: string; agent?: TuiAgent } + +function isWorkerStartResult(value: unknown): value is { state: 'ready'; dispatchId: string } { + return ( + typeof value === 'object' && + value !== null && + 'state' in value && + value.state === 'ready' && + 'dispatchId' in value && + typeof value.dispatchId === 'string' + ) +} export function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void @@ -16,11 +30,11 @@ export type OrchestrationWorkerReleaseHarness = { setup: () => void cleanup: () => void call: (name: string, params: Record) => Promise - startWorker: (options?: { terminal?: string }) => Promise<{ taskId: string; dispatchId: string }> + startWorker: (options?: WorkerStartOptions) => Promise<{ taskId: string; dispatchId: string }> settle: (taskId: string, dispatchId: string, outcome: 'succeeded' | 'failed') => void startSettledWorker: ( outcome?: 'succeeded' | 'failed', - options?: { terminal?: string } + options?: WorkerStartOptions ) => Promise<{ taskId: string; dispatchId: string }> deferred: typeof deferred coordinatorPaneKey: string @@ -143,17 +157,19 @@ export function createOrchestrationWorkerReleaseHarness(): OrchestrationWorkerRe return method.handler(parsed, ctx) } - async function startWorker(options: { terminal?: string } = {}): Promise<{ + async function startWorker(options: WorkerStartOptions = {}): Promise<{ taskId: string dispatchId: string }> { const task = db.createTask({ spec: 'release fixture task', runId: activeRunId }) - const result = (await call('orchestration.workerStart', { + const result = await call('orchestration.workerStart', { task: task.id, from: 'term_coord', - ...(options.terminal ? { terminal: options.terminal } : { agent: 'codex' }) - })) as { dispatchId: string; state: string } - expect(result.state).toBe('ready') + ...(options.terminal ? { terminal: options.terminal } : { agent: options.agent ?? 'codex' }) + }) + if (!isWorkerStartResult(result)) { + throw new Error('Expected worker-start to return a ready dispatch') + } return { taskId: task.id, dispatchId: result.dispatchId } } @@ -169,7 +185,7 @@ export function createOrchestrationWorkerReleaseHarness(): OrchestrationWorkerRe async function startSettledWorker( outcome: 'succeeded' | 'failed' = 'succeeded', - options: { terminal?: string } = {} + options: WorkerStartOptions = {} ): Promise<{ taskId: string; dispatchId: string }> { const worker = await startWorker(options) settle(worker.taskId, worker.dispatchId, outcome) diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index cf095f85775..67acaf2a60b 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -2,6 +2,7 @@ import type { RuntimeTerminalWait as RuntimeTerminalWaitResult, RuntimeTerminalWaitCondition } from '../../shared/runtime-types' +import { hasAntigravityTerminalHeader } from './antigravity-terminal-readiness' import { detectTerminalWaitBlockedReason, isKnownReadyPromptPreview @@ -31,7 +32,11 @@ type RuntimeTerminalWaitDependencies = { quiescenceMs: number getPaneAgent(ptyId: string | null | undefined): TuiAgent | null getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus - startVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void + startVisibleReadProbe( + waiter: TerminalWaiter, + waiterTimeoutMs: number, + agent: TuiAgent | null + ): void } export class RuntimeTerminalWait { @@ -140,8 +145,20 @@ export class RuntimeTerminalWait { this.waiters.resolve(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) } else { this.polls.startPty(waiter, live.pty) - if (live.pty.lastAgentStatus === null && livePtyWaitText.length === 0) { - this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs) + const paneAgent = this.deps.getPaneAgent(live.pty.ptyId) + if ( + // AGY can retain a stale working/blocked status after a trust dialog was + // dismissed. Its visible composer is authoritative, so probe whenever the + // pane is identified as AGY (or its banner is present), regardless of that + // stale status. + (paneAgent === 'antigravity' || + hasAntigravityTerminalHeader(livePtyWaitText) || + live.pty.lastAgentStatus === null) && + (livePtyWaitText.length === 0 || + paneAgent === 'antigravity' || + hasAntigravityTerminalHeader(livePtyWaitText)) + ) { + this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs, paneAgent) } } } @@ -232,8 +249,16 @@ export class RuntimeTerminalWait { // while the last OSC title is still "working"; keep polling the // preview/title until the waiter resolves or hits its timeout. this.polls.startLeaf(waiter, live.leaf) - if (live.leaf.lastAgentStatus === null && liveLeafWaitText.length === 0) { - this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs) + const paneAgent = this.deps.getPaneAgent(live.leaf.ptyId) + if ( + (paneAgent === 'antigravity' || + hasAntigravityTerminalHeader(liveLeafWaitText) || + live.leaf.lastAgentStatus === null) && + (liveLeafWaitText.length === 0 || + paneAgent === 'antigravity' || + hasAntigravityTerminalHeader(liveLeafWaitText)) + ) { + this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs, paneAgent) } } } diff --git a/src/main/runtime/terminal-wait-detection.test.ts b/src/main/runtime/terminal-wait-detection.test.ts index e52344c5dc0..eec93339bf6 100644 --- a/src/main/runtime/terminal-wait-detection.test.ts +++ b/src/main/runtime/terminal-wait-detection.test.ts @@ -389,10 +389,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 ready screen for a non-Gemini model', () => { const waitText = waitTextFor([ 'Antigravity CLI 1.0.3', 'user@example.com (Antigravity Business)', @@ -401,7 +398,7 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { '>' ]) - expect(isKnownReadyPromptPreview(waitText)).toBe(false) + expect(isKnownReadyPromptPreview(waitText)).toBe(true) }) // Ratchet, not a guard of today's code: these pass now only because none of them prints a 'gemini' diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 08bd1d1d512..86aed377d8c 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -5,6 +5,10 @@ import { type AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' +import { + findAntigravityReadyPromptIndex as findAntigravityComposerIndex, + isAntigravityReadyPromptSnapshot +} from './antigravity-terminal-readiness' import { isTerminalWaitWhitespace, startOfLastLines, @@ -83,6 +87,7 @@ function findDismissedStartupModalIndex(normalized: string): number | null { const indexes = [ findCodexReadyPromptIndex(normalized), findAntigravityReadyPromptIndex(normalized), + findAntigravityComposerIndex(normalized), findCursorActivePromptIndex(normalized) ].filter((index): index is number => index !== null) return indexes.length > 0 ? Math.max(...indexes) : null @@ -94,6 +99,9 @@ function findKnownReadyPromptIndex(normalized: string): number | null { findAntigravityReadyPromptIndex(normalized), findCursorReadyPromptIndex(normalized) ].filter((index): index is number => index !== null) + if (isAntigravityReadyPromptSnapshot(normalized)) { + indexes.push(normalized.lastIndexOf('antigravity cli')) + } return indexes.length > 0 ? Math.max(...indexes) : null } @@ -135,8 +143,6 @@ function findAntigravityReadyPromptIndex(normalized: string): number | null { let lineStart = headerIndex let modelIndex: number | null = null let promptIndex: number | null = null - - // Why: ready previews can include echoed paste after the header; scan line bounds directly instead of splitting the whole tail. for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { continue @@ -163,7 +169,6 @@ function findAntigravityReadyPromptIndex(normalized: string): number | null { } lineStart = cursor + 1 } - return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null } diff --git a/src/shared/agent-session-option-catalog-antigravity.ts b/src/shared/agent-session-option-catalog-antigravity.ts new file mode 100644 index 00000000000..e6427a1070b --- /dev/null +++ b/src/shared/agent-session-option-catalog-antigravity.ts @@ -0,0 +1,37 @@ +import { hasFlag } from './agent-cli-flag-detection' +import { removeAgentArgOption } from './agent-session-option-agent-args' +import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' + +const ANTIGRAVITY_EFFORT: CatalogOption = { + id: 'effort', + label: 'Reasoning effort', + category: 'thought_level', + kind: { + type: 'select', + choices: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ], + defaultValue: 'high' + }, + apply: { + launchArgs: (value) => ['--effort', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']), + midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } + } +} + +export const ANTIGRAVITY_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, + // Model availability is account-scoped; worker-start accepts the slug reported by `agy models`. + models: [], + modelApply: { + launchArgs: (value) => ['--model', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']), + midSession: { kind: 'agent-picker', command: '/model' } + }, + unknownModelOptions: [ANTIGRAVITY_EFFORT] +} diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index b242b6b27c9..9327a60ca33 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -1,4 +1,5 @@ import type { AgentType } from './agent-status-types' +import { ANTIGRAVITY_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-antigravity' import { CLAUDE_SESSION_OPTION_CATALOG, CODEX_SESSION_OPTION_CATALOG, @@ -29,6 +30,7 @@ export type { export { createClaudeCatalogOptions } const CATALOGS: AgentSessionOptionCatalogMap = { + antigravity: ANTIGRAVITY_SESSION_OPTION_CATALOG, claude: CLAUDE_SESSION_OPTION_CATALOG, codex: CODEX_SESSION_OPTION_CATALOG, gemini: GEMINI_SESSION_OPTION_CATALOG, diff --git a/src/shared/tui-agent-startup-session-options.test.ts b/src/shared/tui-agent-startup-session-options.test.ts index a8c05f4e68b..7bd7f6bd387 100644 --- a/src/shared/tui-agent-startup-session-options.test.ts +++ b/src/shared/tui-agent-startup-session-options.test.ts @@ -54,6 +54,24 @@ describe('tui agent startup session options', () => { expect(plan?.sessionOptions).toEqual({ model: 'custom-codex-model', effort: 'high' }) }) + it('forwards Antigravity worker model and effort without dropping permission defaults', () => { + const plan = buildAgentStartupPlan({ + agent: 'antigravity', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'gemini-3.1-pro-high', effort: 'high' }, + sessionOptionsOverrideAgentArgs: true, + agentArgs: '--dangerously-skip-permissions' + }) + expect(plan?.launchCommand).toBe( + "agy '--dangerously-skip-permissions' '--model' 'gemini-3.1-pro-high' '--effort' 'high'" + ) + expect(plan?.launchConfig.agentCommand).toBe("agy '--dangerously-skip-permissions'") + expect(plan?.sessionOptions).toEqual({ model: 'gemini-3.1-pro-high', effort: 'high' }) + }) + it('inserts worker preferences before an argument terminator', () => { const plan = buildAgentStartupPlan({ agent: 'codex',