mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat: add supervised Antigravity worker support
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Antigravity readiness: what the transcripts show
|
||||
|
||||
`findAntigravityReadyPromptIndex` in `src/main/runtime/terminal-wait-detection.ts` decides whether
|
||||
an Antigravity pane is ready for a prompt. It has been written five times, each version tuned
|
||||
`findAntigravityReadyPromptIndex` in `src/main/runtime/antigravity-terminal-readiness.ts` decides
|
||||
whether an Antigravity pane is ready for a prompt. Its predecessor was written five times, each version tuned
|
||||
against a five-line screen typed from memory into a `.spec.ts` fixture. Three of the first four
|
||||
were found worse than the bug they replaced, and the fifth was reverted.
|
||||
|
||||
@@ -10,10 +10,10 @@ Real transcripts now exist. They were recorded from a live `agy` on macOS with
|
||||
`src/main/runtime/__fixtures__/`. `src/main/runtime/antigravity-readiness-transcripts.test.ts`
|
||||
replays them through the runtime.
|
||||
|
||||
**Headline: on real output the current detector is inverted.** It refuses a genuinely ready screen
|
||||
and accepts a live model picker. The five attempts argued about which extra condition to add; none
|
||||
of them had noticed that the condition they all shared — a line beginning with the model name —
|
||||
never matches a real Antigravity ready screen at all.
|
||||
**Resolved behavior:** readiness now requires the last retained or visible-screen row to be the
|
||||
bare `>` composer. Model and account rows are deliberately ignored. Antigravity waits also take a
|
||||
bounded visible-screen snapshot because cursor-addressed redraws can leave the retained byte tail
|
||||
ending on an older response after the composer has returned.
|
||||
|
||||
## Versions
|
||||
|
||||
@@ -177,7 +177,7 @@ Nothing else in the capture distinguishes the two states. The hint row (`esc to
|
||||
|
||||
Evidence column names the fixture; all quoted text is from the committed transcripts.
|
||||
|
||||
### Attempt 1 — the rule at HEAD
|
||||
### Attempt 1 — the pre-fix rule
|
||||
|
||||
| # | Claim | Verdict | Evidence |
|
||||
| ---- | -------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -233,9 +233,9 @@ expressed against the model/caret positions, which is what 1.2 and 1.3b just inv
|
||||
| X4 | Banner-to-caret distance | ~8 derived lines on a 120x40 PTY; the banner falls outside the 6-line preview window, so only the full retained tail can see it |
|
||||
| X5 | Pane title on the trust screen versus ready | Identical: none |
|
||||
|
||||
## Can attempt six be written?
|
||||
## Attempt six
|
||||
|
||||
Yes — but not as a variation on any of the five. Every one of them refined a predicate over
|
||||
Implemented, but not as a variation on any of the five. Every one of them refined a predicate over
|
||||
`\n`-delimited lines, and that is the layer where the evidence says the information is not.
|
||||
|
||||
What the captures support:
|
||||
|
||||
@@ -475,7 +475,7 @@ describe('agent prompt submission runtime', () => {
|
||||
state: 'done' | 'working'
|
||||
stateStartedAt: number
|
||||
},
|
||||
launchAgent: 'kimi' | 'codex' = 'kimi'
|
||||
launchAgent: 'antigravity' | 'kimi' | 'codex' = 'kimi'
|
||||
): Promise<{
|
||||
runtime: OrcaRuntimeService
|
||||
handle: string
|
||||
@@ -542,6 +542,43 @@ describe('agent prompt submission runtime', () => {
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('settles an Antigravity prompt when PreInvocation starts a new hook turn', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
const hook = { state: 'done' as 'done' | 'working', stateStartedAt: 1_000 }
|
||||
const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'antigravity')
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
if (data === '\r') {
|
||||
vi.setSystemTime(3_000)
|
||||
hook.state = 'working'
|
||||
hook.stateStartedAt = 3_000
|
||||
}
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
|
||||
acceptQueued: true,
|
||||
requestId: 'antigravity-pre-invocation',
|
||||
observationTimeoutMs: 20_000
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(submission).resolves.toMatchObject({
|
||||
prompt: {
|
||||
provider: 'antigravity',
|
||||
observation: 'supported',
|
||||
stages: ['input_accepted', 'turn_started']
|
||||
}
|
||||
})
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Why: same-state pings keep refreshing receivedAt on a turn that started before the prompt;
|
||||
// only the pinned stateStartedAt separates that from a turn this prompt started.
|
||||
it('does not accept a hook row refreshed without a new working turn', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS,
|
||||
type AgentPromptActivity,
|
||||
isAgentPromptStalledError,
|
||||
isTerminalSendSettlementAgent,
|
||||
readAgentPromptWaitText,
|
||||
resolveAgentPromptEffectTimeoutMs,
|
||||
verifyAgentPromptSubmission
|
||||
@@ -292,12 +293,20 @@ describe('agent prompt submission verification', () => {
|
||||
})
|
||||
|
||||
it('gives hook-observed agents the longer effect window', () => {
|
||||
expect(resolveAgentPromptEffectTimeoutMs('antigravity')).toBe(
|
||||
AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS
|
||||
)
|
||||
expect(resolveAgentPromptEffectTimeoutMs('codex')).toBe(AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS)
|
||||
expect(resolveAgentPromptEffectTimeoutMs('kimi')).toBe(AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS)
|
||||
expect(resolveAgentPromptEffectTimeoutMs('claude')).toBe(AGENT_PROMPT_EFFECT_TIMEOUT_MS)
|
||||
expect(resolveAgentPromptEffectTimeoutMs(null)).toBe(AGENT_PROMPT_EFFECT_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('uses Antigravity PreInvocation hooks to settle prompt receipts', () => {
|
||||
expect(isTerminalSendSettlementAgent('antigravity')).toBe(true)
|
||||
expect(isTerminalSendSettlementAgent('gemini')).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes a stalled verdict from a message or a relayed error code', () => {
|
||||
expect(isAgentPromptStalledError(new Error('agent_prompt_stalled'))).toBe(true)
|
||||
expect(isAgentPromptStalledError({ code: 'agent_prompt_stalled' })).toBe(true)
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { TuiAgent } from '../../shared/tui-agent'
|
||||
export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS
|
||||
const AGENT_PROMPT_EFFECT_POLL_MS = 50
|
||||
|
||||
const HOOK_OBSERVED_TURN_START_AGENTS = new Set<TuiAgent>(['codex', 'kimi'])
|
||||
const HOOK_OBSERVED_TURN_START_AGENTS = new Set<TuiAgent>(['antigravity', 'codex', 'kimi'])
|
||||
|
||||
/** The prompt bytes are written before verification, so this only ever means "not observed". */
|
||||
export const AGENT_PROMPT_STALLED_ERROR = 'agent_prompt_stalled'
|
||||
@@ -53,8 +53,8 @@ export function resolveAgentPromptEffectTimeoutMs(agent: TuiAgent | null | undef
|
||||
/** Only these providers expose a turn-start signal Orca can settle a prompt receipt against. */
|
||||
export function isTerminalSendSettlementAgent(
|
||||
agent: TuiAgent | null | undefined
|
||||
): agent is 'claude' | 'codex' {
|
||||
return agent === 'claude' || agent === 'codex'
|
||||
): agent is 'antigravity' | 'claude' | 'codex' {
|
||||
return agent === 'antigravity' || agent === 'claude' || agent === 'codex'
|
||||
}
|
||||
|
||||
export function isAgentPromptStalledError(error: unknown): boolean {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// One pane builder for every suite that replays a captured agent transcript through the runtime.
|
||||
import { vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
|
||||
const TRANSCRIPT_PANE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const TRANSCRIPT_PANE_TAB_ID = 'tab-1'
|
||||
@@ -11,6 +12,7 @@ export type TranscriptPaneOptions = {
|
||||
paneTitle: string
|
||||
foregroundProcess: string | null
|
||||
data: string
|
||||
launchAgent?: TuiAgent
|
||||
/** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */
|
||||
connectionId?: string
|
||||
/** Simulates a PTY controller whose foreground probe never settles. */
|
||||
@@ -71,6 +73,14 @@ export async function createTranscriptPane(
|
||||
}
|
||||
]
|
||||
})
|
||||
if (options.launchAgent) {
|
||||
runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, TRANSCRIPT_PANE_WORKTREE_ID, null, {
|
||||
tabId: TRANSCRIPT_PANE_TAB_ID,
|
||||
leafId: TRANSCRIPT_PANE_LEAF_ID,
|
||||
incarnationId: 'inc-1',
|
||||
agentLaunchAuthority: { launchToken: 'transcript-launch', launchAgent: options.launchAgent }
|
||||
})
|
||||
}
|
||||
// Why the guard: a restore seed is only applied to a never-written record, so the restore
|
||||
// cases must not write an empty chunk first.
|
||||
if (options.data.length > 0) {
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
* Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name
|
||||
* themselves as skipped until someone can reach them.
|
||||
*
|
||||
* Four cases are pinned as KNOWN DEFECT: on real output the shipped detector refuses the ready
|
||||
* screen and accepts the live model picker. Those assert what it does, not what it should.
|
||||
*
|
||||
* Capture protocol: docs/reference/agent-pty-transcript-capture.md
|
||||
* What each transcript decides: docs/reference/antigravity-readiness-evidence.md
|
||||
*/
|
||||
@@ -51,14 +48,8 @@ type TranscriptCase = {
|
||||
/** Capture in docs/reference/antigravity-readiness-evidence.md. */
|
||||
capture: string
|
||||
what: string
|
||||
/** What a correct detector must answer. Not what the shipped one answers. */
|
||||
/** What the detector must answer. */
|
||||
expectReady: boolean
|
||||
/**
|
||||
* Set where the shipped detector contradicts the transcript. The case then runs inverted, so
|
||||
* CI pins the defect instead of going permanently red — and flips to failing the moment
|
||||
* someone fixes it, which is exactly when these expectations need re-reading.
|
||||
*/
|
||||
knownDefect?: string
|
||||
}
|
||||
|
||||
const TRANSCRIPTS: readonly TranscriptCase[] = [
|
||||
@@ -66,15 +57,13 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [
|
||||
name: 'antigravity-ready-api-key-gemini-model',
|
||||
capture: 'B',
|
||||
what: 'ready screen, API-key identity — the account row reads "Gemini API key", not an email',
|
||||
expectReady: true,
|
||||
knownDefect: 'refused: the model row never starts a line, the logo shares it'
|
||||
expectReady: true
|
||||
},
|
||||
{
|
||||
name: 'antigravity-ready-account-info-hidden',
|
||||
capture: 'B',
|
||||
what: 'ready screen with AGY_CLI_HIDE_ACCOUNT_INFO=1 — no account row at all',
|
||||
expectReady: true,
|
||||
knownDefect: 'refused: same line-start defect, and no account row exists to require'
|
||||
expectReady: true
|
||||
},
|
||||
{
|
||||
name: 'antigravity-dialog-trust-workspace',
|
||||
@@ -86,8 +75,7 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [
|
||||
name: 'antigravity-dialog-model-picker',
|
||||
capture: 'C',
|
||||
what: 'model picker owning the screen',
|
||||
expectReady: false,
|
||||
knownDefect: "accepted: the picker's own `Gemini 3.x Flash` rows satisfy the model rule"
|
||||
expectReady: false
|
||||
},
|
||||
{
|
||||
name: 'antigravity-dialog-command-palette',
|
||||
@@ -102,20 +90,18 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [
|
||||
expectReady: false
|
||||
},
|
||||
{
|
||||
// Expected ready because the turn is over and the composer is back on screen. The captured
|
||||
// turn ends in a backend error, which is the only ending this account's key can produce.
|
||||
// The retained bytes contain an error footer, but the rendered capture still shows a spinner;
|
||||
// fail closed until a live provider screen proves that the composer returned.
|
||||
name: 'antigravity-busy-turn-ended',
|
||||
capture: 'E',
|
||||
what: 'the turn has ended and the composer has returned, process still alive',
|
||||
expectReady: true,
|
||||
knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it'
|
||||
what: 'error-ended turn with an ambiguous rendered screen',
|
||||
expectReady: false
|
||||
},
|
||||
{
|
||||
name: 'antigravity-dialog-dismissed',
|
||||
capture: 'D',
|
||||
what: 'the screen immediately after the model picker is dismissed',
|
||||
expectReady: true,
|
||||
knownDefect: 'refused: the banner is not reprinted and no model row starts a line'
|
||||
expectReady: true
|
||||
},
|
||||
// Not captured: this machine's agy has no OAuth session and offers only Gemini models, and
|
||||
// reaching the rest would mean signing the operator out or deleting their config. See
|
||||
@@ -171,6 +157,7 @@ async function readinessVerdict(
|
||||
// capture carries the OSC bytes, so the pane wears whatever the CLI actually set.
|
||||
paneTitle: extractLastOscTitle(transcript) ?? ANTIGRAVITY_COMMAND,
|
||||
foregroundProcess: ANTIGRAVITY_COMMAND,
|
||||
launchAgent: 'antigravity',
|
||||
data: transcript
|
||||
})
|
||||
try {
|
||||
@@ -194,15 +181,7 @@ describe('Antigravity readiness, decided by captured transcripts', () => {
|
||||
const captured = existsSync(path)
|
||||
const label = `capture ${transcript.capture}: ${transcript.what}`
|
||||
|
||||
// A pinned defect asserts what the detector DOES, so CI is honest rather than permanently
|
||||
// red; fixing the detector flips this case to failing, which is when these expectations
|
||||
// need re-reading. The correct answer stays in `expectReady` and in the test's name.
|
||||
const shipped =
|
||||
transcript.knownDefect === undefined ? transcript.expectReady : !transcript.expectReady
|
||||
const verdictName =
|
||||
transcript.knownDefect === undefined
|
||||
? `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}`
|
||||
: `${label} → must be ${transcript.expectReady ? 'ready' : 'not ready'}; KNOWN DEFECT, ${transcript.knownDefect}`
|
||||
const verdictName = `${label}: ${transcript.expectReady ? 'ready' : 'not ready'}`
|
||||
|
||||
it.skipIf(!captured)(
|
||||
verdictName,
|
||||
@@ -216,7 +195,7 @@ describe('Antigravity readiness, decided by captured transcripts', () => {
|
||||
// A silent dialog carries no blocked-signal wording, so the assertion is only that Orca
|
||||
// does not call the pane ready and type a prompt into a dialog that owns the screen.
|
||||
expect({ ready: verdict.ready, outcome: verdict.outcome }).toMatchObject({
|
||||
ready: shipped
|
||||
ready: transcript.expectReady
|
||||
})
|
||||
},
|
||||
READY_TIMEOUT_MS + 10_000
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
detectTerminalWaitBlockedReason,
|
||||
isKnownReadyPromptPreview
|
||||
} from './terminal-wait-detection'
|
||||
|
||||
const HEADER = 'Antigravity CLI 1.2.0'
|
||||
|
||||
describe('Antigravity terminal readiness', () => {
|
||||
it('accepts the idle composer without requiring model or account rows', () => {
|
||||
expect(isKnownReadyPromptPreview(`${HEADER}\nlogo glyphs custom provider\n>`)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'Signing in...',
|
||||
'Loading workspace...',
|
||||
'Initializing MCP servers...',
|
||||
'> Gemini 3.7 Flash (current)',
|
||||
'unexpected startup state'
|
||||
])('fails closed while the last visible row is %j', (row) => {
|
||||
expect(isKnownReadyPromptPreview(`${HEADER}\n${row}`)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a last-row spinner as busy even when an older composer remains in the tail', () => {
|
||||
expect(isKnownReadyPromptPreview(`${HEADER}\n>\nGenerating...`)).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes a stale trust block after the composer appears without answering it', () => {
|
||||
const trust = `${HEADER}\nDo you trust this workspace folder?\n> Yes, I trust this folder`
|
||||
expect(detectTerminalWaitBlockedReason(trust)).toBe('agent-trust-workspace')
|
||||
|
||||
const acceptedByUser = `${trust}\n${HEADER}\n>`
|
||||
expect(detectTerminalWaitBlockedReason(acceptedByUser)).toBeNull()
|
||||
expect(isKnownReadyPromptPreview(acceptedByUser)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { isTerminalWaitWhitespace } from './terminal-wait-tail-window'
|
||||
|
||||
/**
|
||||
* Antigravity paints its chrome with cursor addressing, so model/account rows are not stable
|
||||
* line anchors. The idle composer is the only captured marker that survives every ready screen.
|
||||
*/
|
||||
export function findAntigravityReadyPromptIndex(normalized: string): number | null {
|
||||
return findAntigravityComposerIndex(normalized, true)
|
||||
}
|
||||
|
||||
/** Visible-screen snapshots may omit the banner after a dialog closes. */
|
||||
export function isAntigravityReadyPromptSnapshot(text: string): boolean {
|
||||
return findAntigravityComposerIndex(text.toLowerCase(), false) !== null
|
||||
}
|
||||
|
||||
function findAntigravityComposerIndex(normalized: string, requireHeader: boolean): number | null {
|
||||
const headerIndex = normalized.lastIndexOf('antigravity cli')
|
||||
const contentStart = headerIndex === -1 ? 0 : headerIndex
|
||||
if (requireHeader && headerIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
let offset = 0
|
||||
let composerStart: number | null = null
|
||||
let composerEnd = 0
|
||||
for (const line of normalized.split('\n')) {
|
||||
const lineStart = offset
|
||||
const lineEnd = offset + line.length
|
||||
let trimmedStart = lineStart
|
||||
let trimmedEnd = lineEnd
|
||||
while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) {
|
||||
trimmedStart += 1
|
||||
}
|
||||
while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) {
|
||||
trimmedEnd -= 1
|
||||
}
|
||||
if (trimmedStart >= contentStart && trimmedEnd - trimmedStart === 1) {
|
||||
if (normalized.charCodeAt(trimmedStart) === 62) {
|
||||
composerStart = trimmedStart
|
||||
composerEnd = trimmedEnd
|
||||
}
|
||||
}
|
||||
offset = lineEnd + 1
|
||||
}
|
||||
if (composerStart === null) {
|
||||
return null
|
||||
}
|
||||
const suffix = normalized.slice(composerEnd)
|
||||
const currentScreen = normalized.slice(contentStart)
|
||||
// A trailing caret also appears on trust, sign-in, model, and onboarding menus. Those panes
|
||||
// must remain blocked until the menu is gone; only the latest AGY screen can establish readiness.
|
||||
if (
|
||||
/do you trust|sign in|select a model|collect usage|choose a theme|press enter to continue|\b[12]\.\s/.test(
|
||||
currentScreen
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return suffix.trim().length === 0 || /resume with -c|agy --conversation/i.test(suffix)
|
||||
? composerStart
|
||||
: null
|
||||
}
|
||||
|
||||
export function hasAntigravityTerminalHeader(text: string): boolean {
|
||||
return text.toLowerCase().includes('antigravity cli')
|
||||
}
|
||||
@@ -232,7 +232,9 @@ export class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWithGetTermin
|
||||
opts: { limit?: number } = {}
|
||||
): Promise<RuntimeTerminalRead> {
|
||||
const visibleState = await this.readVisibleTerminalState(ptyId)
|
||||
const projection = visibleState ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit))
|
||||
const projection =
|
||||
visibleState ??
|
||||
(await this.readProviderTerminalTailLines(ptyId, opts.limit, { visibleScreenOnly: true }))
|
||||
if (projection.lines.length === 0) {
|
||||
return { ...read, source: 'screen-unavailable' }
|
||||
}
|
||||
|
||||
@@ -353,8 +353,8 @@ export class OrcaRuntimeWithRuntimeId {
|
||||
getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId),
|
||||
getFirstPartyAgentStatus: (ptyId) =>
|
||||
(ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null,
|
||||
startVisibleReadProbe: (waiter, waiterTimeoutMs) =>
|
||||
this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs)
|
||||
startVisibleReadProbe: (waiter, waiterTimeoutMs, agent) =>
|
||||
this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs, agent)
|
||||
},
|
||||
this.terminalWaiters,
|
||||
this.terminalIdlePolls
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
buildTerminalWaitResult
|
||||
} from './terminal-wait-results'
|
||||
import { createSetupCompletionScanner } from './orchestration/setup-completion-signal'
|
||||
import { isAntigravityReadyPromptSnapshot } from './antigravity-terminal-readiness'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
|
||||
export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWithCreateAgentPromptRenderGate {
|
||||
/** One bounded look at the provider's screen for an adopted PTY whose retained
|
||||
@@ -31,7 +33,11 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith
|
||||
* screen already showing a settled prompt", and the poll above owns every
|
||||
* later transition. A provider screen that is still working when this fires
|
||||
* resolves through the poll, not here. */
|
||||
protected startTuiIdleVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void {
|
||||
protected startTuiIdleVisibleReadProbe(
|
||||
waiter: TerminalWaiter,
|
||||
waiterTimeoutMs: number,
|
||||
agent: TuiAgent | null
|
||||
): void {
|
||||
const settleMarginMs = Math.min(
|
||||
TUI_IDLE_VISIBLE_PROBE_SETTLE_MARGIN_MS,
|
||||
Math.max(1, Math.floor(waiterTimeoutMs / 3))
|
||||
@@ -48,7 +54,7 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith
|
||||
return
|
||||
}
|
||||
void withTimeout(
|
||||
this.readTerminal(waiter.handle, {}, {
|
||||
this.readTerminal(waiter.handle, { screen: true }, {
|
||||
timeoutMs: providerTimeoutMs,
|
||||
retireOnTimeout: true,
|
||||
// Why: the ready banner stays in scrollback for the whole session, so
|
||||
@@ -66,9 +72,13 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith
|
||||
) {
|
||||
return
|
||||
}
|
||||
const snapshotText = projection.tail.join('\n')
|
||||
const snapshotText = [...projection.tail, projection.draft ?? ''].join('\n')
|
||||
const blockedReason = detectTerminalWaitBlockedReason(snapshotText)
|
||||
if (!blockedReason && !isKnownReadyPromptPreview(snapshotText)) {
|
||||
const ready =
|
||||
agent === 'antigravity'
|
||||
? isAntigravityReadyPromptSnapshot(snapshotText)
|
||||
: isKnownReadyPromptPreview(snapshotText)
|
||||
if (!blockedReason && !ready) {
|
||||
return
|
||||
}
|
||||
const result = this.buildTuiIdleProbeResult(waiter.handle, blockedReason)
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,10 +389,7 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => {
|
||||
expect(detectTerminalWaitBlockedReason(waitText)).toBeNull()
|
||||
})
|
||||
|
||||
// Characterization, not a guard: records the wedge this file has not fixed. An Antigravity user on
|
||||
// a non-Gemini model has no 'gemini' line, so readiness never resolves and the wait times out.
|
||||
// Flipping this to true is the goal of the follow-up, and needs a captured transcript first.
|
||||
it('does not yet recognize a non-Gemini ready screen (known wedge)', () => {
|
||||
it('recognizes a ready screen for a non-Gemini model', () => {
|
||||
const waitText = waitTextFor([
|
||||
'Antigravity CLI 1.0.3',
|
||||
'user@example.com (Antigravity Business)',
|
||||
@@ -401,7 +398,7 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => {
|
||||
'>'
|
||||
])
|
||||
|
||||
expect(isKnownReadyPromptPreview(waitText)).toBe(false)
|
||||
expect(isKnownReadyPromptPreview(waitText)).toBe(true)
|
||||
})
|
||||
|
||||
// Ratchet, not a guard of today's code: these pass now only because none of them prints a 'gemini'
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
type AgentStatus
|
||||
} from '../../shared/agent-detection'
|
||||
import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types'
|
||||
import {
|
||||
findAntigravityReadyPromptIndex as findAntigravityComposerIndex,
|
||||
isAntigravityReadyPromptSnapshot
|
||||
} from './antigravity-terminal-readiness'
|
||||
import {
|
||||
isTerminalWaitWhitespace,
|
||||
startOfLastLines,
|
||||
@@ -83,6 +87,7 @@ function findDismissedStartupModalIndex(normalized: string): number | null {
|
||||
const indexes = [
|
||||
findCodexReadyPromptIndex(normalized),
|
||||
findAntigravityReadyPromptIndex(normalized),
|
||||
findAntigravityComposerIndex(normalized),
|
||||
findCursorActivePromptIndex(normalized)
|
||||
].filter((index): index is number => index !== null)
|
||||
return indexes.length > 0 ? Math.max(...indexes) : null
|
||||
@@ -94,6 +99,9 @@ function findKnownReadyPromptIndex(normalized: string): number | null {
|
||||
findAntigravityReadyPromptIndex(normalized),
|
||||
findCursorReadyPromptIndex(normalized)
|
||||
].filter((index): index is number => index !== null)
|
||||
if (isAntigravityReadyPromptSnapshot(normalized)) {
|
||||
indexes.push(normalized.lastIndexOf('antigravity cli'))
|
||||
}
|
||||
return indexes.length > 0 ? Math.max(...indexes) : null
|
||||
}
|
||||
|
||||
@@ -135,8 +143,6 @@ function findAntigravityReadyPromptIndex(normalized: string): number | null {
|
||||
let lineStart = headerIndex
|
||||
let modelIndex: number | null = null
|
||||
let promptIndex: number | null = null
|
||||
|
||||
// Why: ready previews can include echoed paste after the header; scan line bounds directly instead of splitting the whole tail.
|
||||
for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) {
|
||||
if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) {
|
||||
continue
|
||||
@@ -163,7 +169,6 @@ function findAntigravityReadyPromptIndex(normalized: string): number | null {
|
||||
}
|
||||
lineStart = cursor + 1
|
||||
}
|
||||
|
||||
return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { hasFlag } from './agent-cli-flag-detection'
|
||||
import { removeAgentArgOption } from './agent-session-option-agent-args'
|
||||
import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types'
|
||||
|
||||
const ANTIGRAVITY_EFFORT: CatalogOption = {
|
||||
id: 'effort',
|
||||
label: 'Reasoning effort',
|
||||
category: 'thought_level',
|
||||
kind: {
|
||||
type: 'select',
|
||||
choices: [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' }
|
||||
],
|
||||
defaultValue: 'high'
|
||||
},
|
||||
apply: {
|
||||
launchArgs: (value) => ['--effort', String(value)],
|
||||
agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']),
|
||||
removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']),
|
||||
midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` }
|
||||
}
|
||||
}
|
||||
|
||||
export const ANTIGRAVITY_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
|
||||
supportsWorkerLaunchPreferences: true,
|
||||
// Model availability is account-scoped; worker-start accepts the slug reported by `agy models`.
|
||||
models: [],
|
||||
modelApply: {
|
||||
launchArgs: (value) => ['--model', String(value)],
|
||||
agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']),
|
||||
removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']),
|
||||
midSession: { kind: 'agent-picker', command: '/model' }
|
||||
},
|
||||
unknownModelOptions: [ANTIGRAVITY_EFFORT]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentType } from './agent-status-types'
|
||||
import { ANTIGRAVITY_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-antigravity'
|
||||
import {
|
||||
CLAUDE_SESSION_OPTION_CATALOG,
|
||||
CODEX_SESSION_OPTION_CATALOG,
|
||||
@@ -29,6 +30,7 @@ export type {
|
||||
export { createClaudeCatalogOptions }
|
||||
|
||||
const CATALOGS: AgentSessionOptionCatalogMap = {
|
||||
antigravity: ANTIGRAVITY_SESSION_OPTION_CATALOG,
|
||||
claude: CLAUDE_SESSION_OPTION_CATALOG,
|
||||
codex: CODEX_SESSION_OPTION_CATALOG,
|
||||
gemini: GEMINI_SESSION_OPTION_CATALOG,
|
||||
|
||||
@@ -54,6 +54,24 @@ describe('tui agent startup session options', () => {
|
||||
expect(plan?.sessionOptions).toEqual({ model: 'custom-codex-model', effort: 'high' })
|
||||
})
|
||||
|
||||
it('forwards Antigravity worker model and effort without dropping permission defaults', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'antigravity',
|
||||
prompt: '',
|
||||
cmdOverrides: {},
|
||||
platform: 'linux',
|
||||
allowEmptyPromptLaunch: true,
|
||||
sessionOptions: { model: 'gemini-3.1-pro-high', effort: 'high' },
|
||||
sessionOptionsOverrideAgentArgs: true,
|
||||
agentArgs: '--dangerously-skip-permissions'
|
||||
})
|
||||
expect(plan?.launchCommand).toBe(
|
||||
"agy '--dangerously-skip-permissions' '--model' 'gemini-3.1-pro-high' '--effort' 'high'"
|
||||
)
|
||||
expect(plan?.launchConfig.agentCommand).toBe("agy '--dangerously-skip-permissions'")
|
||||
expect(plan?.sessionOptions).toEqual({ model: 'gemini-3.1-pro-high', effort: 'high' })
|
||||
})
|
||||
|
||||
it('inserts worker preferences before an argument terminator', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'codex',
|
||||
|
||||
Reference in New Issue
Block a user