fix(antigravity): wait for host-confirmed composer before continuation paste

This commit is contained in:
Neil
2026-09-19 04:21:57 -07:00
parent 7ea751027b
commit 32a37f02b4
7 changed files with 422 additions and 7 deletions
@@ -349,3 +349,25 @@ The honest summary is that this is a screen-shaped problem being solved with lin
rule over the derived tail can be made much better than what ships today, but the durable fix is to
ask the terminal emulator what the bottom row of the screen actually is, rather than inferring it
from a byte stream that was written with cursor addressing.
## 2026-09-19: renderer continuation delivery
The renderer's generated-prompt path still used a separate bracketed-paste/quiet
observer. A wrapper that waits 12 seconds before executing installed agy 1.2.7
reproduced #18088: the shell echoed the continuation before agy started, and the
agent opened with an empty composer and no matching saved first turn.
Antigravity draft delivery now resolves the pane on its owning host and uses
`terminal.wait` with `tui-idle`, reusing the recorded-screen classifier above.
It allows 60 seconds for readiness and never falls back to process presence.
The same live PTY and host-published Antigravity identity are checked before
writing. Paired hosts must advertise `terminal.antigravity-visible-readiness.v1`;
older hosts leave the context unsent and use the existing failure notice.
With the delayed wrapper, no context was visible at 10.4 seconds, before agent
startup. Once ready, the saved first user turn exactly matched the generated
prompt. A 36,868-character continuation also matched byte-for-byte (SHA-256),
without transcript truncation. The final identity-checked implementation passed
another delayed launch with an exact 920-character first-turn match. Generation
still failed with the existing 401 authentication error; this verifies delivery,
not a successful continuation task. Real Windows/WSL/SSH runs remain unverified.
+25 -7
View File
@@ -1,3 +1,4 @@
import { waitForAntigravityDraftReady } from './antigravity-draft-readiness'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { TuiAgent } from '../../../shared/tui-agent'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
@@ -102,6 +103,7 @@ export async function pasteDraftWhenAgentReady(args: {
tabId,
spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS,
readinessTimeoutMs,
agent,
readySignal,
settings
})
@@ -112,6 +114,10 @@ export async function pasteDraftWhenAgentReady(args: {
const { ptyId } = readiness
if (!readiness.ready) {
if (agent === 'antigravity') {
onTimeout?.()
return false
}
// Why: fast-starting TUIs can emit the paste-ready escape sequence before
// this sidecar subscription attaches. If process/title inspection says the
// launched agent owns the PTY, fall back to a best-effort paste instead of
@@ -154,8 +160,15 @@ export async function pasteDraftToAgentPtyWhenReady(args: {
const settings = getSettingsForAgentTabRuntimeOwner(tabId)
const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste'
const budget = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs)
const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings)
const ready =
agent === 'antigravity'
? await waitForAntigravityDraftReady(tabId, ptyId, budget, settings)
: await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings)
if (!ready) {
if (agent === 'antigravity') {
onTimeout?.()
return false
}
const fallbackReady = agentConfig
? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000, settings)
: false
@@ -240,6 +253,7 @@ function waitForAgentDraftInputReadyOnTab(args: {
tabId: string
spawnTimeoutMs: number
readinessTimeoutMs: number
agent?: TuiAgent
readySignal: Parameters<typeof waitForAgentDraftInputReady>[2]
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
}): Promise<{ ptyId: string; ready: boolean } | null> {
@@ -271,12 +285,16 @@ function waitForAgentDraftInputReadyOnTab(args: {
unsubscribeStore?.()
// Why: Zustand subscribers run inside updateTabPtyId. Registering the
// sidecar here precedes the transport's immediate pre-handler drain.
void waitForAgentDraftInputReady(
ptyId,
args.readinessTimeoutMs,
args.readySignal,
args.settings
).then((ready) => finish({ ptyId, ready }))
const readiness =
args.agent === 'antigravity'
? waitForAntigravityDraftReady(args.tabId, ptyId, args.readinessTimeoutMs, args.settings)
: waitForAgentDraftInputReady(
ptyId,
args.readinessTimeoutMs,
args.readySignal,
args.settings
)
void readiness.then((ready) => finish({ ptyId, ready }))
}
const bindFromState = (state: ReturnType<typeof useAppStore.getState>): void => {
const ptyId = state.ptyIdsByTabId[args.tabId]?.[0]
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { pasteDraftToAgentPtyWhenReady, pasteDraftWhenAgentReady } from './agent-paste-draft'
const mocks = vi.hoisted(() => ({
hostReady: vi.fn(),
shellReady: vi.fn(),
inspect: vi.fn(),
send: vi.fn(),
processReady: vi.fn()
}))
vi.mock('./antigravity-draft-readiness', () => ({ waitForAntigravityDraftReady: mocks.hostReady }))
vi.mock('./agent-draft-readiness', () => ({ waitForAgentDraftInputReady: mocks.shellReady }))
vi.mock('./agent-ready-wait', () => ({ waitForAgentReady: mocks.processReady }))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({ settings: {}, ptyIdsByTabId: { tab: ['pty'] }, tabsByWorktree: {} }),
subscribe: () => () => {}
}
}))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
sendRuntimePtyInputVerified: mocks.send,
inspectRuntimeTerminalProcess: mocks.inspect
}))
describe('Antigravity continuation delivery', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('window', { setTimeout, clearTimeout })
mocks.hostReady.mockReset()
mocks.shellReady
.mockReset()
.mockImplementation(
() => new Promise<boolean>((resolve) => setTimeout(() => resolve(true), 1500))
)
mocks.processReady.mockReset().mockResolvedValue({ ready: true })
mocks.inspect
.mockReset()
.mockResolvedValue({ foregroundProcess: 'agy', hasChildProcesses: true })
mocks.send.mockReset().mockResolvedValue(true)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('leaves context unwritten through a quiet shell until the host confirms the composer', async () => {
mocks.hostReady.mockImplementation(
() => new Promise<boolean>((resolve) => setTimeout(() => resolve(true), 12000))
)
const waiting = pasteDraftWhenAgentReady({
tabId: 'tab',
agent: 'antigravity',
content: 'continue context',
submit: true,
forcePaste: true
})
await vi.advanceTimersByTimeAsync(10000)
expect(mocks.send).not.toHaveBeenCalled()
expect(mocks.shellReady).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(2100)
await expect(waiting).resolves.toBe(true)
expect(mocks.hostReady).toHaveBeenCalledWith('tab', 'pty', 60000, {})
expect(mocks.send).toHaveBeenLastCalledWith({}, 'pty', '\r')
})
it.each(['tab', 'pty'] as const)('rejects process-only fallback on the %s path', async (path) => {
mocks.hostReady.mockResolvedValue(false)
const onTimeout = vi.fn()
const args = {
tabId: 'tab',
ptyId: 'pty',
agent: 'antigravity' as const,
content: 'preserve context',
submit: true,
forcePaste: true,
onTimeout
}
const waiting =
path === 'tab' ? pasteDraftWhenAgentReady(args) : pasteDraftToAgentPtyWhenReady(args)
await expect(waiting).resolves.toBe(false)
expect(onTimeout).toHaveBeenCalledOnce()
expect(mocks.inspect).not.toHaveBeenCalled()
expect(mocks.processReady).not.toHaveBeenCalled()
expect(mocks.send).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ANTIGRAVITY_VISIBLE_READINESS_RUNTIME_CAPABILITY as capability } from '../../../shared/protocol-version'
import type * as RuntimeRpcClient from '@/runtime/runtime-rpc-client'
import { waitForAntigravityDraftReady } from './antigravity-draft-readiness'
const leaf = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const mocks = vi.hoisted(() => {
const state: {
ptyIdsByTabId: Record<string, string[]>
terminalLayoutsByTabId: Record<string, { ptyIdsByLeafId: Record<string, string> }>
} = { ptyIdsByTabId: { tab: ['pty'] }, terminalLayoutsByTabId: {} }
return {
call: vi.fn(),
capabilities: vi.fn(),
remoteCapability: vi.fn(),
state
}
})
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
vi.mock('@/runtime/local-runtime-capabilities', () => ({
ensureLocalRuntimeCapabilities: mocks.capabilities
}))
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => {
const actual = await importOriginal<typeof RuntimeRpcClient>()
return {
...actual,
callRuntimeRpc: mocks.call,
runtimeEnvironmentSupportsCapability: mocks.remoteCapability,
getActiveRuntimeTarget: (settings?: { activeRuntimeEnvironmentId?: string }) =>
settings?.activeRuntimeEnvironmentId
? { kind: 'environment', environmentId: settings.activeRuntimeEnvironmentId }
: { kind: 'local' }
}
})
const ready = {
handle: 'term_test',
condition: 'tui-idle',
satisfied: true,
status: 'running',
exitCode: null
}
describe('Antigravity host-owned draft readiness', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('window', { setTimeout, clearTimeout })
mocks.state.ptyIdsByTabId = { tab: ['pty'] }
mocks.state.terminalLayoutsByTabId = { tab: { ptyIdsByLeafId: { [leaf]: 'pty' } } }
mocks.capabilities.mockReset().mockResolvedValue([capability])
mocks.remoteCapability.mockReset().mockResolvedValue(true)
mocks.call.mockReset().mockImplementation(async (_target, method) =>
method === 'terminal.show'
? {
terminal: {
handle: 'term_test',
ptyId: 'pty',
connected: true,
writable: true,
agentIdentity: 'antigravity'
}
}
: method === 'terminal.resolvePane'
? { terminal: { handle: 'term_test', tabId: 'tab', leafId: leaf, ptyId: 'pty' } }
: { wait: ready }
)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('waits for the exact pane on its owning host', async () => {
await expect(waitForAntigravityDraftReady('tab', 'pty', 60000, null)).resolves.toBe(true)
expect(mocks.call).toHaveBeenCalledWith(
{ kind: 'local' },
'terminal.resolvePane',
{ paneKey: `tab:${leaf}` },
expect.anything()
)
expect(mocks.call).toHaveBeenCalledWith(
{ kind: 'local' },
'terminal.wait',
{ terminal: 'term_test', for: 'tui-idle', timeoutMs: 60000 },
expect.anything()
)
})
it('routes a paired PTY to its owner even when another host is selected', async () => {
const pty = 'remote:host-b@@term_test'
mocks.state.ptyIdsByTabId.tab = [pty]
mocks.state.terminalLayoutsByTabId.tab = { ptyIdsByLeafId: { [leaf]: pty } }
await expect(
waitForAntigravityDraftReady('tab', pty, 60000, { activeRuntimeEnvironmentId: 'host-a' })
).resolves.toBe(true)
expect(mocks.remoteCapability).toHaveBeenCalledWith('host-b', capability, 5000)
expect(mocks.call).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'host-b' },
'terminal.wait',
{ terminal: 'term_test', for: 'tui-idle', timeoutMs: 60000 },
expect.anything()
)
expect(mocks.capabilities).not.toHaveBeenCalled()
})
it('does not paste on old local or paired hosts', async () => {
mocks.capabilities.mockResolvedValue([])
mocks.remoteCapability.mockResolvedValue(false)
await expect(waitForAntigravityDraftReady('tab', 'pty', 60000, null)).resolves.toBe(false)
await expect(
waitForAntigravityDraftReady('tab', 'pty', 60000, { activeRuntimeEnvironmentId: 'host-b' })
).resolves.toBe(false)
expect(mocks.remoteCapability).toHaveBeenCalledWith('host-b', capability, 5000)
expect(mocks.call).not.toHaveBeenCalled()
})
it.each([
{ satisfied: false },
{ status: 'exited' },
{ blockedReason: 'agent-trust-workspace' },
{ handle: 'term_other' }
])('rejects an unusable wait verdict: %j', async (change) => {
mocks.call.mockImplementation(async (_target, method) =>
method === 'terminal.resolvePane'
? { terminal: { handle: 'term_test', ptyId: 'pty' } }
: { wait: { ...ready, ...change } }
)
await expect(waitForAntigravityDraftReady('tab', 'pty', 60000, null)).resolves.toBe(false)
})
it('rejects a different agent that took over the same PTY while waiting', async () => {
mocks.call.mockImplementation(async (_target, method) =>
method === 'terminal.wait'
? { wait: ready }
: {
terminal: {
handle: 'term_test',
ptyId: 'pty',
connected: true,
writable: true,
agentIdentity: 'codex'
}
}
)
await expect(waitForAntigravityDraftReady('tab', 'pty', 60000, null)).resolves.toBe(false)
})
it('rejects loss of host contact', async () => {
mocks.call.mockRejectedValue(new Error('transport disconnected'))
await expect(waitForAntigravityDraftReady('tab', 'pty', 60000, null)).resolves.toBe(false)
expect(mocks.call).toHaveBeenCalledTimes(1)
})
it('rejects a ready answer after the pane was replaced', async () => {
mocks.call.mockImplementation(async (_target, method) => {
if (method === 'terminal.resolvePane') {
return { terminal: { handle: 'term_test', ptyId: 'pty' } }
}
mocks.state.ptyIdsByTabId.tab = ['replacement']
return { wait: ready }
})
await expect(waitForAntigravityDraftReady('tab', 'pty', 60000, null)).resolves.toBe(false)
})
it('waits for publication of a newly bound pane without trusting the shell', async () => {
mocks.call.mockRejectedValueOnce(new Error('terminal_not_found'))
const waiting = waitForAntigravityDraftReady('tab', 'pty', 60000, null)
await vi.advanceTimersByTimeAsync(100)
await expect(waiting).resolves.toBe(true)
expect(mocks.call).toHaveBeenCalledTimes(4)
})
})
@@ -0,0 +1,112 @@
import type { GlobalSettings } from '../../../shared/global-settings-types'
import { ANTIGRAVITY_VISIBLE_READINESS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import type {
RuntimeTerminalResolvePane,
RuntimeTerminalShow,
RuntimeTerminalWait
} from '../../../shared/runtime-types'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { toHostSessionTabId } from '../../../shared/terminal-surface-id'
import { useAppStore } from '@/store'
import { ensureLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities'
import {
callRuntimeRpc,
getActiveRuntimeTarget,
hasRuntimeRpcErrorCode,
runtimeEnvironmentSupportsCapability
} from '@/runtime/runtime-rpc-client'
import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id'
/** The host's recorded-screen classifier owns readiness; shell paste mode is not agent input. */
export async function waitForAntigravityDraftReady(
tabId: string,
ptyId: string,
timeoutMs: number,
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): Promise<boolean> {
const remotePty = parseRemoteRuntimePtyId(ptyId)
const target = remotePty?.environmentId
? ({ kind: 'environment', environmentId: remotePty.environmentId } as const)
: getActiveRuntimeTarget(settings)
const deadline = Date.now() + timeoutMs
const stillOwnsPty = (): boolean =>
useAppStore.getState().ptyIdsByTabId[tabId]?.includes(ptyId) === true
try {
const supported =
target.kind === 'environment'
? await runtimeEnvironmentSupportsCapability(
target.environmentId,
ANTIGRAVITY_VISIBLE_READINESS_RUNTIME_CAPABILITY,
Math.min(timeoutMs, 5000)
)
: (await ensureLocalRuntimeCapabilities())?.includes(
ANTIGRAVITY_VISIBLE_READINESS_RUNTIME_CAPABILITY
) === true
if (!supported) {
return false
}
while (stillOwnsPty() && Date.now() < deadline) {
const layout = useAppStore.getState().terminalLayoutsByTabId[tabId]
const leafId = Object.entries(layout?.ptyIdsByLeafId ?? {}).find(
([, value]) => value === ptyId
)?.[0]
if (leafId) {
const { terminal } = await callRuntimeRpc<{
terminal: RuntimeTerminalResolvePane | null
}>(
target,
'terminal.resolvePane',
{ paneKey: makePaneKey(toHostSessionTabId(tabId), leafId) },
{ timeoutMs: Math.min(5000, Math.max(1, deadline - Date.now())) }
).catch((error: unknown) => {
if (hasRuntimeRpcErrorCode(error, 'terminal_not_found')) {
return { terminal: null }
}
throw error
})
const remoteHandle = remotePty?.handle
const samePty = remoteHandle ? terminal?.handle === remoteHandle : terminal?.ptyId === ptyId
if (terminal && samePty && stillOwnsPty()) {
const remaining = deadline - Date.now()
if (remaining <= 0) {
return false
}
const { wait } = await callRuntimeRpc<{ wait: RuntimeTerminalWait }>(
target,
'terminal.wait',
{ terminal: terminal.handle, for: 'tui-idle', timeoutMs: remaining },
{ timeoutMs: remaining + 1000 }
)
if (
!stillOwnsPty() ||
wait.handle !== terminal.handle ||
!wait.satisfied ||
wait.status !== 'running' ||
wait.blockedReason
) {
return false
}
const { terminal: current } = await callRuntimeRpc<{ terminal: RuntimeTerminalShow }>(
target,
'terminal.show',
{ terminal: terminal.handle },
{ timeoutMs: 5000 }
)
return (
stillOwnsPty() &&
current.handle === terminal.handle &&
current.connected &&
current.writable &&
current.agentIdentity === 'antigravity' &&
(remoteHandle ? current.handle === remoteHandle : current.ptyId === ptyId)
)
}
}
// The PTY can bind before its pane appears in the host's window graph.
await new Promise<void>((resolve) => window.setTimeout(resolve, 100))
}
} catch {
// A disconnected or older host never licenses a best-effort paste.
}
return false
}
+4
View File
@@ -292,7 +292,11 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY =
'git.antigravity-configured-model.v1' as const
export const ANTIGRAVITY_VISIBLE_READINESS_RUNTIME_CAPABILITY =
'terminal.antigravity-visible-readiness.v1' as const
export const RUNTIME_CAPABILITIES = [
ANTIGRAVITY_VISIBLE_READINESS_RUNTIME_CAPABILITY,
ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY,
'files.pathsExist',
'runtime.status.compat.v1',
+1
View File
@@ -165,6 +165,7 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
},
antigravity: {
detectCmd: 'agy',
draftPasteReadyTimeoutMs: 60_000,
promptInjectionMode: 'flag-prompt-interactive'
},
aider: {