feat: add supervised Antigravity worker support

This commit is contained in:
beattlekid
2026-09-20 00:19:17 +07:00
parent 5a208ce6fe
commit 0e16953245
20 changed files with 459 additions and 74 deletions
@@ -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:
@@ -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 () => {
@@ -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)
@@ -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<TuiAgent>(['codex', 'kimi'])
const HOOK_OBSERVED_TURN_START_AGENTS = new Set<TuiAgent>(['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 {
@@ -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) {
@@ -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
@@ -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)
})
})
@@ -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')
}
@@ -232,7 +232,9 @@ export class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWithGetTermin
opts: { limit?: number } = {}
): Promise<RuntimeTerminalRead> {
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' }
}
+2 -2
View File
@@ -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
@@ -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)
@@ -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<RuntimeTerminalWait>()
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')
})
})
@@ -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
@@ -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<T>(): { promise: Promise<T>; 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<string, unknown>) => Promise<unknown>
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)
+30 -5
View File
@@ -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)
}
}
}
@@ -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'
+8 -3
View File
@@ -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
}
@@ -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]
}
@@ -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,
@@ -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',