From 32a37f02b4fda3e147fd9b0e67e7444a137d9deb Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 19 Sep 2026 04:21:57 -0700 Subject: [PATCH] fix(antigravity): wait for host-confirmed composer before continuation paste --- .../antigravity-readiness-evidence.md | 22 +++ src/renderer/src/lib/agent-paste-draft.ts | 32 +++- .../antigravity-continuation-delivery.test.ts | 86 +++++++++ .../lib/antigravity-draft-readiness.test.ts | 172 ++++++++++++++++++ .../src/lib/antigravity-draft-readiness.ts | 112 ++++++++++++ src/shared/protocol-version.ts | 4 + src/shared/tui-agent-config.ts | 1 + 7 files changed, 422 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/lib/antigravity-continuation-delivery.test.ts create mode 100644 src/renderer/src/lib/antigravity-draft-readiness.test.ts create mode 100644 src/renderer/src/lib/antigravity-draft-readiness.ts diff --git a/docs/reference/antigravity-readiness-evidence.md b/docs/reference/antigravity-readiness-evidence.md index e7774f57b4e..89eddc35448 100644 --- a/docs/reference/antigravity-readiness-evidence.md +++ b/docs/reference/antigravity-readiness-evidence.md @@ -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. diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 72e467ee59e..7522d91298b 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -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[2] settings: Pick | 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): void => { const ptyId = state.ptyIdsByTabId[args.tabId]?.[0] diff --git a/src/renderer/src/lib/antigravity-continuation-delivery.test.ts b/src/renderer/src/lib/antigravity-continuation-delivery.test.ts new file mode 100644 index 00000000000..db7400798fe --- /dev/null +++ b/src/renderer/src/lib/antigravity-continuation-delivery.test.ts @@ -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((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((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() + }) +}) diff --git a/src/renderer/src/lib/antigravity-draft-readiness.test.ts b/src/renderer/src/lib/antigravity-draft-readiness.test.ts new file mode 100644 index 00000000000..f9f2dcea239 --- /dev/null +++ b/src/renderer/src/lib/antigravity-draft-readiness.test.ts @@ -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 + terminalLayoutsByTabId: Record }> + } = { 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() + 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) + }) +}) diff --git a/src/renderer/src/lib/antigravity-draft-readiness.ts b/src/renderer/src/lib/antigravity-draft-readiness.ts new file mode 100644 index 00000000000..430e8271a52 --- /dev/null +++ b/src/renderer/src/lib/antigravity-draft-readiness.ts @@ -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 | null | undefined +): Promise { + 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((resolve) => window.setTimeout(resolve, 100)) + } + } catch { + // A disconnected or older host never licenses a best-effort paste. + } + return false +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index c6751b2cd9b..68918b1f263 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -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', diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 0bb2c35a040..f601b99637e 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -165,6 +165,7 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { }, antigravity: { detectCmd: 'agy', + draftPasteReadyTimeoutMs: 60_000, promptInjectionMode: 'flag-prompt-interactive' }, aider: {