diff --git a/src/main/antigravity/windows-hook-payload-delivery.test.ts b/src/main/antigravity/windows-hook-payload-delivery.test.ts index 30570c384dd..7dbabff6eda 100644 --- a/src/main/antigravity/windows-hook-payload-delivery.test.ts +++ b/src/main/antigravity/windows-hook-payload-delivery.test.ts @@ -96,7 +96,11 @@ async function startHookListener(): Promise<{ type HookRun = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean } -function runWrapper(wrapperPath: string, env: NodeJS.ProcessEnv): Promise { +function runWrapper( + wrapperPath: string, + env: NodeJS.ProcessEnv, + stdinPayload: string = PAYLOAD +): Promise { return new Promise((resolve, reject) => { // Why: mirror how Antigravity spawns the hook — `cmd /c `, the exact // chain in the bug report's process trace. @@ -127,7 +131,7 @@ function runWrapper(wrapperPath: string, env: NodeJS.ProcessEnv): Promise resolve({ exitCode, stdout, stderr, timedOut }), 250) }) - child.stdin.end(Buffer.from(PAYLOAD, 'utf8')) + child.stdin.end(Buffer.from(stdinPayload, 'utf8')) }) } @@ -227,6 +231,35 @@ describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload // Why: five wrapper launches plus a real install can overrun the default under load. }, 60_000) + // Why (#15117): Antigravity fires some events with no stdin at all. PowerShell substituted + // `{}` before posting; curl forwards the empty body, so prove the post still happens — the + // listener's matching allowance is covered in agent-hook-listener-antigravity.test.ts. + it('still posts a status event when the agent supplies no payload', async () => { + home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook-')) + homedirMock.mockReturnValue(home) + expect(new AntigravityHookService().install().state).toBe('installed') + + const listener = await startHookListener() + server = listener.server + const result = await runWrapper( + join(home, '.orca', 'agent-hooks', 'antigravity-pre-invocation.cmd'), + hookEnvironment({ + USERPROFILE: home, + HOME: home, + ORCA_AGENT_HOOK_PORT: String(listener.port), + ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN, + ORCA_PANE_KEY: PANE_KEY + }), + '' + ) + + expect(result.timedOut).toBe(false) + expect(result.exitCode).toBe(0) + expect(listener.posts).toHaveLength(1) + expect(listener.posts[0].payload).toBe('') + expect(listener.posts[0].hookEventName).toBe('PreInvocation') + }, 30_000) + it('exits without reading stdin when the pane env is missing', async () => { home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook-')) homedirMock.mockReturnValue(home) diff --git a/src/shared/agent-hook-listener-antigravity.test.ts b/src/shared/agent-hook-listener-antigravity.test.ts index da3c813b3b1..a92fc3e2b0c 100644 --- a/src/shared/agent-hook-listener-antigravity.test.ts +++ b/src/shared/agent-hook-listener-antigravity.test.ts @@ -23,6 +23,57 @@ describe('shared agent-hook-listener', () => { vi.unstubAllEnvs() }) + // Why (#15117): the Windows script posts stdin verbatim through curl, so an event that + // fires without a payload arrives as `payload=""` rather than the `{}` the POSIX script + // substitutes. Both transports must still produce the status transition. + it('accepts an Antigravity event whose payload arrived empty', () => { + const started = normalizeHookPayload( + state, + 'antigravity', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + hook_event_name: 'PreInvocation', + payload: '' + }, + 'production' + ) + expect(started?.payload).toMatchObject({ state: 'working', agentType: 'antigravity' }) + }) + + it('still drops an Antigravity event whose payload is malformed rather than empty', () => { + expect( + normalizeHookPayload( + state, + 'antigravity', + { + paneKey: PANE_KEY, + hook_event_name: 'PreInvocation', + payload: '{"prompt":' + }, + 'production' + ) + ).toBeNull() + }) + + // Why: the empty-payload allowance is Antigravity's documented policy, not a global one — + // every other source must keep rejecting a body it cannot parse. + it('does not extend the empty-payload allowance to other sources', () => { + expect( + normalizeHookPayload( + state, + 'gemini', + { + paneKey: PANE_KEY, + hook_event_name: 'BeforeAgent', + payload: '' + }, + 'production' + ) + ).toBeNull() + }) + it('normalizes Antigravity invocation and tool hooks', () => { const started = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index da80e171841..5ecdb3260d6 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -4369,6 +4369,13 @@ export function normalizeHookPayload( const hookPayload = typeof rawPayload === 'string' ? (() => { + // Why (#15117): some Antigravity events fire with no stdin at all, yet still carry a + // status transition in `hook_event_name`. The POSIX script maps empty input to `{}` + // before posting; the Windows script pipes stdin straight into curl and cannot, so + // accept the empty body here instead of dropping the event. + if (source === 'antigravity' && rawPayload.trim() === '') { + return {} + } try { return parseAgentHookJson(rawPayload) } catch { diff --git a/src/shared/wsl-exec-mode-separator.test.ts b/src/shared/wsl-exec-mode-separator.test.ts index 02b23268bfd..69028f9aa98 100644 --- a/src/shared/wsl-exec-mode-separator.test.ts +++ b/src/shared/wsl-exec-mode-separator.test.ts @@ -85,7 +85,7 @@ describe('wsl.exe mode separator', () => { it('sees a `--` separator split across lines by the formatter', () => { // Why: the guard once matched line-by-line and was blind to this exact shape, // which is how every multi-element argv array in this repo is formatted. - const formatted = ["args: [", " '-d',", " distro,", " '--',", " 'bash'", "]"].join('\n') + const formatted = ['args: [', " '-d',", ' distro,', " '--',", " 'bash'", ']'].join('\n') expect(ARGV_FORM.test(codeText(formatted))).toBe(true) })