mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix: stabilize Antigravity readiness detection
This commit is contained in:
@@ -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,470 +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: '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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,24 @@ export function isAntigravityReadyPromptSnapshot(text: string): boolean {
|
||||
return findAntigravityComposerIndex(text.toLowerCase(), false) !== null
|
||||
}
|
||||
|
||||
function isModelRow(line: string): boolean {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed === '>' || trimmed.includes('antigravity cli')) {
|
||||
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
|
||||
@@ -22,10 +40,13 @@ function findAntigravityComposerIndex(normalized: string, requireHeader: boolean
|
||||
|
||||
let offset = 0
|
||||
let composerStart: number | null = null
|
||||
let composerEnd = 0
|
||||
for (const line of normalized.split('\n')) {
|
||||
let workspaceBeforeComposer = false
|
||||
let modelBeforeComposer = false
|
||||
let modelAfterComposer = false
|
||||
while (offset <= normalized.length) {
|
||||
const lineStart = offset
|
||||
const lineEnd = offset + line.length
|
||||
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)) {
|
||||
@@ -37,15 +58,27 @@ function findAntigravityComposerIndex(normalized: string, requireHeader: boolean
|
||||
if (trimmedStart >= contentStart && trimmedEnd - trimmedStart === 1) {
|
||||
if (normalized.charCodeAt(trimmedStart) === 62) {
|
||||
composerStart = trimmedStart
|
||||
composerEnd = trimmedEnd
|
||||
modelAfterComposer = false
|
||||
}
|
||||
} else if (trimmedStart >= contentStart) {
|
||||
const value = normalized.slice(trimmedStart, trimmedEnd)
|
||||
const isWorkspace =
|
||||
value.startsWith('~/') || value.startsWith('/') || /^[a-z]:\\/i.test(value)
|
||||
if (composerStart === null) {
|
||||
workspaceBeforeComposer ||= isWorkspace
|
||||
modelBeforeComposer ||= isModelRow(value)
|
||||
} else {
|
||||
modelAfterComposer ||= isModelRow(value)
|
||||
}
|
||||
}
|
||||
offset = lineEnd + 1
|
||||
if (newlineIndex === -1) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (composerStart === null) {
|
||||
return null
|
||||
}
|
||||
const suffix = normalized.slice(composerEnd)
|
||||
// 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 (
|
||||
@@ -55,9 +88,10 @@ function findAntigravityComposerIndex(normalized: string, requireHeader: boolean
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return suffix.trim().length === 0 || /resume with -c|agy --conversation/i.test(suffix)
|
||||
? composerStart
|
||||
: null
|
||||
if (!workspaceBeforeComposer) {
|
||||
return modelAfterComposer ? null : composerStart
|
||||
}
|
||||
return modelBeforeComposer || modelAfterComposer ? composerStart : null
|
||||
}
|
||||
|
||||
export function hasAntigravityTerminalHeader(text: string): boolean {
|
||||
|
||||
@@ -54,7 +54,7 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith
|
||||
return
|
||||
}
|
||||
void withTimeout(
|
||||
this.readTerminal(waiter.handle, { screen: true }, {
|
||||
this.readTerminal(waiter.handle, agent === 'antigravity' ? { screen: true } : {}, {
|
||||
timeoutMs: providerTimeoutMs,
|
||||
retireOnTimeout: true,
|
||||
// Why: the ready banner stays in scrollback for the whole session, so
|
||||
@@ -72,7 +72,10 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith
|
||||
) {
|
||||
return
|
||||
}
|
||||
const snapshotText = [...projection.tail, projection.draft ?? ''].join('\n')
|
||||
const snapshotText =
|
||||
agent === 'antigravity'
|
||||
? [...projection.tail, projection.draft ?? ''].join('\n')
|
||||
: projection.tail.join('\n')
|
||||
const blockedReason = detectTerminalWaitBlockedReason(snapshotText)
|
||||
const ready =
|
||||
agent === 'antigravity'
|
||||
|
||||
Reference in New Issue
Block a user