fix(agent-status): an Antigravity subagent finishing no longer ends its parent's turn (#33645)

Antigravity automations no longer complete (and close their tab) when an in-process invoke_subagent subagent reports back: once a conversation launches subagents, hooks from other conversations without user input are ignored until the parent's own fully idle Stop; a user-started conversation (carrying lastUserInput) clears the marker so a relaunched agy still gets status.

Fixes #17650
This commit is contained in:
Neil
2026-10-25 15:33:23 -07:00
committed by GitHub
parent 7e8bf3db47
commit 9639426abe
4 changed files with 132 additions and 4 deletions
@@ -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)
}
@@ -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<string, unknown>) =>
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<string, unknown>) =>
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', {
@@ -38,6 +38,8 @@ export type HookListenerState = {
/** Read-only compatibility view. All writes pass through the isolated legacy adapter. */
lastStatusByPaneKey: ReadonlyMap<string, AgentHookEventPayload>
antigravityCompletedTranscriptByPaneKey: Map<string, string>
/** Conversation that spawned in-process subagents; kept until its fully idle Stop because children outlive its turns. */
antigravitySubagentParentByPaneKey: Map<string, string>
/** Journal-backed prompt for each jcode pane's current turn; see readJcodeTurnPrompt. */
jcodeTurnPromptByPaneKey: Map<string, JcodeUserPromptEvidence | null>
ampCompletedCacheKeys: Set<string>
@@ -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()
@@ -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<string, unknown>
): 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
}