mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
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:
co-authored by
Neil
LielinaH
parent
841d06a969
commit
eb92222e7f
+5
-5
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+102
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user