diff --git a/src/main/agent-hooks/server/program-status/hook-precedence.test.ts b/src/main/agent-hooks/server/program-status/hook-precedence.test.ts index c873db8f785..146f4a97397 100644 --- a/src/main/agent-hooks/server/program-status/hook-precedence.test.ts +++ b/src/main/agent-hooks/server/program-status/hook-precedence.test.ts @@ -80,6 +80,17 @@ describe('programStatusHookYieldEndsAt', () => { expect(programStatusHookYieldEndsAt(hook, heldOpen, NOW + 1)).toBe(Infinity) }) + it("never lets a program's done settle a turn the hooks hold open for children", () => { + const heldOpen = row({ + state: 'working', + agentType: 'antigravity', + mainAgent: { state: 'done', outcome: 'success', stateStartedAt: NOW } + }) + expect( + programStatusHookYieldEndsAt(heldOpen, report({ state: 'done', agentType: 'antigravity' })) + ).toBe(Infinity) + }) + it('keeps a question the hooks raised only from a working report that began before it', () => { for (const state of ['waiting', 'blocked'] as const) { const hook = row({ state, agentType: 'pi' }) diff --git a/src/main/agent-hooks/server/program-status/hook-precedence.ts b/src/main/agent-hooks/server/program-status/hook-precedence.ts index df80f7f8bbe..b32f0bfc296 100644 --- a/src/main/agent-hooks/server/program-status/hook-precedence.ts +++ b/src/main/agent-hooks/server/program-status/hook-precedence.ts @@ -20,10 +20,13 @@ export function programStatusHookYieldEndsAt( if (!sameAgent && previous.payload.state === 'done') { return undefined } - // Why: the hook lane tracks its own children; a program's child roster must not reopen the hook's settled turn. + // Hook and program child rosters cannot settle or reopen each other's turns. if ( payload.state === previous.payload.state || - (previous.payload.state === 'done' && isAgentStatusHeldOpenByChildWork(payload)) + (previous.payload.state === 'done' && isAgentStatusHeldOpenByChildWork(payload)) || + (payload.state === 'done' && + payload.sessionBoundary !== true && + isAgentStatusHeldOpenByChildWork(previous.payload)) ) { return Infinity } diff --git a/src/shared/agent-hook-listener-antigravity.test.ts b/src/shared/agent-hook-listener-antigravity.test.ts index 8bf775ba567..fa1ec17efbb 100644 --- a/src/shared/agent-hook-listener-antigravity.test.ts +++ b/src/shared/agent-hook-listener-antigravity.test.ts @@ -413,6 +413,7 @@ describe('shared agent-hook-listener', () => { state: 'working', agentType: 'antigravity' }) + expect(event?.payload.mainAgent).toBeUndefined() }) it('keeps Antigravity tool hooks active after a non-idle Stop for the same transcript', () => { @@ -513,10 +514,10 @@ describe('shared agent-hook-listener', () => { 'antigravity', { paneKey: PANE_KEY, hook_event_name: hookEventName, payload }, 'production' - )?.payload.state ?? null + )?.payload ?? null try { - const states = [ + const events = [ send('PreInvocation', parent), send('PreToolUse', { ...parent, toolCall: subagentCall }), send('PostToolUse', { ...parent, toolCall: subagentCall }), @@ -526,7 +527,7 @@ describe('shared agent-hook-listener', () => { send('Stop', { ...child, fullyIdle: true }), send('PreInvocation', parent) ] - expect(states).toEqual([ + expect(events.map((event) => event?.state ?? null)).toEqual([ 'working', 'working', 'working', @@ -536,6 +537,8 @@ describe('shared agent-hook-listener', () => { null, 'working' ]) + // Held open for helpers, so the terminal's done for the parent's turn cannot settle it. + expect(events[4]?.mainAgent?.state).toBe('done') const done = normalizeHookPayload( state, @@ -547,8 +550,13 @@ describe('shared agent-hook-listener', () => { state: 'done', lastAssistantMessage: 'Parent final report' }) + expect(send('PostToolUse', { ...parent, toolCall: subagentCall })).toBeNull() + expect(send('PreInvocation', parent)?.state).toBe('working') + const nextStop = send('Stop', { ...parent, fullyIdle: false }) + expect(nextStop?.state).toBe('working') + expect(nextStop?.mainAgent).toBeUndefined() expect( - send('PreInvocation', { conversationId: 'next-conversation', lastUserInput: 'next' }) + send('PreInvocation', { conversationId: 'next-conversation', lastUserInput: 'next' })?.state ).toBe('working') } finally { rmSync(tmpDir, { recursive: true, force: true }) diff --git a/src/shared/agent-hook-listener/providers/antigravity-events.ts b/src/shared/agent-hook-listener/providers/antigravity-events.ts index f30e6c555d5..3e8b36ab6d6 100644 --- a/src/shared/agent-hook-listener/providers/antigravity-events.ts +++ b/src/shared/agent-hook-listener/providers/antigravity-events.ts @@ -35,14 +35,6 @@ export function normalizeAntigravityEvent( } state.antigravitySubagentParentByPaneKey.delete(paneKey) } - const toolName = readAntigravityToolCall(hookPayload).toolName - if ( - conversationId && - (eventName === 'PreToolUse' || eventName === 'PostToolUse') && - isAntigravitySubagentTool(toolName) - ) { - state.antigravitySubagentParentByPaneKey.set(paneKey, conversationId) - } if (eventName === 'PreInvocation') { state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) } else if ( @@ -54,7 +46,18 @@ export function normalizeAntigravityEvent( return null } + const toolName = readAntigravityToolCall(hookPayload).toolName + if ( + conversationId && + (eventName === 'PreToolUse' || eventName === 'PostToolUse') && + isAntigravitySubagentTool(toolName) + ) { + state.antigravitySubagentParentByPaneKey.set(paneKey, conversationId) + } + const stopStillBusy = eventName === 'Stop' && isAntigravityStopStillBusy(hookPayload) + // agy ends the parent's turn with fullyIdle=false while its helpers run; they hold the pane open. + const heldOpenByHelpers = stopStillBusy && state.antigravitySubagentParentByPaneKey.has(paneKey) const stateName = eventName === 'PreToolUse' && isAntigravityFeedbackTool(toolName) ? 'waiting' @@ -95,7 +98,8 @@ export function normalizeAntigravityEvent( toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, - lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + mainAgent: heldOpenByHelpers ? { state: 'done', stateStartedAt: Date.now() } : undefined }) // Stop between tools can report fullyIdle=false; only an idle Stop is terminal. if (eventName === 'Stop' && !stopStillBusy) {