From bbb7f2d695bdf8614ecc35c06dcfca219cd809ee Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:34:00 +0000 Subject: [PATCH] fix(agent-status): keep Grok background work visible after a cancel (#35495) StopCancelled carries no backgroundTasks, so a cancelled Grok turn settled done while the shells and subagents it left running kept going. Track Grok's own task ids per pane and settle the cancelled row on each task's own end. --- .../grok-cancel-background-hooks.jsonl | 36 ++++++ ...ok-listener-grok-cancel-background.test.ts | 99 +++++++++++++++ ...gent-hook-listener-grok-completion.test.ts | 29 ----- .../agent-hook-listener/listener-state.ts | 8 ++ .../pane-provider-session.ts | 6 + .../providers/grok-background-tasks.ts | 116 ++++++++++++++++++ .../providers/grok-events.ts | 87 ++++++------- .../providers/grok-tool-fields.ts | 7 ++ 8 files changed, 318 insertions(+), 70 deletions(-) create mode 100644 src/shared/__fixtures__/grok-cancel-background-hooks.jsonl create mode 100644 src/shared/agent-hook-listener-grok-cancel-background.test.ts create mode 100644 src/shared/agent-hook-listener/providers/grok-background-tasks.ts 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') ??