diff --git a/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts b/src/main/agent-hooks/agent-cancel-capture.test-fixture.ts similarity index 90% rename from src/main/agent-hooks/claude-cancel-capture.test-fixture.ts rename to src/main/agent-hooks/agent-cancel-capture.test-fixture.ts index 839145a352c2..cbe380e62a5e 100644 --- a/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts +++ b/src/main/agent-hooks/agent-cancel-capture.test-fixture.ts @@ -1,6 +1,6 @@ -// Loads the Claude Code 2.1.280 cancel captures (src/shared/__fixtures__/claude-cancel-*-hooks.jsonl, -// sidecars beside them): hook payloads recorded over a real PTY, merged in time order with the -// driver's cancel and kill markers. +// Loads the agent-CLI cancel captures (src/shared/__fixtures__/claude-cancel-*-hooks.jsonl and +// grok-cancel-*-hooks.jsonl, sidecars beside them): hook payloads recorded over a real PTY, +// merged in time order with the driver's cancel and kill markers. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { AGENT_INTERRUPT_SETTLE_MS } from '../../shared/agent-interrupt-intent' diff --git a/src/main/agent-hooks/server-claude-cancel-captures.test.ts b/src/main/agent-hooks/server-claude-cancel-captures.test.ts index 196c41fbea7f..59ae09dca505 100644 --- a/src/main/agent-hooks/server-claude-cancel-captures.test.ts +++ b/src/main/agent-hooks/server-claude-cancel-captures.test.ts @@ -17,7 +17,7 @@ import { hookSupersedesCancel, loadCapture, type CapturedHook -} from './claude-cancel-capture.test-fixture' +} from './agent-cancel-capture.test-fixture' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), diff --git a/src/main/agent-hooks/server-grok-cancel-captures.test.ts b/src/main/agent-hooks/server-grok-cancel-captures.test.ts new file mode 100644 index 000000000000..2e5e9a728b9d --- /dev/null +++ b/src/main/agent-hooks/server-grok-cancel-captures.test.ts @@ -0,0 +1,220 @@ +// A cancel never hides live work. These stories replay hook payloads recorded from Grok 1.0.41 +// over a real PTY (src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl, sidecar +// beside it) through the server's own HTTP ingress, and stand in for the renderer at the moments +// the driver pressed a cancel key. The capture established that Esc mid-turn fires no hook (the +// turn keeps running), that Ctrl+C with subagents running opens a dialog and fires nothing until +// answered, that `stop_cancelled` carries NO backgroundTasks inventory, and that a killed +// subagent's only trace is its own SessionEnd (sessionId equal to SubagentStart's subagentId); +// the rules below are written against those payloads, not a remembered screen. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer, _internals } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' +import { + cancelLabelled, + hookAt, + loadCapture, + type CapturedHook +} from './agent-cancel-capture.test-fixture' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => vi.restoreAllMocks()) + +const records = loadCapture('grok-cancel-subagent-dialog-hooks') + +async function startServer(): Promise { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + return server +} + +function row(server: AgentHookServer) { + const entry = server.getStatusSnapshotForPane(PANE)[0] + if (!entry) { + throw new Error('the pane has no row') + } + return entry +} + +async function post(server: AgentHookServer, hook: CapturedHook): Promise { + await expect(postHookEvent(server, buildBody(hook.payload), '/hook/grok')).resolves.toMatchObject( + { status: 204 } + ) +} + +async function replay(server: AgentHookServer, indices: number[]): Promise { + for (const index of indices) { + await post(server, hookAt(records, index)) + } +} + +function shellTaskId(payload: Record): string { + const tasks = payload.backgroundTasks + const shell = Array.isArray(tasks) ? tasks.find((task) => task?.type === 'shell') : undefined + if (typeof shell?.id !== 'string') { + throw new Error('the capture lists no shell task') + } + return shell.id +} + +/** The renderer's part of a cancel: capture the row as the baseline and ask the server to infer + * the interrupt, exactly as it would after the settle window. */ +function pressCancelKey(server: AgentHookServer, intent: 'plain-escape' | 'ctrl-c'): boolean { + const baseline = row(server) + return server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'grok', + intent + }) +} + +describe('a Grok cancel with a background shell and subagent (captured)', () => { + it('never marks the row from a keypress, and folds every turn end with the reported inventory', async () => { + const server = await startServer() + try { + // Turn 1 starts a background shell and a background subagent, then ends. Its `stop` + // (hook 8) lists both; the subagent is agent work, so the settled lead reads working. + await replay(server, [0, 1, 2, 3, 4, 5, 6, 7, 8]) + expect(hookAt(records, 8).payload.backgroundTasks).toMatchObject([ + { type: 'shell', status: 'running' }, + { type: 'subagent', status: 'running' } + ]) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'done' } }) + + // Turn 2 runs a foreground command; the lead turn owns the row again. + await replay(server, [9, 10, 11, 12]) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + + // Esc mid-turn: the capture shows no main-session hook and live processes — the turn kept + // running. The renderer's plain-escape inference must be refused. + const escCancel = cancelLabelled(records, 'ESC-mid-turn') + expect(escCancel.interrupted_painted).toBe(false) + const beforeEsc = row(server) + expect(pressCancelKey(server, 'plain-escape')).toBe(false) + expect(row(server)).toEqual(beforeEsc) + + // Ctrl+C mid-turn: Grok opened its "Subagents are still running" dialog and fired nothing. + // The keypress proves nothing either way; the dialog was answered "Continue to run". + expect(pressCancelKey(server, 'ctrl-c')).toBe(false) + expect(row(server)).toEqual(beforeEsc) + + // The subagent's own poll (hook 13) maps to nothing. + await replay(server, [13]) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + + // Grok's own stop_cancelled (hook 14) carries NO inventory; ps in the capture shows the + // shell and subagent still alive. The cancel verdict lands on the main agent while the + // inventory Grok last reported keeps the row working. + expect(hookAt(records, 14).payload).not.toHaveProperty('backgroundTasks') + expect(hookAt(records, 14).sleep_procs).toHaveLength(2) + await replay(server, [14]) + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + // Why: `interrupted` restates the verdict only on a settled row; the held-open row carries it on `mainAgent.outcome`. + expect(row(server).interrupted).toBeUndefined() + expect(row(server).workingMode).toBeUndefined() + } finally { + server.stop() + } + }) + + it("a killed subagent's own SessionEnd re-derives the row the inventory held open", async () => { + const server = await startServer() + try { + await replay(server, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]) + + // Turn 3, then Ctrl+C answered "Stop running" (default): the same inventory-less + // stop_cancelled. Grok has not yet told the pane the subagent died, so the row still works. + await replay(server, [15, 16]) + expect(pressCancelKey(server, 'ctrl-c')).toBe(false) + await replay(server, [17]) + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + // The child's own task_complete (hook 18) names a task the pane's inventory never held. + await replay(server, [18]) + expect(row(server)).toMatchObject({ state: 'working' }) + + // The subagent's SessionEnd (hook 19) drops it from the inventory; only the background + // shell remains, so the cancelled row re-derives to monitoring — through the cancel-verdict + // hold, which releases on Grok's own settled main agent. The child's session id must not + // replace the pane's resume identity. + expect(hookAt(records, 19).payload.subagentType).toBe('general-purpose') + await replay(server, [19]) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(row(server).providerSession?.id).toBe(hookAt(records, 8).payload.session_id) + + // The main session's own end settles the pane whatever the inventory says. + await replay(server, [20]) + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + } finally { + server.stop() + } + }) + + // Why: a cancel arms Grok's wake barrier, so the surviving shell's end runs no follow-up turn + // and no `stop`; its own task_complete Notification is the only hook that says it is gone. + it("the surviving shell's own task_complete settles the cancelled row", async () => { + const server = await startServer() + try { + await replay( + server, + Array.from({ length: 20 }, (_, index) => index) + ) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + // Grok's task_complete for the main session's shell: the captured child notification's + // shape (hook 18), re-addressed to the main session and the shell hook 8 listed. + const main = hookAt(records, 17).payload + const shellId = shellTaskId(hookAt(records, 8).payload) + await post(server, { + ...hookAt(records, 18), + payload: { + ...hookAt(records, 18).payload, + sessionId: main.sessionId, + session_id: main.session_id, + transcriptPath: main.transcriptPath, + transcript_path: main.transcript_path, + message: `Background task completed: ${shellId}` + } + }) + expect(row(server)).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(row(server).workingMode).toBeUndefined() + expect(row(server).providerSession?.id).toBe(main.session_id) + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server-grok-cancel.test.ts b/src/main/agent-hooks/server-grok-cancel.test.ts index 62fd17495b4b..b13a69a96be6 100644 --- a/src/main/agent-hooks/server-grok-cancel.test.ts +++ b/src/main/agent-hooks/server-grok-cancel.test.ts @@ -1,5 +1,9 @@ -// Grok 1.0.41, measured: Ctrl+C mid-turn fires `stop_cancelled` listing the finite tasks the turn -// left running, and Ctrl+C at the idle prompt kills no background task. +// Grok 1.0.41, measured (src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl): +// no keypress cancels a Grok turn by itself — Esc only paints a toast, and Ctrl+C with subagents +// running opens a dialog that can be answered "continue" — so Orca never infers a Grok cancel +// from keys. Every real cancel fires Grok's own `stop_cancelled`, which carries NO backgroundTasks +// inventory, so it folds with the inventory Grok last reported. Ctrl+C at the idle prompt kills +// no background task. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' import { buildBody, PANE } from './server.test-fixtures' @@ -93,7 +97,7 @@ describe('a Grok cancel never hides a running task', () => { } }) - it("shows the task when Grok's own cancel hook trails the inferred cancel", async () => { + it('refuses a key-inferred cancel mid-turn and folds the inventory-less stop_cancelled', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) try { @@ -105,24 +109,162 @@ describe('a Grok cancel never hides a running task', () => { }) expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) - // Why: the inference can win the settle race; the row cannot see the task behind a working main agent. - expect(pressCtrlC(server)).toBe(true) + // Why: Ctrl+C mid-turn can open Grok's subagents dialog and cancel nothing; only Grok's + // own hook is cancel evidence. + const before = row(server) + expect(pressCtrlC(server)).toBe(false) + expect(row(server)).toEqual(before) + + // Grok's real stop_cancelled carries no backgroundTasks key; the fold keeps the + // inventory the last `stop` reported instead of settling the row. + await postGrokHook(server, { + hookEventName: 'stop_cancelled', + promptId: 'prompt-2', + reason: 'user_interrupt', + cancelledBy: 'user', + cancelTrigger: 'ctrl_c' + }) + expect(row(server)).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + // Why: `interrupted` restates the verdict only on a settled row; a held-open row carries it on `mainAgent.outcome`. + expect(row(server).interrupted).toBeUndefined() + } finally { + server.stop() + } + }) + + // Measured live: a subagent spawned in the very turn that gets cancelled appears in NO stop + // inventory yet; only its SubagentStart hook can put it in the fold. + it('folds a subagent the cancelled turn itself spawned, and settles when it ends', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await postGrokHook(server, { + hookEventName: 'user_prompt_submit', + promptId: 'prompt-1', + prompt: 'spawn a subagent then run a command' + }) + await postGrokHook(server, { + hookEventName: 'subagent_start', + subagentId: 'sub-1', + subagentType: 'general-purpose' + }) + await postGrokHook(server, { + hookEventName: 'stop_cancelled', + promptId: 'prompt-1', + reason: 'user_interrupt', + cancelledBy: 'user', + cancelTrigger: 'ctrl_c' + }) + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + + // The subagent's own end (child session id equals the subagentId) settles the row. + await postGrokHook(server, { + hookEventName: 'session_end', + reason: 'shutdown', + subagentType: 'general-purpose', + sessionId: 'sub-1' + }) expect(row(server)).toMatchObject({ state: 'done', + interrupted: true, mainAgent: { state: 'done', outcome: 'cancellation' } }) + } finally { + server.stop() + } + }) + + // Grok 1.0.41: a shell started in the turn that gets cancelled is in no `stop` inventory yet, + // survives the cancel, and its end wakes no follow-up turn; only its own start and end hooks + // bracket it. Shapes copied from the captured PostToolUse (hook 7) and task_complete (hook 18). + it('folds a shell the cancelled turn itself started, and settles on its task_complete', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await postGrokHook(server, { + hookEventName: 'user_prompt_submit', + promptId: 'prompt-1', + prompt: 'start a background shell then run a command' + }) + const started = { + type: 'BackgroundTaskStarted', + task_id: 'task-7', + task_type: 'bash', + status: 'running', + command: 'sleep 375' + } + await postGrokHook(server, { + hookEventName: 'post_tool_use', + toolName: 'run_terminal_command', + toolUseId: 'call-1', + toolInput: { command: 'sleep 375', background: true }, + toolResult: started, + hook_event_name: 'PostToolUse', + tool_name: 'run_terminal_command', + tool_response: started + }) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) + expect(row(server).workingMode).toBeUndefined() await postGrokHook(server, { hookEventName: 'stop_cancelled', - promptId: 'prompt-2', - stopHookActive: false, - backgroundTasks: [RUNNING_TASK] + promptId: 'prompt-1', + reason: 'user_interrupt', + cancelledBy: 'user', + cancelTrigger: 'ctrl_c' }) expect(row(server)).toMatchObject({ state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done', outcome: 'cancellation' } }) + + await postGrokHook(server, { + hookEventName: 'notification', + notificationType: 'task_complete', + message: 'Background task completed: task-7', + level: 'info' + }) + expect(row(server)).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + } finally { + server.stop() + } + }) +}) + +describe('a plain Grok stop a task holds open', () => { + it('earns its turn stamp at the idle restatement and pairs the all-clear with it', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await startTaskThenSettle(server) + expect(row(server).turnCompletedAt).toBeUndefined() + + await postGrokHook(server, { hookEventName: 'notification', notificationType: 'idle_prompt' }) + expect(row(server)).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + const turnCompletedAt = row(server).turnCompletedAt + expect(turnCompletedAt).toEqual(expect.any(Number)) + expect(turnCompletedAt).toBe(row(server).mainAgent?.stateStartedAt) + + await postGrokHook(server, { + hookEventName: 'notification', + notificationType: 'task_complete', + message: 'Background task completed: task-1', + level: 'info' + }) + expect(row(server)).toMatchObject({ state: 'done', turnCompletedAt }) + expect(row(server).interrupted).toBeUndefined() } finally { server.stop() } diff --git a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts index ce93f46f8861..11d9deb08d82 100644 --- a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RelayAgentHookServer } from '../../relay/agent-hook-server' import { AgentHookServer, _internals } from './server' import { buildBody, PANE } from './server.test-fixtures' -import { hookAt, loadCapture, type CapturedHook } from './claude-cancel-capture.test-fixture' +import { hookAt, loadCapture, type CapturedHook } from './agent-cancel-capture.test-fixture' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 7dbe26208900..5973a76ffeea 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -6,6 +6,7 @@ import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listene import { isAgentInterruptInputIntent, isNavigationEscapeIntent, + providerReportsOwnCancel, requiresDoubleEscapeInterrupt, type AgentInterruptInferenceRequest } from '../../../shared/agent-interrupt-intent' @@ -46,6 +47,11 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO if (agentType === 'droid' && request.intent === 'ctrl-c') { return false } + // Why: re-checked here, not only in the renderer, so a stale or direct inference request + // cannot fabricate a cancel onto an agent whose own hook is the only cancel evidence. + if (providerReportsOwnCancel(agentType)) { + return false + } // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. if (requiresDoubleEscapeInterrupt(agentType, request.intent) && request.inputCount !== 2) { return false diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index a37cbb02eb44..ee376b928112 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -99,17 +99,22 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA ) } : terminalOwnedPayload - const previousCodexRoot = - stateReconciledPayload.payload.agentType === 'codex' && + const childAttributedRootAgentType = + stateReconciledPayload.payload.agentType === 'codex' || + stateReconciledPayload.payload.agentType === 'grok' + ? stateReconciledPayload.payload.agentType + : undefined + const previousChildAttributedRoot = + childAttributedRootAgentType && stateReconciledPayload.toolAgentId && - previous?.payload.agentType === 'codex' + previous?.payload.agentType === childAttributedRootAgentType ? previous : undefined const preservedProviderSession = !stateReconciledPayload.providerSession - ? previousCodexRoot?.providerSession + ? previousChildAttributedRoot?.providerSession : undefined const preservedRootModel = !stateReconciledPayload.payload.model - ? previousCodexRoot?.payload.model + ? previousChildAttributedRoot?.payload.model : undefined // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. const rootContextPreservingPayload = diff --git a/src/main/grok/grok-hook-config.ts b/src/main/grok/grok-hook-config.ts index 5482ba0accd4..f35d55e104d0 100644 --- a/src/main/grok/grok-hook-config.ts +++ b/src/main/grok/grok-hook-config.ts @@ -15,6 +15,10 @@ export const GROK_EVENTS = [ { eventName: 'StopCancelled', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: the background-task inventory needs a subagent's start and end; a cancelled turn's + // stop_cancelled carries no inventory, and a killed subagent's only other trace is SessionEnd. + { eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } }, { // Why: Orca needs the pre-event to show in-flight tools and detect ask_user_question waits; // PostToolUse arrives only after both states have ended. diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index fe6d16a46647..5a89947c5cc8 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -256,6 +256,8 @@ describe('GrokHookService', () => { 'Stop', 'StopCancelled', 'StopFailure', + 'SubagentStart', + 'SubagentStop', 'UserPromptSubmit' ].sort() ) @@ -270,6 +272,9 @@ describe('GrokHookService', () => { expect(config.hooks.StopCancelled[0].matcher).toBeUndefined() expect(config.hooks.StopFailure[0].matcher).toBeUndefined() expect(config.hooks.Notification[0].matcher).toBeUndefined() + // Why: a Subagent matcher tests the subagent type; the inventory needs every one. + expect(config.hooks.SubagentStart[0].matcher).toBeUndefined() + expect(config.hooks.SubagentStop[0].matcher).toBeUndefined() // Why: assert the shipped helper still matches what install wrote (regression // guard if GROK_TOOL_EVENT_MATCHER drifts from install). expect(getGrokToolEventMatcherForTests()).toBe('.*') diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts index 920b9f55405d..a160ecbb5338 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts @@ -216,6 +216,27 @@ describe('agent interrupt inference', () => { entry = undefined }) + // Why: Grok reports its own cancels (stop_cancelled); Esc never cancels and Ctrl+C can open a + // dialog that keeps the turn running, so neither keypress may request an inference. + it.each(['plain-escape', 'ctrl-c'] as const)('does not infer %s for Grok', (intent) => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'grok' }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent(intent) + vi.advanceTimersByTime(500) + + expect(inferInterrupt).not.toHaveBeenCalled() + tracker.dispose() + entry = undefined + }) + it.each(['opencode', 'opencode2', 'copilot'] as const)( 'infers immediately on double Escape for %s', (agentType) => { diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts index 0b39565b35c4..c5768411c201 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts @@ -5,6 +5,7 @@ import { import { AGENT_INTERRUPT_SETTLE_MS, isNavigationEscapeIntent, + providerReportsOwnCancel, requiresDoubleEscapeInterrupt, type AgentInterruptInferenceRequest, type AgentInterruptInputIntent @@ -54,7 +55,9 @@ function shouldIgnoreInterruptIntent( agentType: AgentStatusEntry['agentType'], intent: AgentInterruptInputIntent ): boolean { - return agentType === 'droid' && intent === 'ctrl-c' + // Why: Droid's Ctrl+C exits the CLI (PTY lifecycle handles it); an agent that reports its own + // cancels leaves nothing for a keypress to prove on either intent. + return (agentType === 'droid' && intent === 'ctrl-c') || providerReportsOwnCancel(agentType) } /** Why: skip a round-trip main will refuse anyway. Scoped to 'working' so Claude's diff --git a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts index 256ae62d0581..aa7081d6f24b 100644 --- a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts @@ -58,87 +58,90 @@ function pinStateStartedAt( } } -describe('Claude background-turn completion notifications', () => { - beforeEach(() => { - vi.resetModules() - vi.useFakeTimers() - vi.setSystemTime(1_700_000_000_000) - dispatchTerminalNotification.mockClear() - dispatchAgentHookTerminalLifecycle.mockClear() - mockStoreState = { - settings: { - experimentalTerminalAttention: false, - notifications: { - enabled: true, - agentTaskComplete: true - } - }, - ptyIdsByTabId: { - 'tab-1': ['pty-1'] - }, - suppressedPtyExitIds: {}, - tabsByWorktree: { - [WORKTREE_ID]: [{ id: 'tab-1', ptyId: 'pty-1' }] - }, - terminalLayoutsByTabId: {}, - agentLaunchConfigByPaneKey: {}, - agentStatusByPaneKey: {}, - getAgentLaunchConfigForStatusEntry: () => undefined - } - }) +beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) + dispatchTerminalNotification.mockClear() + dispatchAgentHookTerminalLifecycle.mockClear() + mockStoreState = { + settings: { + experimentalTerminalAttention: false, + notifications: { + enabled: true, + agentTaskComplete: true + } + }, + ptyIdsByTabId: { + 'tab-1': ['pty-1'] + }, + suppressedPtyExitIds: {}, + tabsByWorktree: { + [WORKTREE_ID]: [{ id: 'tab-1', ptyId: 'pty-1' }] + }, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {}, + getAgentLaunchConfigForStatusEntry: () => undefined + } +}) - afterEach(() => { - vi.useRealTimers() - }) +afterEach(() => { + vi.useRealTimers() +}) - async function play(events: { at: number; payload: Record }[]): Promise<{ - banners: { at: number; body: string | undefined; stateStartedAt: number | undefined }[] - rows: HookRow[] - }> { - const { observeAgentHookCompletionForNotification } = - await import('./agent-hook-completion-notifications') - const listener = createHookListenerState() - const rows: HookRow[] = [] - let previous: HookRow | undefined - const banners: { - at: number - body: string | undefined - stateStartedAt: number | undefined - }[] = [] +async function play( + events: { at: number; payload: Record }[], + source: 'claude' | 'grok' = 'claude' +): Promise<{ + banners: { at: number; body: string | undefined; stateStartedAt: number | undefined }[] + rows: HookRow[] +}> { + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') + const listener = createHookListenerState() + const rows: HookRow[] = [] + let previous: HookRow | undefined + const banners: { + at: number + body: string | undefined + stateStartedAt: number | undefined + }[] = [] - dispatchTerminalNotification.mockImplementation((_worktreeId, event) => { - banners.push({ - at: Date.now(), - body: event.agentStatusSnapshot?.lastAssistantMessage, - stateStartedAt: event.agentStatusSnapshot?.stateStartedAt - }) + dispatchTerminalNotification.mockImplementation((_worktreeId, event) => { + banners.push({ + at: Date.now(), + body: event.agentStatusSnapshot?.lastAssistantMessage, + stateStartedAt: event.agentStatusSnapshot?.stateStartedAt }) + }) - for (const event of events) { - vi.setSystemTime(event.at) - const normalized = normalizeHookPayload( - listener, - 'claude', - { paneKey: PANE_KEY, payload: event.payload }, - 'production' - ) - if (!normalized) { - continue - } - const row = pinStateStartedAt(previous, normalized.payload, event.at) - previous = row - rows.push(row) - observeAgentHookCompletionForNotification({ - paneKey: PANE_KEY, - worktreeId: WORKTREE_ID, - payload: row - }) - vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + for (const event of events) { + vi.setSystemTime(event.at) + const normalized = normalizeHookPayload( + listener, + source, + { paneKey: PANE_KEY, payload: event.payload }, + 'production' + ) + if (!normalized) { + continue } - - return { banners, rows } + const row = pinStateStartedAt(previous, normalized.payload, event.at) + previous = row + rows.push(row) + observeAgentHookCompletionForNotification({ + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + payload: row + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) } + return { banners, rows } +} + +describe('Claude background-turn completion notifications', () => { it('announces a subagent turn at Stop with that turn’s text, not when the child later drains', async () => { const { banners, rows } = await play([ { @@ -267,3 +270,115 @@ describe('Claude background-turn completion notifications', () => { expect(new Set(banners.map((banner) => banner.stateStartedAt)).size).toBe(3) }) }) + +describe('Grok background-turn completion notifications', () => { + const T0 = 1_700_000_000_000 + const prompt = (at: number, promptId: string) => ({ + at, + payload: { hookEventName: 'user_prompt_submit', sessionId: 's-1', promptId, prompt: 'go' } + }) + const shellStarted = (at: number, taskId: string) => ({ + at, + payload: { + hookEventName: 'post_tool_use', + sessionId: 's-1', + toolName: 'run_terminal_cmd', + toolResult: { type: 'BackgroundTaskStarted', task_type: 'bash', task_id: taskId } + } + }) + const stop = (at: number, promptId: string, text: string, taskIds: string[]) => ({ + at, + payload: { + hookEventName: 'stop', + sessionId: 's-1', + promptId, + reason: 'end_turn', + lastAssistantMessage: text, + backgroundTasks: taskIds.map((id) => ({ id, type: 'shell', status: 'running' })) + } + }) + const notification = (at: number, notificationType: string, message = '') => ({ + at, + payload: { hookEventName: 'notification', sessionId: 's-1', notificationType, message } + }) + + // Grok wakes itself when background work ends and that woken turn announces, so a turn that + // leaves work running announces when Grok goes idle with it still running, and only once. + it('announces a turn that leaves a shell running once, when Grok goes idle', async () => { + const { banners, rows } = await play( + [ + prompt(T0, 'p1'), + shellStarted(T0 + 1_000, 'shell-1'), + stop(T0 + 5_000, 'p1', 'Dev server is up.', ['shell-1']), + // Grok's idle restatement ~60 s later, the shell still running. + notification(T0 + 65_000, 'idle_prompt'), + notification(T0 + 120_000, 'task_complete', 'Background task completed: shell-1'), + notification(T0 + 180_000, 'idle_prompt') + ], + 'grok' + ) + + expect(rows.slice(2).map((row) => [row.state, row.workingMode, row.turnCompletedAt])).toEqual([ + ['working', 'monitoring', undefined], + ['working', 'monitoring', T0 + 5_000], + ['done', undefined, T0 + 5_000], + ['done', undefined, T0 + 5_000] + ]) + expect(banners).toEqual([ + { at: T0 + 65_000, body: 'Dev server is up.', stateStartedAt: T0 + 5_000 } + ]) + }) + + it('stamps nothing on a cancelled turn a shell holds open', async () => { + const { rows } = await play( + [ + prompt(T0, 'p1'), + shellStarted(T0 + 1_000, 'shell-1'), + { + at: T0 + 5_000, + payload: { + hookEventName: 'stop_cancelled', + sessionId: 's-1', + promptId: 'p1', + reason: 'user_interrupt' + } + }, + notification(T0 + 65_000, 'idle_prompt'), + notification(T0 + 120_000, 'task_complete', 'Background task completed: shell-1') + ], + 'grok' + ) + + expect(rows.slice(2).map((row) => [row.state, row.workingMode, row.interrupted])).toEqual([ + ['working', 'monitoring', undefined], + ['working', 'monitoring', undefined], + ['done', undefined, true] + ]) + expect(rows.every((row) => row.turnCompletedAt === undefined)).toBe(true) + }) + + it('starts each new prompt unstamped, so the next turn announces on its own', async () => { + const { banners, rows } = await play( + [ + prompt(T0, 'p1'), + shellStarted(T0 + 1_000, 'shell-1'), + stop(T0 + 5_000, 'p1', 'Dev server is up.', ['shell-1']), + notification(T0 + 20_000, 'idle_prompt'), + notification(T0 + 25_000, 'task_complete', 'Background task completed: shell-1'), + prompt(T0 + 30_000, 'p2'), + stop(T0 + 35_000, 'p2', 'Route added.', []) + ], + 'grok' + ) + + expect(rows.slice(5).map((row) => [row.state, row.turnCompletedAt])).toEqual([ + ['working', undefined], + ['done', undefined] + ]) + // The unstamped done announces after the hook quiet window; the stamped one at the idle. + expect(banners.map((banner) => [banner.body, banner.stateStartedAt])).toEqual([ + ['Dev server is up.', T0 + 5_000], + ['Route added.', T0 + 35_000] + ]) + }) +}) diff --git a/src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl b/src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl new file mode 100644 index 000000000000..14e99df58ee9 --- /dev/null +++ b/src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl @@ -0,0 +1,25 @@ +{"t": 0.0, "kind": "hook", "index": 0, "sleep_procs": [], "payload": {"hookEventName": "session_start", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:20.672441+00:00", "permissionMode": "bypassPermissions", "source": "new", "hook_event_name": "SessionStart", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "permission_mode": "bypassPermissions"}} +{"t": 5.291, "kind": "hook", "index": 1, "sleep_procs": [], "payload": {"hookEventName": "user_prompt_submit", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:25.962196+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "promptId": "b6fce791-fd48-4b0e-a20c-ca8130c8c240", "permissionMode": "bypassPermissions", "prompt": "QA harness step 1: make exactly two tool calls, then end your turn. (1) run_terminal_command with background set to true and command exactly `sleep 375`. (2) spawn_subagent with run_in_background true and this exact prompt: 'Run this exact command in the foreground with run_terminal_command, wait for it to finish, then reply done: sleep 314'. Do not wait for or poll either task. Reply with exactly the word STARTED and end your turn immediately.", "hook_event_name": "UserPromptSubmit", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 12.524, "kind": "hook", "index": 2, "sleep_procs": [], "payload": {"hookEventName": "pre_tool_use", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:33.153978+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "run_terminal_command", "toolUseId": "call-73a5d7da-7209-472d-acd6-7776cf81251d-0", "toolInput": {"command": "sleep 375", "description": "Background sleep for QA harness step 1", "timeout": 0, "background": true}, "toolInputTruncated": false, "hook_event_name": "PreToolUse", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "run_terminal_command", "tool_input": {"command": "sleep 375", "description": "Background sleep for QA harness step 1", "timeout": 0, "background": true}, "tool_use_id": "call-73a5d7da-7209-472d-acd6-7776cf81251d-0"}} +{"t": 12.816, "kind": "hook", "index": 3, "sleep_procs": [], "payload": {"hookEventName": "pre_tool_use", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:33.545281+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "spawn_subagent", "toolUseId": "call-73a5d7da-7209-472d-acd6-7776cf81251d-1", "toolInput": {"description": "Foreground sleep then done", "prompt": "Run this exact command in the foreground with run_terminal_command, wait for it to finish, then reply done: sleep 314", "background": true}, "toolInputTruncated": false, "hook_event_name": "PreToolUse", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "spawn_subagent", "tool_input": {"description": "Foreground sleep then done", "prompt": "Run this exact command in the foreground with run_terminal_command, wait for it to finish, then reply done: sleep 314", "background": true}, "tool_use_id": "call-73a5d7da-7209-472d-acd6-7776cf81251d-1"}} +{"t": 13.143, "kind": "hook", "index": 4, "sleep_procs": ["67236 67220 S 00:01 sleep 375"], "payload": {"hookEventName": "post_tool_use", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:33.866237+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "spawn_subagent", "toolUseId": "call-73a5d7da-7209-472d-acd6-7776cf81251d-1", "toolInput": {"description": "Foreground sleep then done", "prompt": "Run this exact command in the foreground with run_terminal_command, wait for it to finish, then reply done: sleep 314", "background": true}, "toolResult": {"type": "Text", "text": "Subagent started in background.\nsubagent_id: 01a0d51e-b238-7320-b72a-2da75aabf851\ndescription: Foreground sleep then done\n\nWhen you need its result, use get_command_or_subagent_output with task_ids=[\"01a0d51e-b238-7320-b72a-2da75aabf851\"] and a positive timeout_ms."}, "toolInputTruncated": false, "toolResultTruncated": false, "durationMs": 17, "isBackgrounded": false, "hook_event_name": "PostToolUse", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "spawn_subagent", "tool_input": {"description": "Foreground sleep then done", "prompt": "Run this exact command in the foreground with run_terminal_command, wait for it to finish, then reply done: sleep 314", "background": true}, "tool_response": {"type": "Text", "text": "Subagent started in background.\nsubagent_id: 01a0d51e-b238-7320-b72a-2da75aabf851\ndescription: Foreground sleep then done\n\nWhen you need its result, use get_command_or_subagent_output with task_ids=[\"01a0d51e-b238-7320-b72a-2da75aabf851\"] and a positive timeout_ms."}, "tool_use_id": "call-73a5d7da-7209-472d-acd6-7776cf81251d-1", "duration_ms": 17}} +{"t": 13.172, "kind": "hook", "index": 5, "sleep_procs": ["67236 67220 S 00:01 sleep 375"], "payload": {"hookEventName": "subagent_start", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:33.891749+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "subagentId": "01a0d51e-b238-7320-b72a-2da75aabf851", "subagentType": "general-purpose", "description": "Foreground sleep then done", "hook_event_name": "SubagentStart", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 13.324, "kind": "hook", "index": 6, "sleep_procs": ["67236 67220 S 00:01 sleep 375"], "payload": {"hookEventName": "user_prompt_submit", "sessionId": "01a0d51e-b238-7320-b72a-2da75aabf851", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:34.066968+00:00", "promptId": "01a0d51e-b2fb-7903-803f-37f81ec6b97d", "permissionMode": "bypassPermissions", "prompt": "Run this exact command in the foreground with run_terminal_command, wait for it to finish, then reply done: sleep 314", "subagentType": "general-purpose", "hook_event_name": "UserPromptSubmit", "session_id": "01a0d51e-b238-7320-b72a-2da75aabf851", "permission_mode": "bypassPermissions"}} +{"t": 13.422, "kind": "hook", "index": 7, "sleep_procs": ["67236 67220 S 00:01 sleep 375"], "payload": {"hookEventName": "post_tool_use", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:34.154199+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "run_terminal_command", "toolUseId": "call-73a5d7da-7209-472d-acd6-7776cf81251d-0", "toolInput": {"command": "sleep 375", "description": "Background sleep for QA harness step 1", "timeout": 0, "background": true}, "toolResult": {"type": "BackgroundTaskStarted", "task_id": "01a0d51e-b249-74c2-affa-9d146d6fd871", "task_type": "bash", "output_file": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/terminal/call-73a5d7da-7209-472d-acd6-7776cf81251d-0.log", "status": "running", "command": "sleep 375", "summary": "Background task 01a0d51e-b249-74c2-affa-9d146d6fd871 started", "retrieval_hint": "Use get_command_or_subagent_output with task_ids=[\"01a0d51e-b249-74c2-affa-9d146d6fd871\"] when you need the output.", "pid": 67220}, "toolInputTruncated": false, "toolResultTruncated": false, "durationMs": 27, "isBackgrounded": false, "hook_event_name": "PostToolUse", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "run_terminal_command", "tool_input": {"command": "sleep 375", "description": "Background sleep for QA harness step 1", "timeout": 0, "background": true}, "tool_response": {"type": "BackgroundTaskStarted", "task_id": "01a0d51e-b249-74c2-affa-9d146d6fd871", "task_type": "bash", "output_file": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/terminal/call-73a5d7da-7209-472d-acd6-7776cf81251d-0.log", "status": "running", "command": "sleep 375", "summary": "Background task 01a0d51e-b249-74c2-affa-9d146d6fd871 started", "retrieval_hint": "Use get_command_or_subagent_output with task_ids=[\"01a0d51e-b249-74c2-affa-9d146d6fd871\"] when you need the output.", "pid": 67220}, "tool_use_id": "call-73a5d7da-7209-472d-acd6-7776cf81251d-0", "duration_ms": 27}} +{"t": 15.95, "kind": "hook", "index": 8, "sleep_procs": ["67236 67220 S 00:03 sleep 375"], "payload": {"hookEventName": "stop", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:36.629092+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "promptId": "b6fce791-fd48-4b0e-a20c-ca8130c8c240", "permissionMode": "bypassPermissions", "reason": "end_turn", "stopHookActive": false, "lastAssistantMessage": "STARTED", "backgroundTasks": [{"id": "01a0d51e-b249-74c2-affa-9d146d6fd871", "type": "shell", "status": "running", "command": "sleep 375"}, {"id": "01a0d51e-b238-7320-b72a-2da75aabf851", "type": "subagent", "status": "running", "description": "Foreground sleep then done", "agentType": "general-purpose"}], "sessionCrons": [], "hook_event_name": "Stop", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 20.047, "kind": "hook", "index": 9, "sleep_procs": ["67236 67220 S 00:07 sleep 375"], "payload": {"hookEventName": "user_prompt_submit", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:40.784164+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "promptId": "4e9542a8-8bf1-470f-ad2d-38175d5d7172", "permissionMode": "bypassPermissions", "prompt": "QA harness step 2: run_terminal_command in the FOREGROUND (background false) with command exactly `sleep 244`, wait for it to finish, then reply with exactly the word OK.", "hook_event_name": "UserPromptSubmit", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 23.401, "kind": "hook", "index": 10, "sleep_procs": ["67236 67220 S 00:11 sleep 375"], "payload": {"hookEventName": "pre_tool_use", "sessionId": "01a0d51e-b238-7320-b72a-2da75aabf851", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:44.139687+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "run_terminal_command", "toolUseId": "call-904a2f95-71c3-405e-8228-984c8d51a056-0", "toolInput": {"command": "sleep 314", "description": "Sleep 314 seconds in the foreground", "timeout": 330000}, "toolInputTruncated": false, "subagentType": "general-purpose", "hook_event_name": "PreToolUse", "session_id": "01a0d51e-b238-7320-b72a-2da75aabf851", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "run_terminal_command", "tool_input": {"command": "sleep 314", "description": "Sleep 314 seconds in the foreground", "timeout": 330000}, "tool_use_id": "call-904a2f95-71c3-405e-8228-984c8d51a056-0"}} +{"t": 37.857, "kind": "hook", "index": 11, "sleep_procs": ["67236 67220 S 00:25 sleep 375", "69039 69030 S 00:14 sleep 314"], "payload": {"hookEventName": "pre_tool_use", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:58.494694+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "run_terminal_command", "toolUseId": "call-99b78098-24a7-4a1d-b234-801dad908cc9-2", "toolInput": {"command": "sleep 244", "description": "Foreground sleep for QA harness step 2", "timeout": 300000, "background": false}, "toolInputTruncated": false, "hook_event_name": "PreToolUse", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "run_terminal_command", "tool_input": {"command": "sleep 244", "description": "Foreground sleep for QA harness step 2", "timeout": 300000, "background": false}, "tool_use_id": "call-99b78098-24a7-4a1d-b234-801dad908cc9-2"}} +{"t": 38.835, "kind": "hook", "index": 12, "sleep_procs": ["67236 67220 S 00:26 sleep 375", "69039 69030 S 00:15 sleep 314", "71313 71302 S 00:00 sleep 244"], "payload": {"hookEventName": "post_tool_use", "sessionId": "01a0d51e-b238-7320-b72a-2da75aabf851", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:32:59.536733+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "run_terminal_command", "toolUseId": "call-904a2f95-71c3-405e-8228-984c8d51a056-0", "toolInput": {"command": "sleep 314", "description": "Sleep 314 seconds in the foreground", "timeout": 330000}, "toolResult": {"type": "BackgroundTaskStarted", "task_id": "call-904a2f95-71c3-405e-8228-984c8d51a056-0", "task_type": "bash", "output_file": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/terminal/call-904a2f95-71c3-405e-8228-984c8d51a056-0.log", "status": "running", "command": "sleep 314", "summary": "Command \"Sleep 314 seconds in the foreground\" has been automatically moved to background because it exceeded auto-background timeout limit of 15s. Process is still running.", "retrieval_hint": "Use get_command_or_subagent_output with task_ids=[\"call-904a2f95-71c3-405e-8228-984c8d51a056-0\"] when you need the output.", "pid": 69030}, "toolInputTruncated": false, "toolResultTruncated": false, "durationMs": 15124, "isBackgrounded": false, "subagentType": "general-purpose", "hook_event_name": "PostToolUse", "session_id": "01a0d51e-b238-7320-b72a-2da75aabf851", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "run_terminal_command", "tool_input": {"command": "sleep 314", "description": "Sleep 314 seconds in the foreground", "timeout": 330000}, "tool_response": {"type": "BackgroundTaskStarted", "task_id": "call-904a2f95-71c3-405e-8228-984c8d51a056-0", "task_type": "bash", "output_file": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/terminal/call-904a2f95-71c3-405e-8228-984c8d51a056-0.log", "status": "running", "command": "sleep 314", "summary": "Command \"Sleep 314 seconds in the foreground\" has been automatically moved to background because it exceeded auto-background timeout limit of 15s. Process is still running.", "retrieval_hint": "Use get_command_or_subagent_output with task_ids=[\"call-904a2f95-71c3-405e-8228-984c8d51a056-0\"] when you need the output.", "pid": 69030}, "tool_use_id": "call-904a2f95-71c3-405e-8228-984c8d51a056-0", "duration_ms": 15124}} +{"t": 41.105, "kind": "cancel", "label": "ESC-mid-turn", "interrupted_painted": false, "hooks_before_next_typed_prompt": [13, 14]} +{"t": 43.01, "kind": "hook", "index": 13, "sleep_procs": ["67236 67220 S 00:30 sleep 375", "69039 69030 S 00:19 sleep 314", "71313 71302 S 00:04 sleep 244"], "payload": {"hookEventName": "pre_tool_use", "sessionId": "01a0d51e-b238-7320-b72a-2da75aabf851", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:03.684413+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "get_command_or_subagent_output", "toolUseId": "call-031cd906-ee9d-478a-9295-67878d649f16-1", "toolInput": {"task_ids": ["call-904a2f95-71c3-405e-8228-984c8d51a056-0"], "timeout_ms": 330000}, "toolInputTruncated": false, "subagentType": "general-purpose", "hook_event_name": "PreToolUse", "session_id": "01a0d51e-b238-7320-b72a-2da75aabf851", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "get_command_or_subagent_output", "tool_input": {"task_ids": ["call-904a2f95-71c3-405e-8228-984c8d51a056-0"], "timeout_ms": 330000}, "tool_use_id": "call-031cd906-ee9d-478a-9295-67878d649f16-1"}} +{"t": 46.426, "kind": "cancel", "label": "CTRL-C-dialog-continue-to-run", "interrupted_painted": true, "hooks_before_next_typed_prompt": [14]} +{"t": 48.092, "kind": "hook", "index": 14, "sleep_procs": ["67236 67220 S 00:36 sleep 375", "69039 69030 S 00:25 sleep 314"], "payload": {"hookEventName": "stop_cancelled", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:08.711269+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "promptId": "4e9542a8-8bf1-470f-ad2d-38175d5d7172", "permissionMode": "bypassPermissions", "reason": "user_interrupt", "cancelledBy": "user", "cancelTrigger": "ctrl_c", "lastAssistantMessage": "I'll run `sleep 244` in the foreground and wait until it finishes.", "hook_event_name": "StopCancelled", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 61.72, "kind": "hook", "index": 15, "sleep_procs": ["67236 67220 S 00:49 sleep 375", "69039 69030 S 00:38 sleep 314"], "payload": {"hookEventName": "user_prompt_submit", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:22.175449+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "promptId": "1f66f610-0ec3-4f3d-b3bd-1d8f82e0fcc3", "permissionMode": "bypassPermissions", "prompt": "QA harness step 3: run_terminal_command in the FOREGROUND (background false) with command exactly `sleep 199`, wait for it to finish, then reply with exactly the word OK.", "hook_event_name": "UserPromptSubmit", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 77.445, "kind": "hook", "index": 16, "sleep_procs": ["67236 67220 S 01:05 sleep 375", "69039 69030 S 00:54 sleep 314"], "payload": {"hookEventName": "pre_tool_use", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:38.175901+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "toolName": "run_terminal_command", "toolUseId": "call-4b774e97-767e-4643-8643-84bef2251bcb-3", "toolInput": {"command": "sleep 199", "description": "Foreground sleep for QA harness step 3", "timeout": 250000, "background": false}, "toolInputTruncated": false, "hook_event_name": "PreToolUse", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions", "tool_name": "run_terminal_command", "tool_input": {"command": "sleep 199", "description": "Foreground sleep for QA harness step 3", "timeout": 250000, "background": false}, "tool_use_id": "call-4b774e97-767e-4643-8643-84bef2251bcb-3"}} +{"t": 80.669, "kind": "cancel", "label": "CTRL-C-dialog-stop-running", "interrupted_painted": true, "hooks_before_next_typed_prompt": [17, 18, 19, 20, 21]} +{"t": 82.052, "kind": "hook", "index": 17, "sleep_procs": ["67236 67220 S 01:09 sleep 375"], "payload": {"hookEventName": "stop_cancelled", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:42.754037+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "promptId": "1f66f610-0ec3-4f3d-b3bd-1d8f82e0fcc3", "permissionMode": "bypassPermissions", "reason": "user_interrupt", "cancelledBy": "user", "cancelTrigger": "ctrl_c", "lastAssistantMessage": "I'll run `sleep 199` in the foreground and wait until it finishes.", "hook_event_name": "StopCancelled", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 82.124, "kind": "hook", "index": 18, "sleep_procs": ["67236 67220 S 01:10 sleep 375"], "payload": {"hookEventName": "notification", "sessionId": "01a0d51e-b238-7320-b72a-2da75aabf851", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:42.785616+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permissionMode": "bypassPermissions", "notificationType": "task_complete", "message": "Background task completed: call-904a2f95-71c3-405e-8228-984c8d51a056-0", "level": "info", "hook_event_name": "Notification", "session_id": "01a0d51e-b238-7320-b72a-2da75aabf851", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 82.494, "kind": "hook", "index": 19, "sleep_procs": ["67236 67220 S 01:10 sleep 375"], "payload": {"hookEventName": "session_end", "sessionId": "01a0d51e-b238-7320-b72a-2da75aabf851", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:43.132539+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permissionMode": "bypassPermissions", "reason": "shutdown", "subagentType": "general-purpose", "hook_event_name": "SessionEnd", "session_id": "01a0d51e-b238-7320-b72a-2da75aabf851", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-b238-7320-b72a-2da75aabf851/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 98.702, "kind": "hook", "index": 20, "sleep_procs": ["67236 67220 S 01:26 sleep 375"], "payload": {"hookEventName": "session_end", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:59.387975+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "reason": "shutdown", "hook_event_name": "SessionEnd", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} +{"t": 98.969, "kind": "hook", "index": 21, "sleep_procs": ["67236 67220 S 01:26 sleep 375"], "payload": {"hookEventName": "stop", "sessionId": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "cwd": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "workspaceRoot": "/Users/uuuuuuuuuuuuu/orca-qa/grok-cancel-capture/scratch", "timestamp": "2026-09-24T20:33:59.716314+00:00", "transcriptPath": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permissionMode": "bypassPermissions", "reason": "shutdown", "stopHookActive": false, "hook_event_name": "Stop", "session_id": "01a0d51e-7d2a-7672-9f18-5bed4f0ea430", "transcript_path": "/Users/uuuuuuuuuuuuu/.grok/sessions/%2FUsers%2Fuuuuuuuuuuuuu%2Forca-qa%2Fgrok-cancel-capture%2Fscratch/01a0d51e-7d2a-7672-9f18-5bed4f0ea430/updates.jsonl", "permission_mode": "bypassPermissions"}} diff --git a/src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.meta.json b/src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.meta.json new file mode 100644 index 000000000000..0754d0eb1e41 --- /dev/null +++ b/src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.meta.json @@ -0,0 +1,11 @@ +{ + "source_scenario": "cancel-with-subagent-dialog", + "grok_version": "grok 1.0.41 (4220f3b224a6) [stable]", + "argv": ["grok", "--always-approve"], + "platform": "darwin", + "cols": 120, + "rows": 40, + "recorded_at": "2026-09-24", + "clock": "t is seconds since the first record; hook records keep the ps snapshot taken inside the hook", + "note": "Grok driven over a real PTY with every hook logged from stdin by a temporary global hook file. Turn 1 starts a background shell (sleep 375) and a background subagent (whose own turn runs sleep 314 in its foreground), then ends: its `stop` lists both in backgroundTasks. Turn 2 runs a foreground sleep 244; Esc mid-turn fires NO hook (the TUI shows a 'Press Ctrl+c to cancel the turn' toast and the turn keeps running); Ctrl+C mid-turn opens the 'Subagents are still running. Stop them?' dialog (still no hook), and answering '2 Continue to run' fires `stop_cancelled` (reason user_interrupt, cancelTrigger ctrl_c) with NO backgroundTasks key while the shell and subagent keep running (ps proves both alive; only the foreground tool died). Turn 3 repeats with sleep 199 and answers '1 Stop running': the same inventory-less `stop_cancelled`, with the subagent and foreground tool already dead at the hook instant, followed by the subagent's `session_end` (subagentType set, sessionId equal to the subagentId from SubagentStart). Nothing restates the inventory until the next turn's stop; quitting fires the main session_end + a stop with reason shutdown. Scrubbed: username replaced by a same-length placeholder." +} diff --git a/src/shared/agent-hook-listener-grok-completion.test.ts b/src/shared/agent-hook-listener-grok-completion.test.ts index 263c9e01a03f..7bc4c95bb1d3 100644 --- a/src/shared/agent-hook-listener-grok-completion.test.ts +++ b/src/shared/agent-hook-listener-grok-completion.test.ts @@ -130,7 +130,7 @@ describe('Grok completion observations', () => { }) }) - it('keeps the cancellation on the idle backstop that settles a task the cancel left running', () => { + it('keeps a task the cancel left running through the idle restatement, and its verdict', () => { normalize({ hookEventName: 'UserPromptSubmit', sessionId: 's-1', @@ -145,13 +145,26 @@ describe('Grok completion observations', () => { 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. + // Why: idle_prompt fires on "turn over, user idle" even while tasks run (measured live), so it + // restates the verdict without settling the row the inventory holds open. expect( normalize({ hookEventName: 'Notification', sessionId: 's-1', notificationType: 'idle_prompt' }) + ).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + // A session boundary settles the pane whatever the inventory says, still reading interrupted. + expect( + normalize({ + hookEventName: 'SessionEnd', + sessionId: 's-1', + reason: 'shutdown' + }) ).toMatchObject({ state: 'done', interrupted: true, diff --git a/src/shared/agent-hook-listener-grok-task-inventory.test.ts b/src/shared/agent-hook-listener-grok-task-inventory.test.ts new file mode 100644 index 000000000000..3f9700c58051 --- /dev/null +++ b/src/shared/agent-hook-listener-grok-task-inventory.test.ts @@ -0,0 +1,200 @@ +// Grok's background-task inventory between `stop` reports: a shell's own start (PostToolUse +// BackgroundTaskStarted) and end (Notification task_complete) hooks. Measured on 1.0.41: +// src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl and +// grok-background-completion-hooks.jsonl. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { normalizeHookPayload } from './agent-hook-listener' +import { + createHookListenerState, + type HookListenerState +} from './agent-hook-listener/listener-state' +import { PANE_KEY } from './agent-hook-listener-test-harness' + +function readCapture(name: string): unknown[] { + return readFileSync(join(__dirname, '__fixtures__', `${name}.jsonl`), 'utf8') + .trim() + .split('\n') + .map((raw): unknown => JSON.parse(raw)) +} + +function isPayload(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function capturedHookPayload(index: number): Record { + const line = readCapture('grok-cancel-subagent-dialog-hooks').find( + (record) => + typeof record === 'object' && + record !== null && + 'kind' in record && + record.kind === 'hook' && + 'index' in record && + record.index === index + ) + const payload = + typeof line === 'object' && line !== null && 'payload' in line ? line.payload : undefined + if (!isPayload(payload)) { + throw new Error(`Captured Grok hook ${index} not found`) + } + return payload +} + +const taskComplete = (taskId: string) => ({ + hookEventName: 'notification', + sessionId: 's-1', + notificationType: 'task_complete', + message: `Background task completed: ${taskId}`, + level: 'info' +}) + +describe('Grok background-task inventory between stop reports', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function normalize(payload: Record) { + return normalizeHookPayload(state, 'grok', { paneKey: PANE_KEY, payload }, 'production') + ?.payload + } + + function settleWithShell(promptId: string): void { + normalize({ hookEventName: 'user_prompt_submit', sessionId: 's-1', promptId, prompt: 'go' }) + expect( + normalize({ + hookEventName: 'stop', + sessionId: 's-1', + promptId, + reason: 'end_turn', + backgroundTasks: [ + { id: 'task-1', type: 'shell', status: 'running', command: 'sleep 30' }, + { id: 'monitor-1', type: 'monitor', status: 'running', description: 'watch' } + ] + }) + ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + } + + it('drops a shell that completes during a live turn without changing the row', () => { + settleWithShell('prompt-1') + normalize({ + hookEventName: 'user_prompt_submit', + sessionId: 's-1', + promptId: 'prompt-2', + prompt: 'more' + }) + // The live turn owns the row; the completion only updates the inventory. + expect(normalize(taskComplete('task-1'))).toBeUndefined() + // The inventory-less cancel then finds nothing left running. + expect( + normalize({ + hookEventName: 'stop_cancelled', + sessionId: 's-1', + promptId: 'prompt-2', + reason: 'user_interrupt' + }) + ).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + }) + + it('ignores a completion for a task the inventory never held, and for a monitor', () => { + settleWithShell('prompt-1') + expect(normalize(taskComplete('monitor-1'))).toBeUndefined() + expect(normalize(taskComplete('task-unknown'))).toBeUndefined() + expect( + normalize({ ...taskComplete('task-1'), message: 'Background task finished: task-1' }) + ).toBeUndefined() + // The shell still holds the settled row. + expect( + normalize({ + hookEventName: 'notification', + sessionId: 's-1', + notificationType: 'idle_prompt' + }) + ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + // Its own completion settles it; a plain stop carries no cancel verdict. + const settled = normalize(taskComplete('task-1')) + expect(settled).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + expect(settled?.interrupted).toBeUndefined() + }) + + it("does not add a subagent's own background shell to the parent's inventory", () => { + const childStarted = capturedHookPayload(12) + expect(childStarted.subagentType).toBe('general-purpose') + expect(childStarted.toolResult).toMatchObject({ + type: 'BackgroundTaskStarted', + task_type: 'bash' + }) + normalize({ + hookEventName: 'user_prompt_submit', + sessionId: 's-1', + promptId: 'prompt-1', + prompt: 'go' + }) + expect(normalize(childStarted)).toBeUndefined() + expect( + normalize({ + hookEventName: 'stop_cancelled', + sessionId: 's-1', + promptId: 'prompt-1', + reason: 'user_interrupt' + }) + ).toMatchObject({ state: 'done', interrupted: true }) + }) + + it('stamps the held-open turn at idle with its stop time, and a re-delivered stop keeps it', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + normalize({ hookEventName: 'user_prompt_submit', sessionId: 's-1', promptId: 'p1' }) + const stop = { + hookEventName: 'stop', + sessionId: 's-1', + promptId: 'p1', + reason: 'end_turn', + backgroundTasks: [{ id: 'task-1', type: 'shell', status: 'running' }] + } + vi.setSystemTime(2_000) + expect(normalize(stop)?.turnCompletedAt).toBeUndefined() + vi.setSystemTime(62_000) + expect( + normalize({ + hookEventName: 'notification', + sessionId: 's-1', + notificationType: 'idle_prompt' + }) + ).toMatchObject({ state: 'working', turnCompletedAt: 2_000 }) + vi.setSystemTime(62_250) + expect(normalize(stop)?.turnCompletedAt).toBe(2_000) + }) + + // Why: Grok may run no follow-up turn after a completion (a cancel, a goal loop, auto-wake off), + // so the task's own end settles the row; when it does wake, the woken turn reopens it. + it('settles on the captured task_complete and reopens for the woken follow-up turn', () => { + const states = readCapture('grok-background-completion-hooks') + .filter(isPayload) + .slice(0, 8) + .map((payload) => { + const row = normalize(payload) + return [payload.hookEventName, payload.notificationType, row?.state, row?.workingMode] + }) + expect(states).toEqual([ + ['session_start', undefined, undefined, undefined], + ['user_prompt_submit', undefined, 'working', undefined], + ['pre_tool_use', undefined, 'working', undefined], + ['post_tool_use', undefined, 'working', undefined], + ['stop', undefined, 'working', 'monitoring'], + ['notification', 'task_complete', 'done', undefined], + ['user_prompt_submit', undefined, 'working', undefined], + ['stop', undefined, 'done', undefined] + ]) + }) +}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 73f05c1d7637..5d8b7b0d7039 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -50,9 +50,11 @@ export function normalizeHookPayload( readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ?? hookPayloadRecord.hook_event_name ?? hookPayloadRecord.hookEventName - // Codex child hooks expose the child's session_id on the parent's pane. + // Codex and Grok child hooks expose the child's session_id on the parent's pane. const providerSession = - source === 'codex' && readString(hookPayloadRecord, 'agent_id') + (source === 'codex' && readString(hookPayloadRecord, 'agent_id')) || + (source === 'grok' && + readFirstString(hookPayloadRecord, ['subagentType', 'subagent_type']) !== undefined) ? null : extractAgentProviderSession(source, hookPayloadRecord) // Why (#21359): the shared OpenCode server stamps every post with its own @@ -193,7 +195,19 @@ export function normalizeHookPayload( grokPromptBoundary: grokActiveTurn ? true : undefined, compactTrigger, toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), - toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']), + toolAgentId: + readFirstString(hookPayloadRecord, ['agent_id', 'agentId']) ?? + // Why: Grok child hooks carry subagentType; their id is subagentId, or the child's own + // session id (measured equal on the child's SessionEnd, the only signal a killed one leaves). + (source === 'grok' && + readFirstString(hookPayloadRecord, ['subagentType', 'subagent_type']) !== undefined + ? readFirstString(hookPayloadRecord, [ + 'subagentId', + 'subagent_id', + 'sessionId', + 'session_id' + ]) + : undefined), teammateName: source === 'claude' && eventName === 'TeammateIdle' ? readString(hookPayloadRecord, 'teammate_name') diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 53848ca707a8..ef3daf167acf 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -1,5 +1,9 @@ -import type { AgentMainAgentStatus } from '../agent-status-types' -import type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' +import type { AgentChildWorkKind } from '../agent-status-child-work' +import type { + ClaudeLeadTurnState, + CodexLeadTurnState, + GrokMainAgentTurnState +} from './main-agent-turn-state' export type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' import { @@ -56,7 +60,14 @@ export type HookListenerState = { /** Newest Grok turn per pane, used to reject end reports that arrive after a replacement prompt. */ grokActiveTurnByPaneKey: Map /** The Grok main agent's own state as last published, so its clock keeps continuity across events. */ - grokMainAgentStatusByPaneKey: Map + grokMainAgentStatusByPaneKey: Map + /** Grok's background-task inventory per pane (task id -> kind): a turn end carrying + * `backgroundTasks` restates it whole; a shell's BackgroundTaskStarted result or a SubagentStart + * adds an entry, and its own task_complete, SubagentStop or child SessionEnd drops it; a session + * boundary or pane close clears it. Kept because Grok's own cancel hook (`stop_cancelled`) + * carries no inventory and a cancel suppresses the follow-up turn whose `stop` would restate it + * (measured, 1.0.41: src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl). */ + grokBackgroundTasksByPaneKey: Map> /** Muse child-session filter and session-log cursor per pane. */ musePaneStateByPaneKey: Map /** @@ -117,6 +128,7 @@ export function createHookListenerState( codexLeadStateByPaneKey: new Map(), grokActiveTurnByPaneKey: new Map(), grokMainAgentStatusByPaneKey: new Map(), + grokBackgroundTasksByPaneKey: new Map(), musePaneStateByPaneKey: new Map(), opencodeSessionPaneBySessionId: new Map(), lastLaunchTokenByPaneKey: new Map() @@ -207,6 +219,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.codexLeadStateByPaneKey.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) @@ -285,6 +298,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.codexLeadStateByPaneKey, 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) @@ -297,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 deletePaneScopedCacheEntry(map: Map, paneKey: string): void { @@ -339,6 +354,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.codexLeadStateByPaneKey.clear() state.grokActiveTurnByPaneKey.clear() state.grokMainAgentStatusByPaneKey.clear() + state.grokBackgroundTasksByPaneKey.clear() state.opencodeSessionPaneBySessionId.clear() state.lastLaunchTokenByPaneKey.clear() } diff --git a/src/shared/agent-hook-listener/main-agent-turn-state.ts b/src/shared/agent-hook-listener/main-agent-turn-state.ts index c077860f8467..41c5ab83e4f2 100644 --- a/src/shared/agent-hook-listener/main-agent-turn-state.ts +++ b/src/shared/agent-hook-listener/main-agent-turn-state.ts @@ -1,4 +1,4 @@ -import type { AgentStatusState } from '../agent-status-types' +import type { AgentMainAgentStatus, AgentStatusState } from '../agent-status-types' import type { AgentJournalTurnOutcome } from '../agent-turn-outcome' /** The Claude main agent's own turn record, published on every row as `mainAgent`. */ @@ -33,3 +33,9 @@ export type CodexLeadTurnState = { stateStartedAt: number model?: string } + +/** The Grok main agent's own record: the `mainAgent` it publishes, plus the same turn stamp Claude's carries. */ +export type GrokMainAgentTurnState = AgentMainAgentStatus & { + /** See ClaudeLeadTurnState.turnCompletedAt. Always the stamped done run's own `stateStartedAt`. */ + turnCompletedAt?: number +} diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index e44d7e097964..a937bff57f94 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -1,16 +1,24 @@ import { normalizeAgentStatusPayload, + type AgentMainAgentStatus, type ParsedAgentStatusPayload } 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, - type AgentChildWorkLivenessCandidate -} from '../../agent-status-child-work-liveness' + continueMainAgentStatus, + foldAgentLeadStatus, + isAgentStatusHeldOpenByChildWork +} from '../../agent-lead-status-fold' import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import type { GrokMainAgentTurnState } from '../main-agent-turn-state' +import { + grokChildWorkLivenessAfterTurnEnd, + grokIdentityField, + normalizeGrokSubagentLifecycleEvent, + normalizeGrokTaskCompleteNotification, + recordGrokBackgroundTaskStarted, + restateGrokTaskInventory +} from './grok-task-inventory' import { normalizeGrokPromptId } from '../listener-limits' import { resolvePrompt, resolveToolState, stripGrokUserQueryWrapper } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' @@ -22,33 +30,6 @@ import { isGrokRoutinePermissionPromptNotification } from './grok-tool-fields' -function aliasedField( - payload: Record, - primary: string, - alias: string -): { present: boolean; value?: unknown } { - if (Object.hasOwn(payload, primary)) { - return { present: true, value: payload[primary] } - } - if (Object.hasOwn(payload, alias)) { - return { present: true, value: payload[alias] } - } - return { present: false } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function grokIdentityField( - hookPayload: Record, - primary: string, - alias: string -): string | undefined { - const value = readString(hookPayload, primary) ?? readString(hookPayload, alias) - return value && value.length <= 512 ? value : undefined -} - function isGrokSubagentEvent(hookPayload: Record): boolean { return ( readString(hookPayload, 'subagentType') !== undefined || @@ -97,40 +78,23 @@ function grokTurnEndApplies( ) } -/** A finite `backgroundTasks[]` entry as child work. Grok lists only in-flight tasks, so none - * carries a settled state. Monitors are left out: they can run indefinitely and would hold the - * pane (and silence its completion) forever. */ -function grokFiniteTaskKind(task: unknown): AgentChildWorkKind | null { - if (!isRecord(task)) { - return null +/** The finished turn's stamp, earned when Grok goes idle while child work still holds the row open + * — not at its `stop`: Grok wakes itself when that work ends, and the woken turn is the one that + * announces. A cancel earns none. It is the done run's own clock, so every later restatement of + * that turn carries the same value. */ +function grokTurnCompletedAt( + previous: GrokMainAgentTurnState | undefined, + mainAgent: AgentMainAgentStatus, + idleHeldOpen: boolean +): number | undefined { + if (mainAgent.state !== 'done' || mainAgent.outcome === 'cancellation') { + return undefined } - 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 [] + if (idleHeldOpen) { + return mainAgent.stateStartedAt } - return backgroundTasks.value.flatMap((task) => { - const kind = grokFiniteTaskKind(task) - return kind ? [{ kind }] : [] - }) -} - -/** What a turn end leaves running behind the main agent. A background subagent is agent work - * and keeps the pane `working`; a shell, or a still-active stop hook holding the turn, is watch - * work and reads as monitoring. */ -function grokChildWorkLivenessAfterTurnEnd( - hookPayload: Record -): AgentChildWorkLiveness { - const stopHookActive = aliasedField(hookPayload, 'stopHookActive', 'stop_hook_active') - return ( - agentChildWorkLiveness(grokRunningFiniteTasks(hookPayload)) ?? - (stopHookActive.value === true ? 'monitoring' : null) - ) + // Why: only a done record holds a stamp, so a new turn (any non-done state) has already dropped it. + return previous?.turnCompletedAt } function isGrokSessionBoundary(eventName: unknown, hookPayload: Record): boolean { @@ -149,9 +113,13 @@ 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 + // Why: child sessions reuse their parent's pane route; their lifecycle cannot settle the + // parent, but it maintains the task inventory and may re-derive a row that inventory holds open. + if ( + isGrokSubagentEvent(hookPayload) || + isGrokEvent(eventName, 'subagent_start', 'subagent_stop') + ) { + return normalizeGrokSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) } if (isGrokEvent(eventName, 'session_start')) { // Why: SessionStart resets stale per-turn state but must not create a working row before any prompt/tool event. @@ -163,6 +131,9 @@ export function normalizeGrokEvent( if (isGrokEvent(eventName, 'user_prompt_submit')) { recordGrokTurn(state, paneKey, hookPayload) } + if (isGrokEvent(eventName, 'post_tool_use')) { + recordGrokBackgroundTaskStarted(state, paneKey, hookPayload) + } const notificationMessage = readString(hookPayload, 'message') const notificationType = getGrokNotificationType(hookPayload) @@ -196,8 +167,7 @@ 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 + return normalizeGrokTaskCompleteNotification(state, paneKey, hookPayload) } else if ( isGrokEvent(eventName, 'notification') && isGrokRoutinePermissionPromptNotification( @@ -222,14 +192,25 @@ export function normalizeGrokEvent( : isGrokEvent(eventName, 'stop_failure') ? ('failure' as const) : undefined - // Why: every turn end reports what it left running, and a task leaves only when it reports its - // own end or the session ends — a cancelled or failed turn with a still-running task reads - // monitoring exactly like a plain `stop`. Only a session boundary settles the pane whatever - // the inventory says. + // Why: a turn end that carries the inventory restates it; `stop_cancelled` carries none + // (measured), so the cancelled turn folds with the inventory Grok last reported — a cancelled + // or failed turn with a still-running task reads monitoring or working exactly like a plain + // `stop`. Only a session boundary settles the pane whatever the inventory says, and it clears + // the inventory with the process that owned it. + if (isTurnEnd) { + restateGrokTaskInventory(state, paneKey, hookPayload) + } + if (sessionBoundary) { + state.grokBackgroundTasksByPaneKey.delete(paneKey) + } const resolution = foldAgentLeadStatus({ leadState, childWorkLiveness: - isTurnEnd && !sessionBoundary ? grokChildWorkLivenessAfterTurnEnd(hookPayload) : null + // Why: idle_prompt means "turn over, user idle", not "tasks done" (measured: it fires with + // a subagent still running), so the idle restatement folds with the same inventory. + (isTurnEnd || isIdlePrompt) && !sessionBoundary + ? grokChildWorkLivenessAfterTurnEnd(state, paneKey, hookPayload) + : null }) const stateName = resolution.stateName const previousMainAgent = state.grokMainAgentStatusByPaneKey.get(paneKey) @@ -244,7 +225,17 @@ export function normalizeGrokEvent( { state: leadState, outcome: mainAgentOutcome }, Date.now() ) - state.grokMainAgentStatusByPaneKey.set(paneKey, mainAgent) + const turnCompletedAt = sessionBoundary + ? undefined + : grokTurnCompletedAt( + previousMainAgent, + mainAgent, + isIdlePrompt && isAgentStatusHeldOpenByChildWork({ state: stateName, mainAgent }) + ) + state.grokMainAgentStatusByPaneKey.set(paneKey, { + ...mainAgent, + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) + }) const snapshot = resolveToolState( state, @@ -270,9 +261,10 @@ export function normalizeGrokEvent( lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), - // Why: derived from the main agent, so the idle backstop that settles a cancelled turn held open by a task still reads interrupted. + // Why: derived from the main agent, so any later restatement that settles a cancelled turn still reads interrupted. ...(mainAgent.outcome === 'cancellation' ? { interrupted: true } : {}), ...(sessionBoundary ? { sessionBoundary: true } : {}), + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}), mainAgent }) } diff --git a/src/shared/agent-hook-listener/providers/grok-task-inventory.ts b/src/shared/agent-hook-listener/providers/grok-task-inventory.ts new file mode 100644 index 000000000000..27a1c4ab4689 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/grok-task-inventory.ts @@ -0,0 +1,221 @@ +// Grok's per-pane background-task inventory: what Grok last reported running behind the main +// agent, plus each task's own start and end hooks between reports. Kept apart from grok-events so +// the event mapping stays about turn state. Measured against Grok 1.0.41: +// src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl and +// grok-background-completion-hooks.jsonl. +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { foldAgentLeadStatus } from '../../agent-lead-status-fold' +import type { AgentChildWorkKind } from '../../agent-status-child-work' +import { + agentChildWorkLiveness, + type AgentChildWorkLiveness, + type AgentChildWorkLivenessCandidate +} from '../../agent-status-child-work-liveness' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { readString } from '../tool-input-preview' +import { isGrokEvent } from '../provider-event-names' + +function aliasedField( + payload: Record, + primary: string, + alias: string +): { present: boolean; value?: unknown } { + if (Object.hasOwn(payload, primary)) { + return { present: true, value: payload[primary] } + } + if (Object.hasOwn(payload, alias)) { + return { present: true, value: payload[alias] } + } + return { present: false } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +const MAX_GROK_ID_LENGTH = 512 + +export function grokIdentityField( + hookPayload: Record, + primary: string, + alias: string +): string | undefined { + const value = readString(hookPayload, primary) ?? readString(hookPayload, alias) + return value && value.length <= MAX_GROK_ID_LENGTH ? value : undefined +} + +/** A finite `backgroundTasks[]` entry as child work. Grok lists only in-flight tasks, so none + * carries a settled state. Monitors are left out: they can run indefinitely and would hold the + * pane (and silence its completion) forever. */ +function grokFiniteTaskKind(task: unknown): AgentChildWorkKind | null { + if (!isRecord(task)) { + return null + } + return task.type === 'subagent' ? 'agent' : task.type === 'shell' ? 'command' : null +} + +/** A hook that carries the `backgroundTasks` key restates the pane's inventory whole; one without + * it (measured: `stop_cancelled`) leaves the last reported inventory standing. */ +export function restateGrokTaskInventory( + state: HookListenerState, + paneKey: string, + hookPayload: Record +): void { + const backgroundTasks = aliasedField(hookPayload, 'backgroundTasks', 'background_tasks') + if (!backgroundTasks.present || !Array.isArray(backgroundTasks.value)) { + return + } + const inventory = new Map() + backgroundTasks.value.forEach((task, index) => { + const kind = grokFiniteTaskKind(task) + if (!kind) { + return + } + const id = isRecord(task) ? readString(task, 'id') : undefined + inventory.set(id ?? `unidentified-task-${index}`, kind) + }) + if (inventory.size === 0) { + state.grokBackgroundTasksByPaneKey.delete(paneKey) + } else { + state.grokBackgroundTasksByPaneKey.set(paneKey, inventory) + } +} + +/** What the turn's end leaves running behind the main agent, from the inventory Grok last + * reported. A background subagent is agent work and keeps the pane `working`; a shell, or a + * still-active stop hook holding the turn, is watch work and reads as monitoring. */ +export function grokChildWorkLivenessAfterTurnEnd( + state: HookListenerState, + paneKey: string, + hookPayload: Record +): AgentChildWorkLiveness { + const stopHookActive = aliasedField(hookPayload, 'stopHookActive', 'stop_hook_active') + const candidates: AgentChildWorkLivenessCandidate[] = [ + ...(state.grokBackgroundTasksByPaneKey.get(paneKey)?.values() ?? []) + ].map((kind) => ({ kind })) + return agentChildWorkLiveness(candidates) ?? (stopHookActive.value === true ? 'monitoring' : null) +} + +// Why: Grok's own literal for its task_complete Notification; the id after it is the task's id. +const GROK_TASK_COMPLETE_MESSAGE_PREFIX = 'Background task completed: ' + +function addGrokInventoryTask( + state: HookListenerState, + paneKey: string, + taskId: string, + kind: AgentChildWorkKind +): void { + const inventory = + state.grokBackgroundTasksByPaneKey.get(paneKey) ?? new Map() + inventory.set(taskId, kind) + state.grokBackgroundTasksByPaneKey.set(paneKey, inventory) +} + +/** A main-session background shell joins the inventory at its start, so a turn cancelled before + * any `stop` restated it still folds with it. Monitors return a different result and stay out. */ +export function recordGrokBackgroundTaskStarted( + state: HookListenerState, + paneKey: string, + hookPayload: Record +): void { + const result = hookPayload.toolResult ?? hookPayload.toolResponse ?? hookPayload.tool_response + if (!isRecord(result) || result.type !== 'BackgroundTaskStarted' || result.task_type !== 'bash') { + return + } + const taskId = readString(result, 'task_id') + if (!taskId || taskId.length > MAX_GROK_ID_LENGTH) { + return + } + addGrokInventoryTask(state, paneKey, taskId, 'command') +} + +/** Drops a task that reported its own end. When the inventory was all that held a settled row + * open, re-derives the row: a cancel suppresses the follow-up turn whose `stop` would. */ +function dropGrokFinishedTask( + state: HookListenerState, + paneKey: string, + taskId: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const inventory = state.grokBackgroundTasksByPaneKey.get(paneKey) + if (!inventory?.delete(taskId)) { + return null + } + if (inventory.size === 0) { + state.grokBackgroundTasksByPaneKey.delete(paneKey) + } + const record = state.grokMainAgentStatusByPaneKey.get(paneKey) + // Why: a live lead turn already owns the row; only a row the inventory holds open re-derives. + if (record?.state !== 'done') { + return null + } + const { turnCompletedAt, ...mainAgent } = record + const resolution = foldAgentLeadStatus({ + leadState: 'done', + childWorkLiveness: grokChildWorkLivenessAfterTurnEnd(state, paneKey, hookPayload) + }) + const snapshot = resolveToolState(state, paneKey, {}, { resetOnNewTurn: false }) + return normalizeAgentStatusPayload({ + state: resolution.stateName, + prompt: resolvePrompt(state, paneKey, '', { resetOnNewTurn: false }), + agentType: 'grok', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), + ...(mainAgent.outcome === 'cancellation' ? { interrupted: true } : {}), + // Why: the turn already announced at the idle that stamped it; this restatement pairs with that. + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}), + mainAgent + }) +} + +/** Grok fires task_complete for every finished shell or monitor, woken or not. Only an id the + * inventory holds changes anything; child-owned tasks and monitors never enter it. */ +export function normalizeGrokTaskCompleteNotification( + state: HookListenerState, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const message = readString(hookPayload, 'message') + if (!message?.startsWith(GROK_TASK_COMPLETE_MESSAGE_PREFIX)) { + return null + } + const taskId = message.slice(GROK_TASK_COMPLETE_MESSAGE_PREFIX.length).trim() + if (!taskId || taskId.length > MAX_GROK_ID_LENGTH) { + return null + } + return dropGrokFinishedTask(state, paneKey, taskId, hookPayload) +} + +/** A child's lifecycle cannot settle the parent, but it does adjust the task inventory: a + * SubagentStart joins it, and a subagent's own end (SubagentStop, or the child's SessionEnd — + * the only signal a killed subagent leaves, its session id equal to the SubagentStart's + * subagentId) drops it. */ +export function normalizeGrokSubagentLifecycleEvent( + state: HookListenerState, + eventName: unknown, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const subagentId = + grokIdentityField(hookPayload, 'subagentId', 'subagent_id') ?? + grokIdentityField(hookPayload, 'sessionId', 'session_id') + if (!subagentId) { + return null + } + if (isGrokEvent(eventName, 'subagent_start')) { + addGrokInventoryTask(state, paneKey, subagentId, 'agent') + return null + } + if (!isGrokEvent(eventName, 'subagent_stop', 'subagent_end', 'session_end')) { + return null + } + return dropGrokFinishedTask(state, paneKey, subagentId, hookPayload) +} diff --git a/src/shared/agent-interrupt-intent.ts b/src/shared/agent-interrupt-intent.ts index 24f6f179e73b..c8759bd1a700 100644 --- a/src/shared/agent-interrupt-intent.ts +++ b/src/shared/agent-interrupt-intent.ts @@ -41,6 +41,19 @@ export function isNavigationEscapeIntent( ) } +// Why: Grok cancels only through its own TUI flow and reports every real cancel itself with a +// `stop_cancelled` hook. Esc mid-turn never cancels (it paints a "Press Ctrl+c to cancel the +// turn" toast and fires no hook), and Ctrl+C with subagents running opens a "Stop them?" dialog +// that keeps the turn running until answered — and can be answered "continue". Measured on +// 1.0.41: src/shared/__fixtures__/grok-cancel-subagent-dialog-hooks.jsonl. So no keypress is +// evidence a Grok turn ended; only Grok's own hook may cancel the row. +const SELF_REPORTED_CANCEL_AGENT_TYPES: ReadonlySet = new Set(['grok']) + +/** True when this agent reports its own cancels, so no keypress may infer one. */ +export function providerReportsOwnCancel(agentType: AgentType | undefined): boolean { + return agentType !== undefined && SELF_REPORTED_CANCEL_AGENT_TYPES.has(agentType) +} + // Why: these TUIs spend the first Escape on a cancel that can leave the turn running — // opencode2 also dismisses its Subagents dock with it — so only the second Escape on the // same turn is evidence of an interrupt. Shared so the renderer gate and the server