diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 3881499c18f..d2cfb010354 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -63,7 +63,10 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { paneKeysToClear.add(key.split('\0', 1)[0] ?? key) } } - for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) { + for (const key of [ + ...this.state.antigravityCompletedTranscriptByPaneKey.keys(), + ...this.state.antigravitySubagentParentByPaneKey.keys() + ]) { if (paneCacheKeyMatchesTab(key, tabId)) { paneKeysToClear.add(key.split('\0', 1)[0] ?? key) } diff --git a/src/shared/agent-hook-listener-antigravity.test.ts b/src/shared/agent-hook-listener-antigravity.test.ts index eb3d45b2e73..8bf775ba567 100644 --- a/src/shared/agent-hook-listener-antigravity.test.ts +++ b/src/shared/agent-hook-listener-antigravity.test.ts @@ -482,6 +482,101 @@ describe('shared agent-hook-listener', () => { expect(lateTool).toBeNull() }) + // Event order captured from agy 1.4.7 running an in-process invoke_subagent (#17650). + it('keeps an Antigravity parent working until its own idle Stop when a subagent finishes', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-antigravity-subagent-')) + const writeTranscript = (name: string, request: string, reply: string): string => { + const path = join(tmpDir, `${name}.jsonl`) + writeFileSync( + path, + `${[ + JSON.stringify({ source: 'USER_EXPLICIT', type: 'USER_INPUT', content: request }), + JSON.stringify({ source: 'MODEL', type: 'PLANNER_RESPONSE', content: reply }) + ].join('\n')}\n` + ) + return path + } + const parent = { + conversationId: 'parent-conversation', + lastUserInput: 'translate', + transcriptPath: writeTranscript('parent', 'translate', 'Parent final report') + } + const child = { + conversationId: 'child-conversation', + lastUserInput: '', + transcriptPath: writeTranscript('child', 'translate to fr', 'Subagent report') + } + const subagentCall = { name: 'invoke_subagent', args: { TypeName: 'self', Prompt: 'fr' } } + const send = (hookEventName: string, payload: Record) => + normalizeHookPayload( + state, + 'antigravity', + { paneKey: PANE_KEY, hook_event_name: hookEventName, payload }, + 'production' + )?.payload.state ?? null + + try { + const states = [ + send('PreInvocation', parent), + send('PreToolUse', { ...parent, toolCall: subagentCall }), + send('PostToolUse', { ...parent, toolCall: subagentCall }), + send('PreInvocation', child), + send('Stop', { ...parent, fullyIdle: false }), + send('PostInvocation', child), + send('Stop', { ...child, fullyIdle: true }), + send('PreInvocation', parent) + ] + expect(states).toEqual([ + 'working', + 'working', + 'working', + null, + 'working', + null, + null, + 'working' + ]) + + const done = normalizeHookPayload( + state, + 'antigravity', + { paneKey: PANE_KEY, hook_event_name: 'Stop', payload: { ...parent, fullyIdle: true } }, + 'production' + ) + expect(done?.payload).toMatchObject({ + state: 'done', + lastAssistantMessage: 'Parent final report' + }) + expect( + send('PreInvocation', { conversationId: 'next-conversation', lastUserInput: 'next' }) + ).toBe('working') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('tracks a new Antigravity conversation after the parent exits mid-subagent', () => { + const send = (hookEventName: string, payload: Record) => + normalizeHookPayload( + state, + 'antigravity', + { paneKey: PANE_KEY, hook_event_name: hookEventName, payload }, + 'production' + )?.payload.state ?? null + const parent = { conversationId: 'parent-conversation', lastUserInput: 'translate' } + send('PreInvocation', parent) + send('PreToolUse', { ...parent, toolCall: { name: 'invoke_subagent', args: {} } }) + expect(send('PreInvocation', { conversationId: 'child', lastUserInput: '' })).toBeNull() + + // agy was quit mid-subagent, so no idle Stop ever closed the parent; the user relaunched it. + const next = { conversationId: 'next-conversation', lastUserInput: 'hello' } + expect([ + send('PreInvocation', next), + send('PreToolUse', { ...next, toolCall: { name: 'ask_question', args: {} } }), + send('Stop', { ...next, fullyIdle: true }) + ]).toEqual(['working', 'waiting', 'done']) + }) + it('treats Antigravity Stop transcripts as pending result text', () => { expect( hasPendingAgentResultText('antigravity', { diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index d1f4b44ee09..45bf33969e8 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -38,6 +38,8 @@ export type HookListenerState = { /** Read-only compatibility view. All writes pass through the isolated legacy adapter. */ lastStatusByPaneKey: ReadonlyMap antigravityCompletedTranscriptByPaneKey: Map + /** Conversation that spawned in-process subagents; kept until its fully idle Stop because children outlive its turns. */ + antigravitySubagentParentByPaneKey: Map /** Journal-backed prompt for each jcode pane's current turn; see readJcodeTurnPrompt. */ jcodeTurnPromptByPaneKey: Map ampCompletedCacheKeys: Set @@ -121,6 +123,7 @@ export function createHookListenerState( lastToolByPaneKey: new Map(), lastStatusByPaneKey: adapter.view, antigravityCompletedTranscriptByPaneKey: new Map(), + antigravitySubagentParentByPaneKey: new Map(), jcodeTurnPromptByPaneKey: new Map(), ampCompletedCacheKeys: new Set(), claudeSubagentRosterByPaneKey: new Map(), @@ -216,6 +219,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): } } deletePaneScopedCacheEntry(state.antigravityCompletedTranscriptByPaneKey, paneKey) + deletePaneScopedCacheEntry(state.antigravitySubagentParentByPaneKey, paneKey) deletePaneScopedSetEntry(state.ampCompletedCacheKeys, paneKey) deletePaneScopedCacheEntry(state.claudeConsumedCompactPromptIdByPaneKey, paneKey) state.claudeSubagentRosterByPaneKey.delete(paneKey) @@ -264,6 +268,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.lastToolByPaneKey, fromPaneKey, toPaneKey) moveLegacyAgentStatuses(state, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.antigravityCompletedTranscriptByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.antigravitySubagentParentByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.jcodeTurnPromptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedSetEntries(state.ampCompletedCacheKeys, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.claudeConsumedCompactPromptIdByPaneKey, fromPaneKey, toPaneKey) @@ -290,6 +295,7 @@ export function clearPaneTurnCacheState(state: HookListenerState, paneKey: strin state.lastPromptByPaneKey.delete(paneKey) state.lastToolByPaneKey.delete(paneKey) state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) + state.antigravitySubagentParentByPaneKey.delete(paneKey) state.jcodeTurnPromptByPaneKey.delete(paneKey) state.ampCompletedCacheKeys.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) @@ -301,6 +307,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.lastToolByPaneKey.clear() clearLegacyAgentStatuses(state) state.antigravityCompletedTranscriptByPaneKey.clear() + state.antigravitySubagentParentByPaneKey.clear() state.jcodeTurnPromptByPaneKey.clear() state.ampCompletedCacheKeys.clear() state.claudeConsumedCompactPromptIdByPaneKey.clear() diff --git a/src/shared/agent-hook-listener/providers/antigravity-events.ts b/src/shared/agent-hook-listener/providers/antigravity-events.ts index 433a64437cd..f30e6c555d5 100644 --- a/src/shared/agent-hook-listener/providers/antigravity-events.ts +++ b/src/shared/agent-hook-listener/providers/antigravity-events.ts @@ -14,6 +14,10 @@ export function isAntigravityFeedbackTool(toolName: string | undefined): boolean return toolName === 'ask_question' || toolName === 'ask_permission' } +function isAntigravitySubagentTool(toolName: string | undefined): boolean { + return toolName === 'invoke_subagent' || toolName === 'send_message' +} + export function normalizeAntigravityEvent( state: HookListenerState, eventName: unknown, @@ -22,6 +26,23 @@ export function normalizeAntigravityEvent( hookPayload: Record ): ParsedAgentStatusPayload | null { const transcriptPath = readFirstString(hookPayload, ['transcriptPath', 'transcript_path']) + const conversationId = readFirstString(hookPayload, ['conversationId', 'conversation_id']) + const subagentParent = state.antigravitySubagentParentByPaneKey.get(paneKey) + if (subagentParent && conversationId && conversationId !== subagentParent) { + // Subagents run inside the parent agy under their own conversationId and never carry user input. + if (!readFirstString(hookPayload, ['lastUserInput'])) { + return null + } + 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 ( @@ -33,7 +54,6 @@ export function normalizeAntigravityEvent( return null } - const toolName = readAntigravityToolCall(hookPayload).toolName const stopStillBusy = eventName === 'Stop' && isAntigravityStopStillBusy(hookPayload) const stateName = eventName === 'PreToolUse' && isAntigravityFeedbackTool(toolName) @@ -78,8 +98,11 @@ export function normalizeAntigravityEvent( lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput }) // Stop between tools can report fullyIdle=false; only an idle Stop is terminal. - if (eventName === 'Stop' && !stopStillBusy && transcriptPath) { - state.antigravityCompletedTranscriptByPaneKey.set(paneKey, transcriptPath) + if (eventName === 'Stop' && !stopStillBusy) { + state.antigravitySubagentParentByPaneKey.delete(paneKey) + if (transcriptPath) { + state.antigravityCompletedTranscriptByPaneKey.set(paneKey, transcriptPath) + } } return payload }