mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user