diff --git a/src/shared/__fixtures__/grok-cancel-background-hooks.jsonl b/src/shared/__fixtures__/grok-cancel-background-hooks.jsonl new file mode 100644 index 00000000000..e67dc38a672 --- /dev/null +++ b/src/shared/__fixtures__/grok-cancel-background-hooks.jsonl @@ -0,0 +1,36 @@ +{"scenario":"continue-to-run","hook_event_name":"UserPromptSubmit","hookEventName":"user_prompt_submit","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:21.927601+00:00","promptId":"2dd6b250-bad9-4d32-981b-2e04e56e330c","prompt":"Do these in order without asking: (1) start a BACKGROUND shell task running: sleep 150 ; (2) launch a BACKGROUND subagent whose task is to run the bash command sleep 120 and then say ok ; (3) then run"} +{"scenario":"continue-to-run","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:27.971568+00:00","toolName":"run_terminal_command","toolUseId":"call-ff008b34-a2e7-4205-afcf-f8541ac7dce4-6","toolInput":{"command":"sleep 150","description":"Background sleep for 150 seconds","block_until_ms":0}} +{"scenario":"continue-to-run","hook_event_name":"PostToolUse","hookEventName":"post_tool_use","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:29.122111+00:00","toolName":"run_terminal_command","toolUseId":"call-ff008b34-a2e7-4205-afcf-f8541ac7dce4-6","toolInput":{"command":"sleep 150","description":"Background sleep for 150 seconds","block_until_ms":0},"toolResult":{"type":"BackgroundTaskStarted","task_id":"e1d2068e-ada9-4e9b-b7cc-c71abcc1c11f","task_type":"bash","status":"running"}} +{"scenario":"continue-to-run","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:31.370583+00:00","toolName":"spawn_subagent","toolUseId":"call-06ef61bc-f28c-4373-abd1-f25ae36577af-7","toolInput":{"description":"Sleep 120 then say ok","prompt":"Run the bash command `sleep 120` and wait for it to finish. Then reply with exactly: ok","background":true}} +{"scenario":"continue-to-run","hook_event_name":"SubagentStart","hookEventName":"subagent_start","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:31.792357+00:00","subagentId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","subagentType":"general-purpose","description":"Sleep 120 then say ok"} +{"scenario":"continue-to-run","hook_event_name":"PostToolUse","hookEventName":"post_tool_use","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:31.732458+00:00","toolName":"spawn_subagent","toolUseId":"call-06ef61bc-f28c-4373-abd1-f25ae36577af-7","toolInput":{"description":"Sleep 120 then say ok","prompt":"Run the bash command `sleep 120` and wait for it to finish. Then reply with exactly: ok","background":true},"toolResult":{"type":"Text","text":"Subagent started in background.\nsubagent_id: 01a183f9-8a65-71b1-93c1-fe7b3d92c574\ndescription: Sleep 120 then say ok\n\nWhen you need its result, use get_command_or_subagent_output with task_ids=[\"01a18"}} +{"scenario":"continue-to-run","hook_event_name":"UserPromptSubmit","hookEventName":"user_prompt_submit","sessionId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","timestamp":"2026-10-28T19:25:32.150390+00:00","promptId":"01a183f9-8c10-7e42-932a-2b9caa86a175","prompt":"Run the bash command `sleep 120` and wait for it to finish. Then reply with exactly: ok","subagentType":"general-purpose"} +{"scenario":"continue-to-run","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:25:34.592144+00:00","toolName":"run_terminal_command","toolUseId":"call-6739900f-05bc-4d99-9223-fe071b8bfd9a-8","toolInput":{"command":"sleep 60","description":"Foreground sleep for 60 seconds","block_until_ms":75000}} +{"scenario":"continue-to-run","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","timestamp":"2026-10-28T19:25:35.045912+00:00","toolName":"run_terminal_command","toolUseId":"call-5d6cc212-dbc5-493c-a99b-e9958957758a-0","toolInput":{"command":"sleep 120","description":"Wait 120 seconds before replying","block_until_ms":130000},"subagentType":"general-purpose"} +{"scenario":"continue-to-run","hook_event_name":"StopCancelled","hookEventName":"stop_cancelled","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:26:11.283501+00:00","promptId":"2dd6b250-bad9-4d32-981b-2e04e56e330c","reason":"user_interrupt","cancelledBy":"user","cancelTrigger":"ctrl_c"} +{"scenario":"continue-to-run","hook_event_name":"Notification","hookEventName":"notification","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:27:11.135027+00:00","notificationType":"idle_prompt","message":"Waiting for your next prompt","level":"info"} +{"scenario":"continue-to-run","hook_event_name":"PostToolUse","hookEventName":"post_tool_use","sessionId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","timestamp":"2026-10-28T19:27:36.050294+00:00","toolName":"run_terminal_command","toolUseId":"call-5d6cc212-dbc5-493c-a99b-e9958957758a-0","toolInput":{"command":"sleep 120","description":"Wait 120 seconds before replying","block_until_ms":130000},"toolResult":{"type":"Bash"},"subagentType":"general-purpose"} +{"scenario":"continue-to-run","hook_event_name":"SubagentStop","hookEventName":"subagent_stop","sessionId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","timestamp":"2026-10-28T19:27:41.524951+00:00","promptId":"01a183f9-8c10-7e42-932a-2b9caa86a175","subagentId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","subagentType":"general-purpose","stopHookActive":false} +{"scenario":"continue-to-run","hook_event_name":"SessionEnd","hookEventName":"session_end","sessionId":"01a183f9-8a65-71b1-93c1-fe7b3d92c574","timestamp":"2026-10-28T19:27:42.267006+00:00","subagentType":"general-purpose","reason":"shutdown"} +{"scenario":"continue-to-run","hook_event_name":"UserPromptSubmit","hookEventName":"user_prompt_submit","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:27:43.030713+00:00","promptId":"subagent-completed-01a183f9-8a65-71b1-93c1-fe7b3d92c574","prompt":"\nWhile you were idle, 1 background subagent completed:\n- [general-purpose] \"Sleep 120 then say ok\" \u2014 completed successfully (130.5s, 1 tool calls)\n=== Task 01a183f9-8a65-71b1-93c1-fe7"} +{"scenario":"continue-to-run","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:27:47.340615+00:00","toolName":"run_terminal_command","toolUseId":"call-ff876bcf-280d-4d83-86e9-c56475a845f1-9","toolInput":{"command":"sleep 60","description":"Foreground sleep for 60 seconds","block_until_ms":75000}} +{"scenario":"continue-to-run","hook_event_name":"Notification","hookEventName":"notification","sessionId":"01a183ea-2c14-7ca2-b795-9b3bd3c74bfd","timestamp":"2026-10-28T19:28:00.456613+00:00","notificationType":"task_complete","message":"Background task completed: e1d2068e-ada9-4e9b-b7cc-c71abcc1c11f","level":"info"} +{"scenario":"shell-only","hook_event_name":"UserPromptSubmit","hookEventName":"user_prompt_submit","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:24:41.463633+00:00","promptId":"d44bdc20-4ea1-488e-8e77-09383a06ce16","prompt":"Start a BACKGROUND shell task running exactly: sleep 45 ; then run sleep 90 in the FOREGROUND and wait for it. Then reply DONE."} +{"scenario":"shell-only","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:24:44.862943+00:00","toolName":"read_file","toolUseId":"call-d5422960-6079-44c0-957d-8b4456d7f4ff-0","toolInput":{"target_file":"/home/user/.grok/bundled/skills/long-running-background-tasks/SKILL.md"}} +{"scenario":"shell-only","hook_event_name":"PostToolUse","hookEventName":"post_tool_use","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:24:45.119791+00:00","toolName":"read_file","toolUseId":"call-d5422960-6079-44c0-957d-8b4456d7f4ff-0","toolInput":{"target_file":"/home/user/.grok/bundled/skills/long-running-background-tasks/SKILL.md"},"toolResult":{"type":"ReadFile"}} +{"scenario":"shell-only","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:24:52.233728+00:00","toolName":"run_terminal_command","toolUseId":"call-a4d622ed-655b-4aad-b772-b9aa98163c7e-1","toolInput":{"command":"sleep 45","description":"Background sleep 45","block_until_ms":0}} +{"scenario":"shell-only","hook_event_name":"PostToolUse","hookEventName":"post_tool_use","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:24:52.872235+00:00","toolName":"run_terminal_command","toolUseId":"call-a4d622ed-655b-4aad-b772-b9aa98163c7e-1","toolInput":{"command":"sleep 45","description":"Background sleep 45","block_until_ms":0},"toolResult":{"type":"BackgroundTaskStarted","task_id":"4ebb49e6-528c-402e-acbd-d9b6e8dc60d8","task_type":"bash","status":"running"}} +{"scenario":"shell-only","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:24:57.023367+00:00","toolName":"run_terminal_command","toolUseId":"call-84f6ab73-3a14-424b-b630-a8967aed06fe-2","toolInput":{"command":"sleep 90","description":"Foreground sleep 90 and wait","block_until_ms":120000}} +{"scenario":"shell-only","hook_event_name":"StopCancelled","hookEventName":"stop_cancelled","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:25:01.593131+00:00","promptId":"d44bdc20-4ea1-488e-8e77-09383a06ce16","reason":"user_interrupt","cancelledBy":"user","cancelTrigger":"ctrl_c"} +{"scenario":"shell-only","hook_event_name":"Notification","hookEventName":"notification","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:25:37.693008+00:00","notificationType":"task_complete","message":"Background task completed: 4ebb49e6-528c-402e-acbd-d9b6e8dc60d8","level":"info"} +{"scenario":"shell-only","hook_event_name":"Notification","hookEventName":"notification","sessionId":"01a1849d-1f0c-7d30-9007-ab623bb65ea1","timestamp":"2026-10-28T22:26:01.474946+00:00","notificationType":"idle_prompt","message":"Waiting for your next prompt","level":"info"} +{"scenario":"stop-running","hook_event_name":"UserPromptSubmit","hookEventName":"user_prompt_submit","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:28:02.051752+00:00","promptId":"9fe5e4e0-3d00-4a72-bee8-d9465e6dc2b6","prompt":"Launch a BACKGROUND subagent whose task is to run the bash command sleep 100 and then say ok; then run sleep 90 in the FOREGROUND and wait for it. Then reply DONE."} +{"scenario":"stop-running","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:28:22.490044+00:00","toolName":"spawn_subagent","toolUseId":"call-feca83be-16b1-406f-a879-d962893800b7-0","toolInput":{"description":"Sleep 100 then ok","prompt":"Run this bash command and wait until it finishes: sleep 100\n\nUse the terminal tool with block_until_ms set high enough to wait for the full 100 seconds (at least 110000). Do not background it. When the command has finished, reply with exactly: ok\n\nDo nothing else.","background":true}} +{"scenario":"stop-running","hook_event_name":"SubagentStart","hookEventName":"subagent_start","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:28:22.595236+00:00","subagentId":"01a184a0-f1aa-70f3-a91c-c37b0930dd99","subagentType":"general-purpose","description":"Sleep 100 then ok"} +{"scenario":"stop-running","hook_event_name":"PostToolUse","hookEventName":"post_tool_use","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:28:22.591367+00:00","toolName":"spawn_subagent","toolUseId":"call-feca83be-16b1-406f-a879-d962893800b7-0","toolInput":{"description":"Sleep 100 then ok","prompt":"Run this bash command and wait until it finishes: sleep 100\n\nUse the terminal tool with block_until_ms set high enough to wait for the full 100 seconds (at least 110000). Do not background it. When the command has finished, reply with exactly: ok\n\nDo nothing else.","background":true},"toolResult":{"type":"Text","text":"Subagent started in background.\nsubagent_id: 01a184a0-f1aa-70f3-a91c-c37b0930dd99\ndescription: Sleep 100 then ok\n\nWhen you need its result, use get_command_or_subagent_output with task_ids=[\"01a184a0-"}} +{"scenario":"stop-running","hook_event_name":"UserPromptSubmit","hookEventName":"user_prompt_submit","sessionId":"01a184a0-f1aa-70f3-a91c-c37b0930dd99","timestamp":"2026-10-28T22:28:22.733904+00:00","promptId":"01a184a0-f230-7f61-9744-cef56988eda1","prompt":"Run this bash command and wait until it finishes: sleep 100\n\nUse the terminal tool with block_until_ms set high enough to wait for the full 100 seconds (at least 110000). Do not background it. When th","subagentType":"general-purpose"} +{"scenario":"stop-running","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a184a0-f1aa-70f3-a91c-c37b0930dd99","timestamp":"2026-10-28T22:28:24.864071+00:00","toolName":"run_terminal_command","toolUseId":"call-853df35c-570f-4c0d-a4c3-9f2272fd9ac8-0","toolInput":{"command":"sleep 100","description":"Sleep for 100 seconds","block_until_ms":110000},"subagentType":"general-purpose"} +{"scenario":"stop-running","hook_event_name":"PreToolUse","hookEventName":"pre_tool_use","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:28:25.175975+00:00","toolName":"run_terminal_command","toolUseId":"call-974db590-6eea-4763-9042-223d6cf95698-1","toolInput":{"command":"sleep 90","description":"Wait 90 seconds in the foreground","block_until_ms":120000}} +{"scenario":"stop-running","hook_event_name":"StopCancelled","hookEventName":"stop_cancelled","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:28:30.214441+00:00","promptId":"9fe5e4e0-3d00-4a72-bee8-d9465e6dc2b6","reason":"user_interrupt","cancelledBy":"user","cancelTrigger":"ctrl_c"} +{"scenario":"stop-running","hook_event_name":"SessionEnd","hookEventName":"session_end","sessionId":"01a184a0-f1aa-70f3-a91c-c37b0930dd99","timestamp":"2026-10-28T22:28:30.242682+00:00","subagentType":"general-purpose","reason":"shutdown"} +{"scenario":"stop-running","hook_event_name":"Notification","hookEventName":"notification","sessionId":"01a184a0-009e-7b11-9b47-043c1dc3f59a","timestamp":"2026-10-28T22:29:30.044480+00:00","notificationType":"idle_prompt","message":"Waiting for your next prompt","level":"info"} diff --git a/src/shared/agent-hook-listener-grok-cancel-background.test.ts b/src/shared/agent-hook-listener-grok-cancel-background.test.ts new file mode 100644 index 00000000000..9d8674ad521 --- /dev/null +++ b/src/shared/agent-hook-listener-grok-cancel-background.test.ts @@ -0,0 +1,99 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { normalizeAndAccept } from './agent-hook-listener-test-harness' + +// Grok 1.0.70 hooks captured around a confirmed Ctrl+C cancel; StopCancelled carries no backgroundTasks. +const capturedHooks: Record[] = readFileSync( + join(__dirname, '__fixtures__', 'grok-cancel-background-hooks.jsonl'), + 'utf8' +) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + +// Orca's managed Grok hooks do not subscribe SubagentStart/SubagentStop. +const UNSUBSCRIBED = new Set(['subagent_start', 'subagent_stop']) + +function replay(scenario: string, edit = (hooks: Record[]) => hooks) { + const state = createHookListenerState() + const sessions = new Set() + const rows = edit( + capturedHooks.filter( + (hook) => hook.scenario === scenario && !UNSUBSCRIBED.has(String(hook.hookEventName)) + ) + ).flatMap((hook) => { + const event = normalizeAndAccept(state, 'grok', hook) + const row = event?.payload + if (!row) { + return [] + } + sessions.add(String(event.providerSession?.id)) + const name = hook.subagentType + ? `child ${String(hook.hookEventName)}` + : String(hook.notificationType ?? hook.hookEventName) + const status = [row.state, row.workingMode, row.interrupted ? 'interrupted' : undefined] + return [`${name}: ${status.filter(Boolean).join(' ')}`] + }) + // A child's end restates the pane; resume must stay on the main session, never the subagent's. + expect(sessions.size).toBe(1) + return rows +} + +describe('Grok background work that outlives a cancel', () => { + it('keeps a kept-running subagent and shell visible until each reports its end', () => { + expect(replay('continue-to-run')).toEqual([ + 'user_prompt_submit: working', + 'pre_tool_use: working', + 'post_tool_use: working', + 'pre_tool_use: working', + 'post_tool_use: working', + 'pre_tool_use: working', + 'stop_cancelled: working', + 'idle_prompt: working', + 'child session_end: working monitoring', + // Grok's own follow-up turn once the subagent finished. + 'user_prompt_submit: working', + 'pre_tool_use: working' + ]) + }) + + it('holds a cancelled turn on its shell until task_complete names it', () => { + expect(replay('shell-only')).toEqual([ + 'user_prompt_submit: working', + 'pre_tool_use: working', + 'post_tool_use: working', + 'pre_tool_use: working', + 'post_tool_use: working', + 'pre_tool_use: working', + 'stop_cancelled: working monitoring', + 'task_complete: done interrupted', + 'idle_prompt: done interrupted' + ]) + }) + + it('settles when "Stop running" ends the subagent with the cancel', () => { + expect(replay('stop-running')).toEqual([ + 'user_prompt_submit: working', + 'pre_tool_use: working', + 'post_tool_use: working', + 'pre_tool_use: working', + 'stop_cancelled: working', + 'child session_end: done interrupted', + 'idle_prompt: done interrupted' + ]) + }) + + it('lets a session that ended first stay settled when its subagent ends later', () => { + const rows = replay('continue-to-run', (hooks) => { + const childEnd = hooks.findIndex( + (hook) => hook.subagentType && hook.hookEventName === 'session_end' + ) + const { sessionId } = hooks[0] + const mainEnd = { hook_event_name: 'SessionEnd', hookEventName: 'session_end', sessionId } + return [...hooks.slice(0, childEnd), mainEnd, hooks[childEnd]] + }) + expect(rows.at(-1)).toBe('session_end: done interrupted') + }) +}) diff --git a/src/shared/agent-hook-listener-grok-completion.test.ts b/src/shared/agent-hook-listener-grok-completion.test.ts index af37cb05685..c234b6a15b8 100644 --- a/src/shared/agent-hook-listener-grok-completion.test.ts +++ b/src/shared/agent-hook-listener-grok-completion.test.ts @@ -130,35 +130,6 @@ describe('Grok completion observations', () => { }) }) - it('keeps the cancellation on the idle backstop that settles a task the cancel left running', () => { - normalize({ - hookEventName: 'UserPromptSubmit', - sessionId: 's-1', - promptId: 'p-1', - prompt: 'go' - }) - expect( - normalize({ - hookEventName: 'StopCancelled', - sessionId: 's-1', - promptId: 'p-1', - backgroundTasks: [{ id: 'task-1', type: 'shell', status: 'running' }] - }) - ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) - // A settled row without `interrupted` would announce the cancelled turn as a clean finish. - expect( - normalize({ - hookEventName: 'Notification', - sessionId: 's-1', - notificationType: 'idle_prompt' - }) - ).toMatchObject({ - state: 'done', - interrupted: true, - mainAgent: { state: 'done', outcome: 'cancellation' } - }) - }) - // Pins grok-events.ts: finite task predicate and sessionCrons omission; treating monitors/crons as finite must redden. it.each([ { diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 6a56bbae1ab..fc6ddd7e841 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -1,4 +1,5 @@ import type { AgentMainAgentStatus } from '../agent-status-types' +import type { AgentChildWorkKind } from '../agent-status-child-work' import type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' export type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' @@ -73,6 +74,8 @@ export type HookListenerState = { grokActiveTurnByPaneKey: Map /** The Grok main agent's own state as last published, so its clock keeps continuity across events. */ grokMainAgentStatusByPaneKey: Map + /** Grok background shells and subagents still running, by Grok's task id; a cancel report omits them. */ + grokBackgroundTasksByPaneKey: Map> /** Muse child-session filter and session-log cursor per pane. */ musePaneStateByPaneKey: Map /** Client-observed OpenCode1 session ownership replaces frozen shared-server stamps before ingest. This map names owners, not status claims. */ @@ -143,6 +146,7 @@ export function createHookListenerState( codexHeldEphemeralStartByPaneKey: new Map(), grokActiveTurnByPaneKey: new Map(), grokMainAgentStatusByPaneKey: new Map(), + grokBackgroundTasksByPaneKey: new Map(), musePaneStateByPaneKey: new Map(), opencodeSessionPaneBySessionId: new Map(), lastLaunchTokenByPaneKey: new Map(), @@ -239,6 +243,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.codexHeldEphemeralStartByPaneKey.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) state.grokMainAgentStatusByPaneKey.delete(paneKey) + state.grokBackgroundTasksByPaneKey.delete(paneKey) state.musePaneStateByPaneKey.delete(paneKey) unbindOpenCodeSessionsOfPane(state, paneKey) deletePaneScopedCacheEntry(state.lastLaunchTokenByPaneKey, paneKey) @@ -290,6 +295,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.codexHeldEphemeralStartByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokActiveTurnByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokMainAgentStatusByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.grokBackgroundTasksByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.musePaneStateByPaneKey, fromPaneKey, toPaneKey) moveOpenCodeSessionBindings(state, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.lastLaunchTokenByPaneKey, fromPaneKey, toPaneKey) @@ -305,6 +311,7 @@ export function clearPaneTurnCacheState(state: HookListenerState, paneKey: strin state.ampCompletedCacheKeys.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) state.grokMainAgentStatusByPaneKey.delete(paneKey) + state.grokBackgroundTasksByPaneKey.delete(paneKey) } export function clearAllListenerCaches(state: HookListenerState): void { @@ -332,6 +339,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.codexHeldEphemeralStartByPaneKey.clear() state.grokActiveTurnByPaneKey.clear() state.grokMainAgentStatusByPaneKey.clear() + state.grokBackgroundTasksByPaneKey.clear() state.musePaneStateByPaneKey.clear() state.opencodeSessionPaneBySessionId.clear() state.lastLaunchTokenByPaneKey.clear() diff --git a/src/shared/agent-hook-listener/pane-provider-session.ts b/src/shared/agent-hook-listener/pane-provider-session.ts index 2b30227dcad..22235d2bd38 100644 --- a/src/shared/agent-hook-listener/pane-provider-session.ts +++ b/src/shared/agent-hook-listener/pane-provider-session.ts @@ -6,6 +6,7 @@ import { type AgentProviderSessionMetadata } from '../agent-session-resume' import type { HookListenerState } from './listener-state' +import { isGrokSubagentEvent } from './providers/grok-tool-fields' import { withHermesPaneSessionId } from './providers/hermes-events' import { readString } from './tool-input-preview' @@ -19,6 +20,11 @@ export function readPaneProviderSession( if (source === 'codex' && readString(hookPayload, 'agent_id')) { return null } + if (source === 'grok' && isGrokSubagentEvent(hookPayload)) { + // A Grok child's session id is its own; the only child row it emits restates the pane's session. + const previous = state.lastStatusByPaneKey.get(paneKey) + return previous?.source === 'grok' ? (previous.providerSession ?? null) : null + } const providerSession = extractAgentProviderSession( source, source === 'hermes' ? withHermesPaneSessionId(state, paneKey, hookPayload) : hookPayload diff --git a/src/shared/agent-hook-listener/providers/grok-background-tasks.ts b/src/shared/agent-hook-listener/providers/grok-background-tasks.ts new file mode 100644 index 00000000000..a2869bebfca --- /dev/null +++ b/src/shared/agent-hook-listener/providers/grok-background-tasks.ts @@ -0,0 +1,116 @@ +import type { AgentChildWorkKind } from '../../agent-status-child-work' +import { + agentChildWorkLiveness, + type AgentChildWorkLiveness +} from '../../agent-status-child-work-liveness' +import { isRecord } from '../../agent-status-child-work-value-guards' +import type { HookListenerState } from '../listener-state' +import { isGrokEvent } from '../provider-event-names' +import { readString } from '../tool-input-preview' +import { getGrokNotificationType } from './grok-tool-fields' + +// Grok's own task ids per pane, since StopCancelled carries no inventory. A child session's id is its +// subagent's task id; a finished shell is named only inside its task_complete message. + +/** Grok lists only in-flight finite tasks; exclude indefinite monitors so they cannot suppress completion forever. */ +export function grokFiniteTaskKind(task: unknown): AgentChildWorkKind | null { + if (!isRecord(task)) { + return null + } + return task.type === 'subagent' ? 'agent' : task.type === 'shell' ? 'command' : null +} + +/** The inventory a Grok turn end reports; Stop carries it, StopCancelled (1.0.70) does not. */ +export function readGrokInventory(hookPayload: Record): unknown[] | undefined { + const value = Object.hasOwn(hookPayload, 'backgroundTasks') + ? hookPayload.backgroundTasks + : hookPayload.background_tasks + return Array.isArray(value) ? value : undefined +} + +function startedShellTaskId(hookPayload: Record): string | undefined { + const result = hookPayload.toolResult ?? hookPayload.tool_response + return isRecord(result) && result.type === 'BackgroundTaskStarted' + ? readString(result, 'task_id') + : undefined +} + +function addTask( + state: HookListenerState, + paneKey: string, + id: string, + kind: AgentChildWorkKind +): void { + const tasks = state.grokBackgroundTasksByPaneKey.get(paneKey) ?? new Map() + state.grokBackgroundTasksByPaneKey.set(paneKey, tasks.set(id, kind)) +} + +/** Returns true when a child session the pane was tracking ends. */ +export function trackGrokChildSession( + state: HookListenerState, + paneKey: string, + eventName: unknown, + hookPayload: Record +): boolean { + const sessionId = readString(hookPayload, 'sessionId') ?? readString(hookPayload, 'session_id') + if (!sessionId) { + return false + } + if (isGrokEvent(eventName, 'user_prompt_submit')) { + addTask(state, paneKey, sessionId, 'agent') + } + return ( + isGrokEvent(eventName, 'session_end') && + state.grokBackgroundTasksByPaneKey.get(paneKey)?.delete(sessionId) === true + ) +} + +export function trackGrokMainTasks( + state: HookListenerState, + paneKey: string, + eventName: unknown, + hookPayload: Record +): void { + const inventory = isGrokEvent(eventName, 'stop', 'stop_failure', 'stop_cancelled') + ? readGrokInventory(hookPayload) + : undefined + if (inventory) { + state.grokBackgroundTasksByPaneKey.delete(paneKey) + for (const task of inventory) { + const kind = grokFiniteTaskKind(task) + const id = isRecord(task) ? readString(task, 'id') : undefined + if (kind && id) { + addTask(state, paneKey, id, kind) + } + } + return + } + const shellId = isGrokEvent(eventName, 'post_tool_use') + ? startedShellTaskId(hookPayload) + : undefined + if (shellId) { + addTask(state, paneKey, shellId, 'command') + return + } + const tasks = state.grokBackgroundTasksByPaneKey.get(paneKey) + if ( + tasks && + isGrokEvent(eventName, 'notification') && + isGrokEvent(getGrokNotificationType(hookPayload), 'task_complete') + ) { + const message = readString(hookPayload, 'message') ?? '' + for (const id of tasks.keys()) { + if (message.includes(id)) { + tasks.delete(id) + } + } + } +} + +export function grokBackgroundTaskLiveness( + state: HookListenerState, + paneKey: string +): AgentChildWorkLiveness { + const tasks = state.grokBackgroundTasksByPaneKey.get(paneKey) + return agentChildWorkLiveness(Array.from(tasks?.values() ?? [], (kind) => ({ kind }))) +} diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index a03ea9a7d19..7ddbddf51f0 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -4,7 +4,6 @@ import { } from '../../agent-status-types' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' import { continueMainAgentStatus, foldAgentLeadStatus } from '../../agent-lead-status-fold' -import type { AgentChildWorkKind } from '../../agent-status-child-work' import { agentChildWorkLiveness, type AgentChildWorkLiveness, @@ -16,8 +15,16 @@ import { resolvePrompt, resolveToolState, stripGrokUserQueryWrapper } from '../p import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import { readString } from '../tool-input-preview' import { isGrokEvent } from '../provider-event-names' +import { + grokBackgroundTaskLiveness, + grokFiniteTaskKind, + readGrokInventory, + trackGrokChildSession, + trackGrokMainTasks +} from './grok-background-tasks' import { getGrokNotificationType, + isGrokSubagentEvent, isGrokPermissionNotification, isGrokRoutinePermissionPromptNotification } from './grok-tool-fields' @@ -36,10 +43,6 @@ function aliasedField( return { present: false } } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - function grokIdentityField( hookPayload: Record, primary: string, @@ -49,13 +52,6 @@ function grokIdentityField( return value && value.length <= 512 ? value : undefined } -function isGrokSubagentEvent(hookPayload: Record): boolean { - return ( - readString(hookPayload, 'subagentType') !== undefined || - readString(hookPayload, 'subagent_type') !== undefined - ) -} - function grokPromptId(hookPayload: Record): string | undefined { return normalizeGrokPromptId(hookPayload.promptId ?? hookPayload.prompt_id) } @@ -97,22 +93,10 @@ function grokTurnEndApplies( ) } -/** Grok lists only in-flight finite tasks; exclude indefinite monitors so they cannot suppress completion forever. */ -function grokFiniteTaskKind(task: unknown): AgentChildWorkKind | null { - if (!isRecord(task)) { - return null - } - return task.type === 'subagent' ? 'agent' : task.type === 'shell' ? 'command' : null -} - function grokRunningFiniteTasks( hookPayload: Record ): AgentChildWorkLivenessCandidate[] { - const backgroundTasks = aliasedField(hookPayload, 'backgroundTasks', 'background_tasks') - if (!backgroundTasks.present || !Array.isArray(backgroundTasks.value)) { - return [] - } - return backgroundTasks.value.flatMap((task) => { + return (readGrokInventory(hookPayload) ?? []).flatMap((task) => { const kind = grokFiniteTaskKind(task) return kind ? [{ kind }] : [] }) @@ -145,11 +129,18 @@ export function normalizeGrokEvent( hookPayload: Record, grokHome?: string ): ParsedAgentStatusPayload | null { - // Why: child sessions reuse their parent's pane route; their lifecycle cannot settle the parent. - if (isGrokSubagentEvent(hookPayload)) { - return null - } - if (isGrokEvent(eventName, 'session_start')) { + const isChild = isGrokSubagentEvent(hookPayload) + const previousMainAgent = state.grokMainAgentStatusByPaneKey.get(paneKey) + // Why: Grok starts no turn when a shell outlives a cancel, so task ends must settle that row here. + const afterCancel = + previousMainAgent?.state === 'done' && previousMainAgent.outcome === 'cancellation' + if (isChild) { + const trackedChildEnded = trackGrokChildSession(state, paneKey, eventName, hookPayload) + // Why: child sessions reuse their parent's pane route; only a tracked child's end restates a cancelled parent. + if (!afterCancel || !trackedChildEnded) { + return null + } + } else if (isGrokEvent(eventName, 'session_start')) { // SessionStart clears stale turn/main-agent clocks but asserts no work before activity. clearPaneTurnCacheState(state, paneKey) return null @@ -173,10 +164,16 @@ export function normalizeGrokEvent( const isTurnEnd = isGrokEvent(eventName, 'stop', 'stop_failure', 'stop_cancelled') const isIdlePrompt = isGrokEvent(eventName, 'notification') && isGrokEvent(notificationType, 'idle_prompt') - const sessionBoundary = isGrokSessionBoundary(eventName, hookPayload) + const sessionBoundary = !isChild && isGrokSessionBoundary(eventName, hookPayload) if (isTurnEnd && !grokTurnEndApplies(state, paneKey, hookPayload)) { return null } + if (!isChild) { + trackGrokMainTasks(state, paneKey, eventName, hookPayload) + } + if (sessionBoundary) { + state.grokBackgroundTasksByPaneKey.delete(paneKey) + } let leadState: 'working' | 'waiting' | 'done' | null = null if ( isGrokEvent(eventName, 'user_prompt_submit', 'post_tool_use', 'post_tool_use_failure') || @@ -191,8 +188,11 @@ export function normalizeGrokEvent( isGrokEvent(eventName, 'notification') && isGrokEvent(notificationType, 'task_complete') ) { - // Why: one task finishing does not prove that every finite task and follow-up turn settled. - return null + // Why: after a normal turn Grok wakes itself and restates its inventory when a task ends. + if (!afterCancel) { + return null + } + leadState = 'done' } else if ( isGrokEvent(eventName, 'notification') && isGrokRoutinePermissionPromptNotification( @@ -217,14 +217,20 @@ export function normalizeGrokEvent( : isGrokEvent(eventName, 'stop_failure') ? ('failure' as const) : undefined - // Cancel/failure retains reported tasks until their own end; only session boundaries settle independently of inventory. + // A cancel report omits the inventory, so it and every later restatement of that cancel read the tracked tasks. + const readsTrackedTasks = + isGrokEvent(eventName, 'stop_cancelled') || (afterCancel && !isTurnEnd && leadState === 'done') const resolution = foldAgentLeadStatus({ leadState, - childWorkLiveness: - isTurnEnd && !sessionBoundary ? grokChildWorkLivenessAfterTurnEnd(hookPayload) : null + childWorkLiveness: sessionBoundary + ? null + : readsTrackedTasks + ? grokBackgroundTaskLiveness(state, paneKey) + : isTurnEnd + ? grokChildWorkLivenessAfterTurnEnd(hookPayload) + : null }) const stateName = resolution.stateName - const previousMainAgent = state.grokMainAgentStatusByPaneKey.get(paneKey) // Why: an idle prompt or session end restates the same finished turn, so its verdict stands. const mainAgentOutcome = outcome ?? @@ -241,14 +247,13 @@ export function normalizeGrokEvent( const snapshot = resolveToolState( state, paneKey, - extractToolFields('grok', eventName, hookPayload, { grokHome }), + isChild ? {} : extractToolFields('grok', eventName, hookPayload, { grokHome }), { resetOnNewTurn: isNewTurnEvent('grok', eventName) } ) // Why: Grok Notification.message is status UI text, not the prompt; '' preserves the cached UserPromptSubmit. - const effectivePrompt = isGrokEvent(eventName, 'notification') - ? '' - : stripGrokUserQueryWrapper(promptText) + const effectivePrompt = + isChild || isGrokEvent(eventName, 'notification') ? '' : stripGrokUserQueryWrapper(promptText) return normalizeAgentStatusPayload({ state: stateName, diff --git a/src/shared/agent-hook-listener/providers/grok-tool-fields.ts b/src/shared/agent-hook-listener/providers/grok-tool-fields.ts index 38b8ba4af50..fab7c0b962c 100644 --- a/src/shared/agent-hook-listener/providers/grok-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/grok-tool-fields.ts @@ -107,6 +107,13 @@ export function isGrokPermissionNotification(message: string | undefined): boole ) } +export function isGrokSubagentEvent(hookPayload: Record): boolean { + return ( + readString(hookPayload, 'subagentType') !== undefined || + readString(hookPayload, 'subagent_type') !== undefined + ) +} + export function getGrokNotificationType(hookPayload: Record): string | undefined { return ( readString(hookPayload, 'notificationType') ??