feat: support Antigravity as supervised worker (#21705)

* feat: add supervised Antigravity worker support

* fix: address Antigravity worker review findings

* fix: stabilize Antigravity readiness detection

* fix: allow Antigravity resume footer after readiness

* fix(antigravity): make agy reach worker_done as a supervised worker

Three defects each blocked `orchestration worker-start --agent antigravity
--worktree new-child` at the agent_readiness stage.

1. Readiness never fired. The composer check required the trimmed line to be
   exactly one character, but agy 1.2.7 launches in accept-edits mode and paints
   it into the caret row (`> Accept-edits mode: ...`). Widened narrowly to a bare
   `>` or `> <name> mode:`; matching any `> <text>` would make every menu dialog
   read as ready, since they all prefix their highlighted row the same way.

2. No trust artifact for agy. Added markAntigravityWorkspaceTrusted, writing
   ~/.gemini/antigravity-cli/settings.json under `trustedWorkspaces` — verified
   empirically against agy 1.2.7, and distinct from the Gemini CLI's
   trustedFolders.json, which agy does not consult. Trust is exact-path and not
   inherited by subdirectories, so each child worktree needs its own entry.

3. The orchestration path skipped the preset. Orca has two trust dispatch
   chains: the renderer's preflightAgentTrust and the main-process
   markLocalWorktreeTrusted. worker-start only takes the second, which matched
   cursor/copilot/codex and fell through for antigravity, so the trust write
   never happened while renderer-side tests passed.

Verified live end to end: the dispatch settles `succeeded` with worker_done
carrying the right task and dispatch ids, and the worktree is appended to agy's
settings with sibling keys untouched.

Known gap: remote-agent-trust-presets.ts has no antigravity branch. The SSH
artifact path is unverified, so agy over SSH still stalls at agent_readiness.
Recorded in a comment there rather than guessed at.

* fix(antigravity): wire trust preset through preload safely

* fix: preserve Antigravity readiness across transcript tails

---------

Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: LielinaH <lielinah@gmail.com>
This commit is contained in:
beattlekid
2026-09-21 20:22:08 -07:00
committed by GitHub
co-authored by Neil LielinaH
parent 841d06a969
commit eb92222e7f
31 changed files with 1179 additions and 543 deletions
+5 -5
View File
@@ -55,12 +55,12 @@
"tc:cli": "pnpm run typecheck:cli",
"tc:web": "pnpm run typecheck:web",
"tc": "pnpm run typecheck",
"typecheck:node": "tsc --noEmit -p config/tsconfig.node.json",
"typecheck:cli": "tsc --noEmit -p config/tsconfig.tc.cli.json",
"typecheck:web": "tsc --noEmit -p config/tsconfig.tc.web.json",
"typecheck:e2e": "tsc --noEmit -p config/tsconfig.e2e.json",
"typecheck:node": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.node.json",
"typecheck:cli": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.cli.json",
"typecheck:web": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.web.json",
"typecheck:e2e": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.e2e.json",
"typecheck": "node config/scripts/run-typecheck-projects-in-parallel.mjs",
"typecheck:tsc:node": "tsc --noEmit -p config/tsconfig.node.json --composite false",
"typecheck:tsc:node": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.node.json --composite false",
"typecheck:tsc:cli": "tsc --noEmit -p config/tsconfig.cli.json --composite false",
"typecheck:tsc:web": "tsc --noEmit -p config/tsconfig.web.json --composite false",
"typecheck:tsc": "tsc --noEmit -p config/tsconfig.node.json --composite false && tsc --noEmit -p config/tsconfig.cli.json --composite false && tsc --noEmit -p config/tsconfig.web.json --composite false",
@@ -21,7 +21,7 @@ when an older CLI rejects the flag. A nested worker must respect
## Launch preferences
For a fresh Claude, Codex, or Cursor terminal, `--model` accepts an opaque
For a fresh Claude, Codex, Cursor, or Antigravity terminal, `--model` accepts an opaque
provider model ID. Pass it only when the user named a model; otherwise omit it
so the worker inherits the user's configured agent default. Add `--effort` only
when that model supports it:
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -34,7 +34,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
notes: [
'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.',
'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.',
'--model supports Claude, Codex, and Cursor opaque provider model ids; --effort requires --model. Neither can combine with --terminal.',
'--model supports Claude, Codex, Cursor, and Antigravity opaque provider model ids; --effort requires --model. Neither can combine with --terminal.',
'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.',
'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.',
"How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.",
+75 -2
View File
@@ -38,8 +38,12 @@ vi.mock('node:os', async () => {
}
})
const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted } =
await import('./agent-trust-presets')
const {
markAntigravityWorkspaceTrusted,
markCodexProjectTrusted,
markCopilotFolderTrusted,
markCursorWorkspaceTrusted
} = await import('./agent-trust-presets')
const { runExclusivelyForCodexTrustConfig } =
await import('./codex/codex-trust-config-mutation-queue')
@@ -138,6 +142,75 @@ describe('markCopilotFolderTrusted', () => {
})
})
describe('markAntigravityWorkspaceTrusted', () => {
it('appends the workspace to trustedWorkspaces in ~/.gemini/antigravity-cli/settings.json', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-agy-ws-'))
try {
markAntigravityWorkspaceTrusted(workspace)
const configPath = join(testState.fakeHomeDir, '.gemini', 'antigravity-cli', 'settings.json')
expect(existsSync(configPath)).toBe(true)
const parsed = JSON.parse(readFileSync(configPath, 'utf-8'))
expect(Array.isArray(parsed.trustedWorkspaces)).toBe(true)
expect(parsed.trustedWorkspaces).toHaveLength(1)
expect(parsed.trustedWorkspaces[0]).toBe(realpathSync(workspace))
} finally {
rmSync(workspace, { recursive: true, force: true })
}
})
// Why: the same settings.json also carries model, permissions and toolPermission. A
// clobbering write here would silently reset the user's agy configuration.
it('preserves sibling settings keys and dedups an already-trusted workspace', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-agy-ws-'))
const realpath = realpathSync(workspace)
try {
mkdirSync(join(testState.fakeHomeDir, '.gemini', 'antigravity-cli'), { recursive: true })
writeFileSync(
join(testState.fakeHomeDir, '.gemini', 'antigravity-cli', 'settings.json'),
JSON.stringify({
agentMode: 'accept-edits',
model: 'gemini-3.8-flash',
trustedWorkspaces: [realpath]
})
)
markAntigravityWorkspaceTrusted(workspace)
const parsed = JSON.parse(
readFileSync(
join(testState.fakeHomeDir, '.gemini', 'antigravity-cli', 'settings.json'),
'utf-8'
)
)
expect(parsed.agentMode).toBe('accept-edits')
expect(parsed.model).toBe('gemini-3.8-flash')
expect(parsed.trustedWorkspaces).toHaveLength(1)
} finally {
rmSync(workspace, { recursive: true, force: true })
}
})
// Why: agy's trust is exact-path, not inherited — a parent entry does not cover a child,
// which is what makes the per-worktree preflight necessary at all.
it('adds a child worktree even when its parent is already trusted', () => {
const parent = mkdtempSync(join(tmpdir(), 'orca-agy-parent-'))
const child = join(parent, 'child-worktree')
try {
mkdirSync(child, { recursive: true })
markAntigravityWorkspaceTrusted(parent)
markAntigravityWorkspaceTrusted(child)
const parsed = JSON.parse(
readFileSync(
join(testState.fakeHomeDir, '.gemini', 'antigravity-cli', 'settings.json'),
'utf-8'
)
)
expect(parsed.trustedWorkspaces).toHaveLength(2)
expect(parsed.trustedWorkspaces).toContain(realpathSync(child))
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
})
describe('markCodexProjectTrusted', () => {
// Why (#16441): a hook install/grant holds this file across an awaited
// app-server session; an unqueued write here lands inside its
+57 -4
View File
@@ -6,7 +6,7 @@ import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths'
import { upsertProjectTrustLevel } from './codex/config-toml-trust'
import { runExclusivelyForCodexTrustConfig } from './codex/codex-trust-config-mutation-queue'
export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex'
export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' | 'antigravity'
/**
* Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or
@@ -75,9 +75,9 @@ export function markCopilotFolderTrusted(workspacePath: string): void {
try {
if (existsSync(configPath)) {
const raw = readFileSync(configPath, 'utf-8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
config = parsed as Record<string, unknown>
const parsed: unknown = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
config = Object.fromEntries(Object.entries(parsed))
}
}
} catch {
@@ -101,6 +101,59 @@ export function markCopilotFolderTrusted(workspacePath: string): void {
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`)
}
/**
* The Antigravity CLI (agy) keeps its trusted workspaces in
* ~/.gemini/antigravity-cli/settings.json under `trustedWorkspaces`, a flat
* array of absolute paths in native OS form.
*
* Verified empirically against agy 1.2.7 on Windows: accepting the CLI's
* "Do you trust the contents of this project?" prompt for a freshly created
* worktree appended exactly that worktree's path to this array. Note this is
* NOT ~/.gemini/trustedFolders.json — that file belongs to the Gemini CLI and
* agy does not consult it.
*
* Trust is exact-path and NOT inherited by subdirectories: `C:\Users\<you>`
* was already present in the array, yet launching agy in a descendant still
* raised the prompt and appended the descendant separately. Every new child
* worktree therefore needs its own entry, which is precisely what this
* per-worktree preflight provides.
*
* We append in-place so the sibling keys in the same file (model, permissions,
* toolPermission, agentMode, …) survive untouched.
*/
export function markAntigravityWorkspaceTrusted(workspacePath: string): void {
const absPath = canonicalize(workspacePath)
const configDir = join(homedir(), '.gemini', 'antigravity-cli')
const configPath = join(configDir, 'settings.json')
let config: Record<string, unknown> = {}
try {
if (existsSync(configPath)) {
const raw = readFileSync(configPath, 'utf-8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
config = parsed as Record<string, unknown>
}
}
} catch {
// Why: a corrupted settings.json is the user's to fix — refuse to
// overwrite it from this side-effect path. agy rewrites the file itself
// once the user accepts the trust prompt manually.
return
}
const existing = Array.isArray(config.trustedWorkspaces) ? config.trustedWorkspaces : []
const normalizedExisting = existing.map((entry) =>
typeof entry === 'string' ? canonicalize(entry) : null
)
if (normalizedExisting.includes(absPath)) {
return
}
config.trustedWorkspaces = [...existing.filter((e) => typeof e === 'string'), absPath]
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true })
}
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`)
}
/**
* Codex stores project trust in ~/.codex/config.toml under:
* [projects."<realpath>"]
+3
View File
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import {
type AgentTrustPreset,
markAntigravityWorkspaceTrusted,
markCodexProjectTrusted,
markCopilotFolderTrusted,
markCursorWorkspaceTrusted
@@ -43,6 +44,8 @@ export function registerAgentTrustHandlers(): void {
markCopilotFolderTrusted(args.workspacePath)
} else if (args.preset === 'codex') {
markCodexProjectTrusted(args.workspacePath)
} else if (args.preset === 'antigravity') {
markAntigravityWorkspaceTrusted(args.workspacePath)
}
} catch {
// Best-effort: see Why above. The user can still accept the trust
+6
View File
@@ -27,6 +27,12 @@ export async function markRemoteAgentWorkspaceTrusted(args: {
} else if (args.preset === 'copilot') {
await markRemoteCopilotFolderTrusted(fsProvider, home, workspacePath)
}
// KNOWN GAP: 'antigravity' is deliberately absent. The local preset writes
// ~/.gemini/antigravity-cli/settings.json, and the remote equivalent has not been verified
// against an SSH execution host, so an agy worker launched over SSH still raises its
// first-launch trust prompt and will stall at agent_readiness. Falling through silently
// matches the pre-existing behaviour for agy; it is recorded here rather than left as an
// unexplained omission. Mirror markRemoteCopilotFolderTrusted once it can be tested.
}
async function resolveRemoteHome(connectionId: string): Promise<string | null> {
@@ -0,0 +1,506 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_PROMPT_BRACKETED_PASTE_END,
buildAgentPromptPasteBytes,
getAgentPromptSubmitDelayMs
} from '../../shared/agent-prompt-injection'
import {
AGENT_PROMPT_TEST_WORKTREE_PATH,
createAgentPromptSubmissionRuntime
} from './agent-prompt-submission-runtime-test-fixture'
import { OrcaRuntimeService } from './orca-runtime'
import { makeStore } from './runtime-rpc-worktree-store-fixtures'
const createPromptRuntime = createAgentPromptSubmissionRuntime
vi.mock('../git/worktree', () => ({
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/tmp/worktree-a',
head: 'abc',
branch: 'feature/prompt-verification',
isBare: false,
isMainWorktree: false
}
]),
listWorktreesStrict: vi.fn().mockResolvedValue([
{
path: '/tmp/worktree-a',
head: 'abc',
branch: 'feature/prompt-verification',
isBare: false,
isMainWorktree: false
}
])
}))
describe('agent prompt submission runtime hook and generation cases', () => {
afterEach(() => vi.useRealTimers())
async function createHookOnlyPromptRuntime(
hook: {
state: 'done' | 'working'
stateStartedAt: number
},
launchAgent: 'antigravity' | 'kimi' | 'codex' = 'kimi'
): Promise<{
runtime: OrcaRuntimeService
handle: string
writes: string[]
}> {
let handle = ''
const writes: string[] = []
const runtime = new OrcaRuntimeService(makeStore() as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'prompt-pane',
terminalHandle: handle,
state: hook.state,
prompt: '',
agentType: launchAgent,
connectionId: null,
// Why: every hook ping refreshes receivedAt, including same-state tool pings.
receivedAt: Date.now(),
stateStartedAt: hook.stateStartedAt
}
]
})
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }),
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
handle = (
await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, {
launchAgent
})
).handle
return { runtime, handle, writes }
}
it('accepts a hook working status with no window and no title coverage', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const hook = { state: 'done' as 'done' | 'working', stateStartedAt: 1_000 }
const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook)
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')
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
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 () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const { runtime, handle, writes } = await createHookOnlyPromptRuntime({
state: 'working',
stateStartedAt: 1_000
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('reserves a hook-only turn start for the oldest queued prompt receipt', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const hook = { state: 'working' as const, stateStartedAt: 1_000 }
const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'codex')
const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', {
acceptQueued: true,
requestId: 'hook-queued-first',
observationTimeoutMs: 0
})
await vi.runAllTimersAsync()
const first = await firstPromise
expect(first.prompt?.stages).toEqual(['input_accepted'])
const firstObserved = runtime.observeTerminalAgentPrompt(handle, first.prompt!, 20_000)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }),
write: (_ptyId, data) => {
writes.push(data)
if (data === '\r') {
hook.stateStartedAt = Date.now()
}
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', {
acceptQueued: true,
requestId: 'hook-queued-second',
observationTimeoutMs: 500
})
await vi.runAllTimersAsync()
await expect(firstObserved).resolves.toMatchObject({
stages: ['input_accepted', 'turn_started']
})
const second = await secondPromise
expect(second).toMatchObject({
prompt: { stages: ['input_accepted'] }
})
const secondObserved = runtime.observeTerminalAgentPrompt(handle, second.prompt!, 1_000)
hook.stateStartedAt += 1
await vi.advanceTimersByTimeAsync(50)
await expect(secondObserved).resolves.toMatchObject({
stages: ['input_accepted', 'turn_started']
})
})
it('does not write Enter after the PTY generation changes during settlement', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('terminal_handle_stale')
await vi.advanceTimersByTimeAsync(0)
expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
runtime.getPtyOutputSequence('pty-prompt')
)
await vi.runAllTimersAsync()
await rejected
expect(writes).not.toContain('\r')
})
it('does not reuse explicit permission status across a provider generation reset', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'continued' },
0
)
runtime.onPtyData(
'pty-prompt',
'\x1b]9999;{"state":"waiting","agentType":"aider"}\x07',
Date.now()
)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
0
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
await vi.advanceTimersByTimeAsync(0)
expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true)
controller.abort()
await vi.runAllTimersAsync()
await rejected
})
it('does not reuse output-only permission across a provider generation reset', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
})
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'continued' },
0
)
runtime.onPtyData(
'pty-prompt',
'Permission required\nAllow once\nAllow always\nReject\n',
Date.now()
)
const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt')
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
sequenceAtSpawnStart
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('fails closed when new bytes race a reset after old permission output', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'continued' },
0
)
runtime.onPtyData(
'pty-prompt',
'Permission required\nAllow once\nAllow always\nReject\n',
Date.now()
)
const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt')
runtime.onPtyData('pty-prompt', 'replacement startup output\n', Date.now())
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
sequenceAtSpawnStart
)
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
expect(writes).toEqual([])
})
it('reports permission reached after the first Enter as blocked', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('serializes concurrent prompt submissions within one PTY generation', async () => {
vi.useFakeTimers()
let enterCount = 0
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
enterCount += 1
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
}
})
const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt')
const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt')
await vi.runAllTimersAsync()
await Promise.all([first, second])
const firstPaste = writes.findIndex((data) => data.includes('first prompt'))
const firstEnter = writes.indexOf('\r', firstPaste + 1)
const secondPaste = writes.findIndex((data) => data.includes('second prompt'))
const secondEnter = writes.indexOf('\r', secondPaste + 1)
expect(firstPaste).toBeGreaterThanOrEqual(0)
expect(firstEnter).toBeGreaterThan(firstPaste)
expect(secondPaste).toBeGreaterThan(firstEnter)
expect(secondEnter).toBeGreaterThan(secondPaste)
expect(enterCount).toBe(2)
})
it('reserves a lifecycle transition for only one queued prompt receipt', async () => {
vi.useFakeTimers()
const { runtime, handle } = await createAgentPromptSubmissionRuntime(() => undefined, 'codex')
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', {
acceptQueued: true,
requestId: 'queued-first',
observationTimeoutMs: 0
})
await vi.runAllTimersAsync()
const first = await firstPromise
const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', {
acceptQueued: true,
requestId: 'queued-second',
observationTimeoutMs: 0
})
await vi.runAllTimersAsync()
const second = await secondPromise
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07\x1b]0;Codex working\x07', Date.now())
const firstObserved = runtime.observeTerminalAgentPrompt(handle, first.prompt!, 1_000)
await vi.runAllTimersAsync()
const secondObserved = runtime.observeTerminalAgentPrompt(handle, second.prompt!, 1_000)
await vi.runAllTimersAsync()
await expect(firstObserved).resolves.toMatchObject({
stages: ['input_accepted', 'turn_started']
})
await expect(secondObserved).resolves.toMatchObject({
stages: ['input_accepted']
})
})
it('does not queue a replacement generation behind an obsolete submission', async () => {
vi.useFakeTimers()
let releaseFirst!: () => void
let firstWriteReached!: () => void
const firstWrite = new Promise<void>((resolve) => {
firstWriteReached = resolve
})
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
})
const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', {
beforeWrite: async () => {
firstWriteReached()
await firstGate
}
})
await firstWrite
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
0
)
const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt')
await vi.runAllTimersAsync()
await expect(replacement).resolves.toMatchObject({ accepted: true })
expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true)
releaseFirst()
await expect(first).rejects.toThrow('terminal_handle_stale')
})
it('does not close a partial paste after the PTY generation changes', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
let writeChecks = 0
const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), {
beforeWrite: () => {
writeChecks += 1
if (writeChecks === 2) {
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
runtime.getPtyOutputSequence('pty-prompt')
)
}
}
})
await expect(submission).rejects.toThrow('terminal_handle_stale')
expect(writes).toHaveLength(1)
expect(writes[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_END)
})
it('does not send delayed Enter after cancellation during settlement', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
await vi.advanceTimersByTimeAsync(0)
controller.abort()
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(0)
})
it('does not send another Enter after cancellation during verification', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
// Why compute it: the submit delay now follows the payload size and the executing host,
// so a hardcoded number aborts before the Enter on some lanes.
await vi.advanceTimersByTimeAsync(
getAgentPromptSubmitDelayMs(
process.platform,
Buffer.byteLength(buildAgentPromptPasteBytes('review this'), 'utf8')
)
)
// Why: pin the phase boundary so drift fails here instead of as an empty post-abort array.
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
controller.abort()
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
})
@@ -1,9 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_PROMPT_BRACKETED_PASTE_END,
buildAgentPromptPasteBytes,
getAgentPromptSubmitDelayMs
} from '../../shared/agent-prompt-injection'
import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../shared/agent-prompt-injection'
import {
AGENT_PROMPT_TEST_WORKTREE_PATH,
createAgentPromptSubmissionRuntime
@@ -470,434 +466,4 @@ describe('agent prompt submission runtime', () => {
// Why: hook rows reach the runtime through this provider, which has no window and no OSC title —
// the same path a headless `orca serve` host and a minimized desktop window take.
async function createHookOnlyPromptRuntime(
hook: {
state: 'done' | 'working'
stateStartedAt: number
},
launchAgent: 'kimi' | 'codex' = 'kimi'
): Promise<{
runtime: OrcaRuntimeService
handle: string
writes: string[]
}> {
let handle = ''
const writes: string[] = []
const runtime = new OrcaRuntimeService(makeStore() as never, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey: 'prompt-pane',
terminalHandle: handle,
state: hook.state,
prompt: '',
agentType: launchAgent,
connectionId: null,
// Why: every hook ping refreshes receivedAt, including same-state tool pings.
receivedAt: Date.now(),
stateStartedAt: hook.stateStartedAt
}
]
})
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }),
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
handle = (
await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, {
launchAgent
})
).handle
return { runtime, handle, writes }
}
it('accepts a hook working status with no window and no title coverage', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const hook = { state: 'done' as 'done' | 'working', stateStartedAt: 1_000 }
const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook)
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')
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
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 () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const { runtime, handle, writes } = await createHookOnlyPromptRuntime({
state: 'working',
stateStartedAt: 1_000
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled')
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('reserves a hook-only turn start for the oldest queued prompt receipt', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const hook = { state: 'working' as const, stateStartedAt: 1_000 }
const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'codex')
const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', {
acceptQueued: true,
requestId: 'hook-queued-first',
observationTimeoutMs: 0
})
await vi.runAllTimersAsync()
const first = await firstPromise
expect(first.prompt?.stages).toEqual(['input_accepted'])
const firstObserved = runtime.observeTerminalAgentPrompt(handle, first.prompt!, 20_000)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }),
write: (_ptyId, data) => {
writes.push(data)
if (data === '\r') {
hook.stateStartedAt = Date.now()
}
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', {
acceptQueued: true,
requestId: 'hook-queued-second',
observationTimeoutMs: 500
})
await vi.runAllTimersAsync()
await expect(firstObserved).resolves.toMatchObject({
stages: ['input_accepted', 'turn_started']
})
const second = await secondPromise
expect(second).toMatchObject({
prompt: { stages: ['input_accepted'] }
})
const secondObserved = runtime.observeTerminalAgentPrompt(handle, second.prompt!, 1_000)
hook.stateStartedAt += 1
await vi.advanceTimersByTimeAsync(50)
await expect(secondObserved).resolves.toMatchObject({
stages: ['input_accepted', 'turn_started']
})
})
it('does not write Enter after the PTY generation changes during settlement', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('terminal_handle_stale')
await vi.advanceTimersByTimeAsync(0)
expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
runtime.getPtyOutputSequence('pty-prompt')
)
await vi.runAllTimersAsync()
await rejected
expect(writes).not.toContain('\r')
})
it('does not reuse explicit permission status across a provider generation reset', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'continued' },
0
)
runtime.onPtyData(
'pty-prompt',
'\x1b]9999;{"state":"waiting","agentType":"aider"}\x07',
Date.now()
)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
0
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
await vi.advanceTimersByTimeAsync(0)
expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true)
controller.abort()
await vi.runAllTimersAsync()
await rejected
})
it('does not reuse output-only permission across a provider generation reset', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
})
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'continued' },
0
)
runtime.onPtyData(
'pty-prompt',
'Permission required\nAllow once\nAllow always\nReject\n',
Date.now()
)
const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt')
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
sequenceAtSpawnStart
)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
await vi.runAllTimersAsync()
await expect(submission).resolves.toMatchObject({ accepted: true })
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('fails closed when new bytes race a reset after old permission output', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'continued' },
0
)
runtime.onPtyData(
'pty-prompt',
'Permission required\nAllow once\nAllow always\nReject\n',
Date.now()
)
const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt')
runtime.onPtyData('pty-prompt', 'replacement startup output\n', Date.now())
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
sequenceAtSpawnStart
)
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
'agent_prompt_blocked'
)
expect(writes).toEqual([])
})
it('reports permission reached after the first Enter as blocked', async () => {
vi.useFakeTimers()
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
}
})
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
it('serializes concurrent prompt submissions within one PTY generation', async () => {
vi.useFakeTimers()
let enterCount = 0
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
enterCount += 1
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
}
})
const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt')
const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt')
await vi.runAllTimersAsync()
await Promise.all([first, second])
const firstPaste = writes.findIndex((data) => data.includes('first prompt'))
const firstEnter = writes.indexOf('\r', firstPaste + 1)
const secondPaste = writes.findIndex((data) => data.includes('second prompt'))
const secondEnter = writes.indexOf('\r', secondPaste + 1)
expect(firstPaste).toBeGreaterThanOrEqual(0)
expect(firstEnter).toBeGreaterThan(firstPaste)
expect(secondPaste).toBeGreaterThan(firstEnter)
expect(secondEnter).toBeGreaterThan(secondPaste)
expect(enterCount).toBe(2)
})
it('reserves a lifecycle transition for only one queued prompt receipt', async () => {
vi.useFakeTimers()
const { runtime, handle } = await createAgentPromptSubmissionRuntime(() => undefined, 'codex')
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', {
acceptQueued: true,
requestId: 'queued-first',
observationTimeoutMs: 0
})
await vi.runAllTimersAsync()
const first = await firstPromise
const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', {
acceptQueued: true,
requestId: 'queued-second',
observationTimeoutMs: 0
})
await vi.runAllTimersAsync()
const second = await secondPromise
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07\x1b]0;Codex working\x07', Date.now())
const firstObserved = runtime.observeTerminalAgentPrompt(handle, first.prompt!, 1_000)
await vi.runAllTimersAsync()
const secondObserved = runtime.observeTerminalAgentPrompt(handle, second.prompt!, 1_000)
await vi.runAllTimersAsync()
await expect(firstObserved).resolves.toMatchObject({
stages: ['input_accepted', 'turn_started']
})
await expect(secondObserved).resolves.toMatchObject({
stages: ['input_accepted']
})
})
it('does not queue a replacement generation behind an obsolete submission', async () => {
vi.useFakeTimers()
let releaseFirst!: () => void
let firstWriteReached!: () => void
const firstWrite = new Promise<void>((resolve) => {
firstWriteReached = resolve
})
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
if (data === '\r') {
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
}
})
const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', {
beforeWrite: async () => {
firstWriteReached()
await firstGate
}
})
await firstWrite
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
0
)
const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt')
await vi.runAllTimersAsync()
await expect(replacement).resolves.toMatchObject({ accepted: true })
expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true)
releaseFirst()
await expect(first).rejects.toThrow('terminal_handle_stale')
})
it('does not close a partial paste after the PTY generation changes', async () => {
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
let writeChecks = 0
const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), {
beforeWrite: () => {
writeChecks += 1
if (writeChecks === 2) {
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-prompt',
{ value: 0, generation: 'reset' },
runtime.getPtyOutputSequence('pty-prompt')
)
}
}
})
await expect(submission).rejects.toThrow('terminal_handle_stale')
expect(writes).toHaveLength(1)
expect(writes[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_END)
})
it('does not send delayed Enter after cancellation during settlement', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
await vi.advanceTimersByTimeAsync(0)
controller.abort()
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(0)
})
it('does not send another Enter after cancellation during verification', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
signal: controller.signal
})
const rejected = expect(submission).rejects.toThrow('request_aborted')
// Why compute it: the submit delay now follows the payload size and the executing host,
// so a hardcoded number aborts before the Enter on some lanes.
await vi.advanceTimersByTimeAsync(
getAgentPromptSubmitDelayMs(
process.platform,
Buffer.byteLength(buildAgentPromptPasteBytes('review this'), 'utf8')
)
)
// Why: pin the phase boundary so drift fails here instead of as an empty post-abort array.
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
controller.abort()
await vi.runAllTimersAsync()
await rejected
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
})
})
@@ -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) {
@@ -0,0 +1,74 @@
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('accepts an idle screen after an agent response with a numbered list', () => {
expect(isKnownReadyPromptPreview(`${HEADER}\n1. First result\n2. Second result\n>`)).toBe(true)
})
it('refuses a model picker drawn after an older composer', () => {
expect(isKnownReadyPromptPreview(`${HEADER}\n>\nGemini 3.7 Flash (current)`)).toBe(false)
})
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)
})
/**
* Provenance: transcribed from a live agy 1.2.7 / Gemini 3.8 Flash session observed through
* Orca on 2026-09-21, NOT a byte-exact PTY capture — node-pty could not be rebuilt on this
* Windows host (winpty's GetCommitHash.bat fails under node-gyp), so the recorder in
* docs/reference/agent-pty-transcript-capture.md was unavailable. The account row and the
* workspace path are scrubbed per that doc's privacy table. Replace this with a real
* transcript fixture once a host that can run the recorder is available.
*
* What it pins: agy 1.2.7 launches in accept-edits mode by default and paints that mode into
* the composer row, so a bare-caret-only rule never established readiness and every supervised
* worker timed out at agent_readiness.
*/
it('accepts the composer when agy paints its edit mode into the caret row', () => {
const acceptEdits = [
'Antigravity CLI 1.2.7',
'redacted@example.com (Google AI Ultra)',
'Gemini 3.8 Flash (High)',
'~/workspace/example',
'> Accept-edits mode: file edits auto-approved (shift+tab to cycle)'
].join('\n')
expect(isKnownReadyPromptPreview(acceptEdits)).toBe(true)
})
it('still refuses a menu dialog whose highlighted row merely starts with a caret', () => {
// Guards the widened composer rule: every dialog prefixes its selection with '> '.
expect(isKnownReadyPromptPreview(`${HEADER}\n> Yes, I trust this folder`)).toBe(false)
expect(isKnownReadyPromptPreview(`${HEADER}\n> Gemini 3.8 Flash`)).toBe(false)
expect(isKnownReadyPromptPreview(`${HEADER}\n> /model Set a model`)).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,117 @@
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
}
/**
* The composer is a bare `>` on the captured 3.7 Flash screens, but agy 1.2.7 paints the active
* edit mode into that same line (`> Accept-edits mode: file edits auto-approved (shift+tab to
* cycle)`), so a bare-caret-only rule never establishes readiness on a default 3.8 Flash launch.
*
* Why this stays narrow: every menu dialog also prefixes its highlighted row with `> ` —
* `> Yes, I trust this folder` (trust), `> Gemini 3.8 Flash` (model picker). Matching any
* `> <text>` would make all of them read as ready, which is the bug the bare-caret rule was
* guarding against. Only a caret alone, or a caret followed by `<name> mode:`, counts.
*/
function isComposerLine(value: string): boolean {
return value === '>' || /^>\s+[a-z][a-z-]*\s+mode:\s/i.test(value)
}
function isModelRow(line: string): boolean {
const trimmed = line.trim()
if (
!trimmed ||
trimmed === '>' ||
trimmed.includes('antigravity cli') ||
/^resume with -c|^agy --conversation=/i.test(trimmed)
) {
return false
}
if (
trimmed.includes('@') ||
trimmed.includes('antigravity business') ||
trimmed.includes('for shortcuts') ||
trimmed.startsWith('~/') ||
trimmed.startsWith('/') ||
/^[a-z]:\\/i.test(trimmed)
) {
return false
}
return true
}
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 workspaceBeforeComposer = false
let workspaceAfterComposer = false
let modelAfterComposer = false
while (offset <= normalized.length) {
const lineStart = offset
const newlineIndex = normalized.indexOf('\n', lineStart)
const lineEnd = newlineIndex === -1 ? normalized.length : newlineIndex
let trimmedStart = lineStart
let trimmedEnd = lineEnd
while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) {
trimmedStart += 1
}
while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) {
trimmedEnd -= 1
}
const lineValue = normalized.slice(trimmedStart, trimmedEnd)
if (trimmedStart >= contentStart && isComposerLine(lineValue)) {
composerStart = trimmedStart
modelAfterComposer = false
} else if (trimmedStart >= contentStart) {
const value = lineValue
const isWorkspace =
value.startsWith('~/') || value.startsWith('/') || /^[a-z]:\\/i.test(value)
if (composerStart === null) {
workspaceBeforeComposer ||= isWorkspace
} else {
workspaceAfterComposer ||= isWorkspace
modelAfterComposer ||= isModelRow(value)
}
}
offset = lineEnd + 1
if (newlineIndex === -1) {
break
}
}
if (composerStart === null) {
return null
}
// 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/.test(
normalized.slice(contentStart)
)
) {
return null
}
if (!workspaceBeforeComposer) {
return modelAfterComposer ? null : composerStart
}
return modelAfterComposer && !workspaceAfterComposer ? null : composerStart
}
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
@@ -354,8 +354,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, agent === 'antigravity' ? { screen: true } : {}, {
timeoutMs: providerTimeoutMs,
retireOnTimeout: true,
// Why: the ready banner stays in scrollback for the whole session, so
@@ -66,9 +72,16 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith
) {
return
}
const snapshotText = projection.tail.join('\n')
const snapshotText =
agent === 'antigravity'
? [...projection.tail, projection.draft ?? ''].join('\n')
: projection.tail.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)
}
}
}
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../shared/repo-types'
const mocks = vi.hoisted(() => ({
markAntigravityWorkspaceTrusted: vi.fn(),
markCodexProjectTrusted: vi.fn(),
markCopilotFolderTrusted: vi.fn(),
markCursorWorkspaceTrusted: vi.fn(),
@@ -10,6 +11,7 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('../agent-trust-presets', () => ({
markAntigravityWorkspaceTrusted: mocks.markAntigravityWorkspaceTrusted,
markCodexProjectTrusted: mocks.markCodexProjectTrusted,
markCopilotFolderTrusted: mocks.markCopilotFolderTrusted,
markCursorWorkspaceTrusted: mocks.markCursorWorkspaceTrusted
@@ -159,4 +161,29 @@ describe('markLocalWorktreeTrusted', () => {
await expect(markLocalWorktreeTrusted('codex', '/workspace/app')).resolves.toBeUndefined()
})
/**
* Why this test exists: Orca has two trust dispatch chains — the renderer's
* preflightAgentTrust (via the agentTrust:markTrusted IPC) and this main-process
* one, which is the only path `orchestration worker-start` takes. Adding
* `preflightTrust: 'antigravity'` to TUI_AGENT_CONFIG clears the `!preset` guard
* here but matched none of the cursor/copilot/codex branches, so every supervised
* agy worker still failed at agent_readiness with 'agent-trust-workspace' while
* the renderer-side unit tests passed. Verified live: with the branch added, the
* worktree is appended to ~/.gemini/antigravity-cli/settings.json and the dispatch
* reaches worker_done.
*/
it('writes the agy workspace trust artifact on the orchestration path', async () => {
await markLocalWorktreeTrusted('antigravity', '/workspace/app')
expect(mocks.markAntigravityWorkspaceTrusted).toHaveBeenCalledWith('/workspace/app')
})
it('contains a throwing agy trust write', async () => {
mocks.markAntigravityWorkspaceTrusted.mockImplementationOnce(() => {
throw new Error('write failed')
})
await expect(markLocalWorktreeTrusted('antigravity', '/workspace/app')).resolves.toBeUndefined()
})
})
@@ -11,6 +11,7 @@ import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selectio
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup'
import {
markAntigravityWorkspaceTrusted,
markCodexProjectTrusted,
markCopilotFolderTrusted,
markCursorWorkspaceTrusted
@@ -200,6 +201,8 @@ export async function markLocalWorktreeTrusted(
} else if (preset === 'codex') {
// Why: the Codex write queues behind any in-flight hook grant, so the agent must not launch until it lands.
await markCodexProjectTrusted(workspacePath)
} else if (preset === 'antigravity') {
markAntigravityWorkspaceTrusted(workspacePath)
}
} catch {
// Best-effort: the user can still accept the agent trust prompt manually.
+2 -67
View File
@@ -5,11 +5,8 @@ import {
type AgentStatus
} from '../../shared/agent-detection'
import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types'
import {
isTerminalWaitWhitespace,
startOfLastLines,
startOfLastNonBlankLines
} from './terminal-wait-tail-window'
import { findAntigravityReadyPromptIndex } from './antigravity-terminal-readiness'
import { startOfLastLines, startOfLastNonBlankLines } from './terminal-wait-tail-window'
const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i
const CLAUDE_IDLE_PREFIX = '\u2733'
@@ -50,15 +47,6 @@ export function isKnownReadyPromptPreview(preview: string): boolean {
if (readyIndex === null) {
return false
}
const antigravityReadyIndex = findAntigravityReadyPromptIndex(normalized)
const modelPickerIndex = findActiveAntigravityModelPickerIndex(normalized)
if (
antigravityReadyIndex !== null &&
modelPickerIndex !== null &&
modelPickerIndex > antigravityReadyIndex
) {
return false
}
const blockedSignal = findTerminalWaitBlockedSignal(normalized)
if (blockedSignal !== null && blockedSignal.index > readyIndex) {
return false
@@ -136,59 +124,6 @@ function findCodexReadyPromptIndex(normalized: string): number | null {
return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null
}
function findAntigravityReadyPromptIndex(normalized: string): number | null {
const headerIndex = normalized.lastIndexOf('antigravity cli')
if (headerIndex === -1) {
return null
}
let lineStart = headerIndex
let promptIndex: number | null = null
let previousNonEmpty: { start: number; end: number } | null = null
// Why: a column-0 caret is ready; an indented `>` under `> draft` is a wrap, not an empty box.
for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) {
if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) {
continue
}
let trimmedStart = lineStart
let trimmedEnd = cursor
while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) {
trimmedStart += 1
}
while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) {
trimmedEnd -= 1
}
if (lineStart > headerIndex && trimmedStart < trimmedEnd) {
if (
trimmedEnd - trimmedStart === 1 &&
normalized.charCodeAt(trimmedStart) === 62 &&
trimmedStart === lineStart &&
!(
previousNonEmpty !== null &&
normalized.charCodeAt(previousNonEmpty.start) === 62 &&
previousNonEmpty.end - previousNonEmpty.start > 1
)
) {
promptIndex = trimmedStart
}
previousNonEmpty = { start: trimmedStart, end: trimmedEnd }
}
lineStart = cursor + 1
}
return promptIndex
}
// Why: the model picker keeps the ready composer's bare caret in scrollback while its selected row
// is labeled, so that stale caret must not satisfy tui-idle until the picker emits its exit marker.
function findActiveAntigravityModelPickerIndex(normalized: string): number | null {
const pickerIndex = normalized.lastIndexOf('switch model')
if (pickerIndex === -1 || normalized.lastIndexOf('antigravity cli') > pickerIndex) {
return null
}
return normalized.lastIndexOf('exited /model command') > pickerIndex ? null : pickerIndex
}
export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE =
/update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i
+1 -1
View File
@@ -50,7 +50,7 @@ export type AgentStatusApi = {
export type AgentTrustApi = {
markTrusted: (args: {
preset: 'cursor' | 'copilot' | 'codex'
preset: 'cursor' | 'copilot' | 'codex' | 'antigravity'
workspacePath: string
connectionId?: string
}) => Promise<void>
+1 -1
View File
@@ -3,7 +3,7 @@ import type { PreloadApi } from '../api-types'
export const agentTrustApi = {
markTrusted: (args: {
preset: 'cursor' | 'copilot' | 'codex'
preset: 'cursor' | 'copilot' | 'codex' | 'antigravity'
workspacePath: string
connectionId?: string
}): Promise<void> => ipcRenderer.invoke('agentTrust:markTrusted', args)
@@ -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,
@@ -30,6 +31,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,
+6 -1
View File
@@ -37,7 +37,7 @@ export type TuiAgentConfig = {
/** Startup env var that seeds the input without submitting, for agents with no `--prefill`-style flag (e.g. pi); avoids the paste-after-ready race. */
draftPromptEnvVar?: string
/** Pre-write a trust artifact so the agent's first-launch "trust this folder?" menu doesn't consume the bracketed paste (see agent-trust-presets.ts). */
preflightTrust?: 'cursor' | 'copilot' | 'codex'
preflightTrust?: 'cursor' | 'copilot' | 'codex' | 'antigravity'
/** Agent-specific signal that the composer is ready for paste, stronger than the default quiet-render window. */
draftPasteReadySignal?: DraftPasteReadySignal
/** Hard deadline for the agent's composer readiness signal. */
@@ -178,6 +178,11 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
antigravity: {
detectCmd: 'agy',
promptInjectionMode: 'flag-prompt-interactive',
// Why: agy's first-launch trust menu consumes the bracketed paste, and its trust is
// exact-path rather than inherited, so every freshly created child worktree raises it
// again — a supervised worker would otherwise always fail at agent_readiness
// (agent-trust-presets.ts).
preflightTrust: 'antigravity',
// Why: agy 1.2.x collapses long paste as "↑ N more lines" and expands it over seconds; byte
// ingest alone (~500 ms on macOS) finishes before the composer is submit-ready.
submitLineSettleMsPerLine: 45
@@ -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',