From 76f77bf811e16dfbe5b391e5c4f28ad1ea8ffa2e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:51:45 -0700 Subject: [PATCH] feat(agents): render Claude monitoring as a static turquoise dot Replaces the yellow Radio glyph from #14205 with a static dot in a new --agent-monitoring token (#8abeb7), defined once for light and once for dark like --workspace-status-done, so the status keeps its identity when the theme flips. Deliberately a fixed UI value: it never reads terminal theme state at runtime. Adds the turn-boundary notification pins. The monitoring predicate and the turnCompletedAt stamp are computed from the same "lead said done but the pane resolves to working" expression, so a rename can silently drop the stamp and kill a completion notification that works today with nothing else going red. --- mobile/src/components/AgentSpinner.tsx | 6 +- mobile/src/components/AgentStateDot.tsx | 18 +- .../agent-monitoring-indicators.test.ts | 45 +- .../server-ingest-terminal-status.test.ts | 46 + .../server-interrupt-inference-guards.test.ts | 1 + .../server-last-status-write.test.ts | 6 +- src/main/agent-hooks/server.test.ts | 8861 ----------------- src/renderer/src/assets/main.css | 9 + .../src/components/AgentStateDot.test.ts | 8 +- src/renderer/src/components/AgentStateDot.tsx | 19 +- .../cmd-j/palette-live-status.test.tsx | 4 +- .../components/dashboard-popout/agent-map.css | 4 +- .../dashboard/DashboardAgentRow.test.tsx | 3 +- .../sidebar/StatusIndicator.test.ts | 6 +- .../components/sidebar/StatusIndicator.tsx | 19 +- ...on-coordinator-monitoring-turn-end.test.ts | 131 + .../claude-background-task-status.test.ts | 82 + 17 files changed, 342 insertions(+), 8926 deletions(-) delete mode 100644 src/main/agent-hooks/server.test.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index a5e81ec07a9..4c476693de1 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef } from 'react' -import { Radio } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' @@ -18,6 +17,7 @@ const STATUS_COLORS: Record = { permission: '#ef4444', inactive: 'rgba(115,115,115,0.4)' } +const MONITORING_COLOR = '#8abeb7' export function AgentSpinner({ status, @@ -48,9 +48,11 @@ export function AgentSpinner({ const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive if (monitoring) { + // Why: static dot, not a spinner — the lead turn is over. Keep MONITORING_COLOR + // in step with --agent-monitoring in the desktop main.css. return ( - + ) } diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 4d596b7e094..b1c6514b922 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef } from 'react' -import { Radio } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' import type { AgentDotState } from '../worktree/agent-row-display' @@ -8,8 +7,10 @@ import type { AgentDotState } from '../worktree/agent-row-display' // emerald for 'done', red for blocked/waiting/interrupted (attention), neutral // for idle. Distinct from the worktree-level AgentSpinner, which collapses the // agent vocabulary into the 5-state rollup the sidebar dot uses. -const DOT_COLORS: Record, string> = { +const DOT_COLORS: Record, string> = { done: '#10b981', + // Keep in step with --agent-monitoring in the desktop main.css. + monitoring: '#8abeb7', blocked: '#ef4444', waiting: '#ef4444', interrupted: '#ef4444', @@ -46,16 +47,11 @@ export function AgentStateDot({ state }: { state: AgentDotState }) { ) } - if (state === 'monitoring') { - return ( - - - - ) - } - return ( - + ) diff --git a/mobile/src/components/agent-monitoring-indicators.test.ts b/mobile/src/components/agent-monitoring-indicators.test.ts index 749d2636b98..4bfab357270 100644 --- a/mobile/src/components/agent-monitoring-indicators.test.ts +++ b/mobile/src/components/agent-monitoring-indicators.test.ts @@ -4,22 +4,38 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentSpinner } from './AgentSpinner' import { AgentStateDot } from './AgentStateDot' -const DESKTOP_WORKING_COLOR = '#eab308' +// Why: pinned to --agent-monitoring in the desktop main.css. Mobile has no CSS +// tokens, so this literal is the only thing keeping the two surfaces in step. +const DESKTOP_MONITORING_COLOR = '#8abeb7' +const DESKTOP_DONE_COLOR = '#10b981' type MonitoringTestRenderer = { readonly root: { - findByType(type: string): { props: Record } + findAllByType(type: string): { props: Record }[] } unmount(): void } +/** Background colour of the inner dot (the wrapper View is index 0). */ +function dotColor(renderer: MonitoringTestRenderer | null): string | undefined { + const inner = renderer?.root.findAllByType('View')[1] + const style = inner?.props.style + const layers = Array.isArray(style) ? style : [style] + for (const layer of layers) { + const color = (layer as { backgroundColor?: string } | undefined)?.backgroundColor + if (typeof color === 'string') { + return color + } + } + return undefined +} + const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({ animationLoop: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), animationTiming: vi.fn(() => ({})), setValue: vi.fn() })) -vi.mock('lucide-react-native', () => ({ Radio: 'Radio' })) vi.mock('react-native', () => ({ Animated: { Value: function Value() { @@ -48,30 +64,33 @@ describe('mobile monitoring indicators', () => { renderer = null }) - it('renders a static Radio for a monitoring agent', async () => { + it('renders a static turquoise dot for a monitoring agent', async () => { await act(async () => { renderer = create(createElement(AgentStateDot, { state: 'monitoring' })) }) - expect(renderer?.root.findByType('Radio').props).toMatchObject({ - color: DESKTOP_WORKING_COLOR, - size: 10 - }) + expect(dotColor(renderer)).toBe(DESKTOP_MONITORING_COLOR) + // Why: the lead turn is over — spinning here is the bug this state exists to fix. expect(animationTiming).not.toHaveBeenCalled() expect(animationLoop).not.toHaveBeenCalled() }) - it('renders a static Radio for an all-monitoring workspace', async () => { + it('keeps a monitoring agent dot separable from the done dot', async () => { + await act(async () => { + renderer = create(createElement(AgentStateDot, { state: 'monitoring' })) + }) + + expect(dotColor(renderer)).not.toBe(DESKTOP_DONE_COLOR) + }) + + it('renders a static turquoise dot for an all-monitoring workspace', async () => { await act(async () => { renderer = create( createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' }) ) }) - expect(renderer?.root.findByType('Radio').props).toMatchObject({ - color: DESKTOP_WORKING_COLOR, - size: 12 - }) + expect(dotColor(renderer)).toBe(DESKTOP_MONITORING_COLOR) expect(animationTiming).not.toHaveBeenCalled() expect(animationLoop).not.toHaveBeenCalled() }) diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index 8661701e1dc..0ac729fa72e 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -27,6 +27,52 @@ afterEach(() => { }) describe('AgentHookServer ingestTerminalStatus', () => { + it('keeps hook monitoring mode across an equivalent OSC ping until a hook clears it', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + source: 'claude', + hookEventName: 'Stop', + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'watch the build', + agentType: 'claude' + } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' } + }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + workingMode: 'monitoring' + }) + + server.ingestRemote( + { + paneKey: PANE, + source: 'claude', + hookEventName: 'PreToolUse', + payload: { + state: 'working', + prompt: 'watch the build', + agentType: 'claude', + toolName: 'Read' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]?.workingMode).toBeUndefined() + }) + it('preserves a hook turn stamp when an OSC repaint omits hook-only completion text', () => { const server = new AgentHookServer() const listener = vi.fn() diff --git a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts index 8d0d6a3975c..29803adf020 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -228,6 +228,7 @@ describe('AgentHookServer listener replay', () => { const baseline = server.getStatusSnapshot()[0] expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask') + expect(baseline).toMatchObject({ state: 'working', workingMode: 'monitoring' }) expect( server.inferInterrupt({ paneKey: PANE, diff --git a/src/main/agent-hooks/server-last-status-write.test.ts b/src/main/agent-hooks/server-last-status-write.test.ts index f693c2d4c36..b0bfb7db929 100644 --- a/src/main/agent-hooks/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server-last-status-write.test.ts @@ -77,7 +77,11 @@ describe('Last-status persistence', () => { worktreeId: 'wt-1', receivedAt: expect.any(Number), stateStartedAt: expect.any(Number), - payload: expect.objectContaining({ state: 'working', prompt: 'persist me' }) + payload: expect.objectContaining({ + state: 'working', + workingMode: 'monitoring', + prompt: 'persist me' + }) }) expect(file.entries[PANE].launchToken).toBeUndefined() expect(file.entries[PANE].launchTokenHash).toBe( diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts deleted file mode 100644 index 142b28325e0..00000000000 --- a/src/main/agent-hooks/server.test.ts +++ /dev/null @@ -1,8861 +0,0 @@ -/* eslint-disable max-lines -- Why: this suite exercises the full hook HTTP surface (Claude/Codex/Gemini parsing, transcript chunked scan, paneKey dispatch) and keeping the scenarios co-located avoids fixture drift across files. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - utimesSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - AgentHookServer, - agentHookServer, - CLOSED_AGENT_STATUS_TAB_IDS_MAX, - _internals -} from './server' -import { - AGENT_STATUS_MAX_FIELD_LENGTH, - AGENT_STATUS_STALE_AFTER_MS, - parseAgentStatusPayload -} from '../../shared/agent-status-types' -import { - createHookListenerState, - normalizeHookPayload, - type HookListenerState -} from '../../shared/agent-hook-listener' -import { makePaneKey } from '../../shared/stable-pane-id' -import { createShedSubagentsField } from '../../shared/agent-hook-relay' - -const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ - getCohortAtEmitMock: vi.fn(), - trackMock: vi.fn() -})) - -vi.mock('../telemetry/client', () => ({ - track: trackMock -})) - -vi.mock('../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: getCohortAtEmitMock -})) - -const LEAF_1 = '11111111-1111-4111-8111-111111111111' -const LEAF_2 = '22222222-2222-4222-8222-222222222222' -const LEAF_3 = '33333333-3333-4333-8333-333333333333' -const LEAF_4 = '44444444-4444-4444-8444-444444444444' -const LEAF_5 = '55555555-5555-4555-8555-555555555555' -const PANE = makePaneKey('tab-1', LEAF_1) -const GOOD_PANE = makePaneKey('tab-good', LEAF_2) -const OLD_PANE = makePaneKey('tab-old', LEAF_3) -const FRESH_PANE = makePaneKey('tab-fresh', LEAF_4) -const TAB_A_PANE = makePaneKey('tab-A', LEAF_5) -const RUNNING_SHELL = { id: 'shell-1', type: 'shell', status: 'running' } - -type Body = { - paneKey: string - launchToken?: string - tabId?: string - worktreeId?: string - env?: string - version?: string - payload: Record -} - -type AgentHookServerCacheInternals = { - assistantMessageRetryTimers: Map> - promptSentDedupeByPaneKey: Map - runtimeObservedStatusPaneKeys: Set - scheduleStatusPersist: () => void -} - -function buildBody(payload: Record, overrides: Partial = {}): Body { - return { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - env: 'production', - payload, - ...overrides - } -} - -beforeEach(() => { - _internals.resetCachesForTests() - trackMock.mockReset() - getCohortAtEmitMock.mockReset() - getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('AgentHookServer listener replay', () => { - it('preserves Codex sibling and lead state across relay listener restarts', () => { - const server = new AgentHookServer() - const send = (state: HookListenerState, payload: Record): void => { - const event = normalizeHookPayload(state, 'codex', buildBody(payload), 'production') - if (!event) { - throw new Error('normalizeHookPayload rejected a known-good Codex fixture') - } - server.ingestRemote(event, 'conn-1') - } - - const initialRelay = createHookListenerState() - send(initialRelay, { - hook_event_name: 'UserPromptSubmit', - session_id: 'root-session', - prompt: 'coordinate reviewers', - model: 'gpt-5.4' - }) - for (const id of ['child-a', 'child-b']) { - send(initialRelay, { - hook_event_name: 'SubagentStart', - agent_id: id, - agent_type: 'reviewer', - model: 'gpt-5.4-mini' - }) - } - - const restartedRelay = createHookListenerState() - send(restartedRelay, { hook_event_name: 'SubagentStop', agent_id: 'child-a' }) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - model: 'gpt-5.4', - providerSession: { key: 'session_id', id: 'root-session' }, - subagents: [expect.objectContaining({ id: 'child-b' })] - }) - - send(restartedRelay, { - hook_event_name: 'Stop', - session_id: 'root-session', - model: 'gpt-5.4' - }) - const restartedAgain = createHookListenerState() - send(restartedAgain, { hook_event_name: 'SubagentStop', agent_id: 'child-b' }) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - model: 'gpt-5.4', - providerSession: { key: 'session_id', id: 'root-session' }, - subagents: undefined - }) - }) - - it('does not carry a remote Codex roster across connection cleanup', () => { - const server = new AgentHookServer() - const child = (id: string) => ({ - id, - state: 'working' as const, - startedAt: 1 - }) - server.ingestRemote( - { - paneKey: PANE, - hookEventName: 'SubagentStart', - toolAgentId: 'old-child', - payload: { - state: 'working', - prompt: '', - agentType: 'codex', - subagents: [child('old-child')] - } - }, - 'conn-1' - ) - - server.clearStatusEntriesForConnection('conn-1') - server.ingestRemote( - { - paneKey: PANE, - hookEventName: 'SubagentStart', - toolAgentId: 'new-child', - payload: { - state: 'working', - prompt: '', - agentType: 'codex', - subagents: [child('new-child')] - } - }, - 'conn-2' - ) - - expect(server.getStatusSnapshot()[0]?.subagents).toEqual([child('new-child')]) - }) - - it('restores a subagent roster the relay shed to fit the frame', () => { - const server = new AgentHookServer() - const roster = [ - { id: 'reviewer-1', agentType: 'reviewer', state: 'working' as const, startedAt: 1 } - ] - server.ingestRemote( - { - paneKey: PANE, - payload: { state: 'working', prompt: 'review', agentType: 'claude', subagents: roster } - }, - 'conn-1' - ) - // The relay dropped the roster to fit an oversized frame and said so on the wire. - server.ingestRemote( - { - paneKey: PANE, - shedFields: ['lastAssistantMessage', createShedSubagentsField(roster)], - payload: { state: 'done', prompt: 'review', agentType: 'claude' } - }, - 'conn-1' - ) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', subagents: roster }) - }) - - it('lets an unmarked absent roster clear, so a finished team still retires', () => { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - payload: { - state: 'working', - prompt: 'review', - agentType: 'claude', - subagents: [{ id: 'reviewer-1', agentType: 'reviewer', state: 'working', startedAt: 1 }] - } - }, - 'conn-1' - ) - server.ingestRemote( - { paneKey: PANE, payload: { state: 'done', prompt: 'review', agentType: 'claude' } }, - 'conn-1' - ) - expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() - }) - - it('does not carry Claude background work across connection cleanup', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - claudeRunningNonAgentTask: true, - payload: { state: 'working', prompt: 'old host', agentType: 'claude' } - }, - 'conn-1' - ) - - server.clearStatusEntriesForConnection('conn-1') - server.ingestRemote( - { - paneKey: PANE, - payload: { state: 'working', prompt: 'new host', agentType: 'claude' } - }, - 'conn-2' - ) - const baseline = server.getStatusSnapshot()[0] - vi.setSystemTime(1_500) - - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'new host', - baselineAgentType: 'claude', - intent: 'plain-escape' - }) - ).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it('retains root Codex identity when relay child events omit it', () => { - const server = new AgentHookServer() - const providerSession = { key: 'session_id' as const, id: 'root-session' } - - server.ingestRemote( - { - paneKey: PANE, - providerSession, - payload: { - state: 'working', - prompt: 'coordinate reviewers', - agentType: 'codex', - model: 'gpt-5.4' - } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - toolAgentId: 'child-session', - payload: { state: 'waiting', prompt: 'coordinate reviewers', agentType: 'codex' } - }, - 'conn-1' - ) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - model: 'gpt-5.4', - providerSession - }) - - server.ingestRemote( - { - paneKey: PANE, - hookEventName: 'SubagentStop', - toolAgentId: 'child-session', - payload: { state: 'done', prompt: 'coordinate reviewers', agentType: 'codex' } - }, - 'conn-1' - ) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - model: 'gpt-5.4', - providerSession - }) - }) - - it('keeps Codex lead state terminal after an inferred interrupt', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, - hookEventName: 'UserPromptSubmit', - payload: { - state: 'working', - prompt: 'long task', - agentType: 'codex', - model: 'gpt-5.6-sol' - } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - const applied = server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'long task', - baselineAgentType: 'codex', - intent: 'plain-escape' - }) - - expect(applied).toBe(true) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'done', - prompt: 'long task', - agentType: 'codex', - providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, - interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 - }) - ]) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - paneKey: PANE, - providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, - payload: expect.objectContaining({ state: 'done', interrupted: true }) - }) - ) - - vi.setSystemTime(17_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'SubagentStop', - toolAgentId: 'delayed-child', - payload: { state: 'done', prompt: 'long task', agentType: 'codex' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - model: 'gpt-5.6-sol', - prompt: 'long task' - }) - } finally { - vi.useRealTimers() - } - }) - - it('does not infer an interrupt while a subagent child is still working', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { - state: 'working', - prompt: 'review loop', - agentType: 'claude', - // Why: a working pane can be child-driven while the lead is idle; Ctrl+C doesn't stop children, so no terminal-done may be inferred here. - subagents: [{ id: 'a1', state: 'working', startedAt: 900 }] - } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - const applied = server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'review loop', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - - expect(applied).toBe(false) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) - } finally { - vi.useRealTimers() - } - }) - - it('does not infer an interrupt while Claude reports a background shell', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - claudeRunningNonAgentTask: true, - payload: { state: 'working', prompt: 'run in background', agentType: 'claude' } - }, - 'conn-1' - ) - let baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'run in background', - baselineAgentType: 'claude', - intent: 'plain-escape' - }) - ).toBe(false) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) - - vi.setSystemTime(2_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - claudeRunningNonAgentTask: false, - payload: { state: 'working', prompt: 'run in background', agentType: 'claude' } - }, - 'conn-1' - ) - baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(2_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'run in background', - baselineAgentType: 'claude', - intent: 'plain-escape' - }) - ).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it('blocks local HTTP interrupt inference for provider-owned work without exposing transport metadata', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postHook = (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await expect( - postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' }) - ).resolves.toMatchObject({ status: 204 }) - await expect( - postHook({ hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) - ).resolves.toMatchObject({ status: 204 }) - const baseline = server.getStatusSnapshot()[0] - - expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask') - expect(baseline).toMatchObject({ state: 'working', workingMode: 'monitoring' }) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'run in background', - baselineAgentType: 'claude', - intent: 'plain-escape' - }) - ).toBe(false) - - await expect( - postHook({ - hook_event_name: 'Stop', - background_tasks: [], - session_crons: [{ id: 'cron-1' }] - }) - ).resolves.toMatchObject({ status: 204 }) - const cronBaseline = server.getStatusSnapshot()[0] - expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(true) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: cronBaseline.receivedAt, - baselineStateStartedAt: cronBaseline.stateStartedAt, - baselinePrompt: 'run in background', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - ).toBe(false) - } finally { - server.stop() - } - }) - - it('uses replayed Claude background metadata only before a live observation', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - isReplay: true, - claudeRunningNonAgentTask: true, - payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'stale replay', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - ).toBe(false) - - vi.setSystemTime(1_500) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - claudeRunningNonAgentTask: false, - payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' } - }, - 'conn-1' - ) - vi.setSystemTime(2_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - isReplay: true, - claudeRunningNonAgentTask: true, - payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' } - }, - 'conn-1' - ) - const liveBaseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(2_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: liveBaseline.receivedAt, - baselineStateStartedAt: liveBaseline.stateStartedAt, - baselinePrompt: 'stale replay', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - ).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it('does not apply Claude background metadata from a rejected remote status', () => { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - hookEventName: 'PermissionRequest', - claudeRunningNonAgentTask: true, - payload: { - state: 'waiting', - prompt: 'approve shell', - agentType: 'claude', - toolName: 'Bash' - } - }, - 'conn-1' - ) - const waiting = server.getStatusSnapshot()[0] - - server.ingestRemote( - { - paneKey: PANE, - hookEventName: 'PreToolUse', - claudeRunningNonAgentTask: false, - payload: { - state: 'working', - prompt: 'approve shell', - agentType: 'claude', - toolName: 'OtherTool' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()[0]).toEqual(waiting) - expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(true) - }) - - it('carries idle subagent rows through an inferred interrupt', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { - state: 'working', - prompt: 'wrap up', - agentType: 'claude', - subagents: [{ id: 'a1', state: 'idle', startedAt: 900, agentType: 'probe1' }] - } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - const applied = server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'wrap up', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - - expect(applied).toBe(true) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - interrupted: true, - subagents: [expect.objectContaining({ id: 'a1', state: 'idle' })] - }) - } finally { - vi.useRealTimers() - } - }) - - it('preserves an inferred interrupted row when OpenCode immediately reports SessionIdle', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'long task', agentType: 'opencode' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'long task', - baselineAgentType: 'opencode', - intent: 'plain-escape', - inputCount: 2 - }) - ).toBe(true) - - vi.setSystemTime(1_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'long task', agentType: 'opencode' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'done', - prompt: 'long task', - agentType: 'opencode', - interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 - }) - ]) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - paneKey: PANE, - payload: expect.objectContaining({ state: 'done', interrupted: true }) - }) - ) - } finally { - vi.useRealTimers() - } - }) - - it('rejects inferred interrupts when a same-millisecond prompt update changed the row', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'first task', agentType: 'codex' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'second task', agentType: 'codex' } - }, - 'conn-1' - ) - - const applied = server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'first task', - baselineAgentType: 'codex', - intent: 'plain-escape' - }) - - expect(applied).toBe(false) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'second task', - agentType: 'codex' - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it.each(['opencode', 'copilot'] as const)( - 'rejects single plain Escape inference for %s', - (agentType) => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'long task', agentType } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - const applied = server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'long task', - baselineAgentType: agentType, - intent: 'plain-escape' - }) - - expect(applied).toBe(false) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'long task', - agentType - }) - ]) - } finally { - vi.useRealTimers() - } - } - ) - - it.each(['opencode', 'copilot'] as const)( - 'accepts double plain Escape inference for %s', - (agentType) => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'long task', agentType } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - const applied = server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'long task', - baselineAgentType: agentType, - intent: 'plain-escape', - inputCount: 2 - }) - - expect(applied).toBe(true) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'done', - prompt: 'long task', - agentType, - interrupted: true - }) - ]) - } finally { - vi.useRealTimers() - } - } - ) - - it('rejects Ctrl+C inference for Droid', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'long task', agentType: 'droid' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - const applied = server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'long task', - baselineAgentType: 'droid', - intent: 'ctrl-c' - }) - - expect(applied).toBe(false) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'long task', - agentType: 'droid' - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('does not let late same-turn working hooks resurrect an inferred interrupt', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'long task', agentType: 'pi' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'long task', - baselineAgentType: 'pi', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(6_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { - state: 'working', - prompt: 'long task', - agentType: 'pi', - toolName: 'bash', - toolInput: '/bin/sleep 90' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'done', - prompt: 'long task', - agentType: 'pi', - interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('does not let late Claude tool hooks with explicit prompt resurrect an inferred interrupt', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - hookEventName: 'UserPromptSubmit', - payload: { - state: 'working', - prompt: 'Do I have gpu acceleration on on my terminal?', - agentType: 'claude' - } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'Do I have gpu acceleration on on my terminal?', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(2_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - hookEventName: 'PostToolUse', - payload: { - state: 'working', - prompt: 'Do I have gpu acceleration on on my terminal?', - agentType: 'claude', - toolName: 'Read', - toolInput: 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'done', - prompt: 'Do I have gpu acceleration on on my terminal?', - agentType: 'claude', - interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('allows a new prompt after an inferred interrupt', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'first task', agentType: 'pi' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'first task', - baselineAgentType: 'pi', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(2_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'second task', agentType: 'pi' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'second task', - agentType: 'pi', - receivedAt: 2_000, - stateStartedAt: 2_000 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('allows a Claude follow-up prompt after an inferred interrupt to keep working', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - hookEventName: 'UserPromptSubmit', - payload: { state: 'working', prompt: 'first Claude turn', agentType: 'claude' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'first Claude turn', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(2_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - hookEventName: 'UserPromptSubmit', - payload: { state: 'working', prompt: 'second queued Claude turn', agentType: 'claude' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'second queued Claude turn', - agentType: 'claude', - interrupted: undefined, - receivedAt: 2_000, - stateStartedAt: 2_000 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('allows an immediate same-prompt retry after an inferred interrupt', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'retryable task', agentType: 'pi' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'retryable task', - baselineAgentType: 'pi', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(2_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'retryable task', agentType: 'pi' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'retryable task', - agentType: 'pi', - receivedAt: 2_000, - stateStartedAt: 2_000 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('suppresses same-turn Claude tool progress after the stale suppression window', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - hookEventName: 'UserPromptSubmit', - payload: { state: 'working', prompt: 'repeat task', agentType: 'claude' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'repeat task', - baselineAgentType: 'claude', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(16_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - hookEventName: 'PostToolUse', - payload: { - state: 'working', - prompt: 'repeat task', - agentType: 'claude', - toolName: 'bash', - toolInput: '/bin/sleep 90' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'done', - prompt: 'repeat task', - agentType: 'claude', - interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('allows generic non-explicit same-prompt working after the stale suppression window', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'repeat task', - baselineAgentType: 'pi', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(16_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'repeat task', - agentType: 'pi', - interrupted: undefined, - receivedAt: 16_501, - stateStartedAt: 16_501 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('allows non-Claude tool-context working after the stale suppression window', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'repeat task', - baselineAgentType: 'pi', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(16_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { - state: 'working', - prompt: 'repeat task', - agentType: 'pi', - toolName: 'bash', - toolInput: '/bin/sleep 90' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'repeat task', - agentType: 'pi', - interrupted: undefined, - toolName: 'bash', - toolInput: '/bin/sleep 90', - receivedAt: 16_501, - stateStartedAt: 16_501 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('rejects malformed inferred interrupt requests without throwing', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'long task', agentType: 'codex' } - }, - 'conn-1' - ) - const malformed: unknown[] = [ - { - paneKey: 'tab-1:0', - baselineUpdatedAt: 1_000, - baselineStateStartedAt: 1_000, - baselinePrompt: 'long task', - baselineAgentType: 'codex', - intent: 'ctrl-c' - }, - { - paneKey: PANE, - baselineUpdatedAt: 1_000, - baselineStateStartedAt: 1_000, - baselinePrompt: 'long task', - baselineAgentType: 'codex', - intent: 'sigint' - }, - { - paneKey: PANE, - baselineUpdatedAt: '1_000', - baselineStateStartedAt: 1_000, - baselinePrompt: 'long task', - baselineAgentType: 'codex', - intent: 'ctrl-c' - }, - { - paneKey: PANE, - baselineUpdatedAt: 1_000, - baselineStateStartedAt: 1_000, - baselinePrompt: 123, - baselineAgentType: 'codex', - intent: 'ctrl-c' - } - ] - - for (const request of malformed) { - expect(() => - server.inferInterrupt(request as Parameters[0]) - ).not.toThrow() - expect( - server.inferInterrupt(request as Parameters[0]) - ).toBe(false) - } - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'long task', - agentType: 'codex' - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('allows an immediate same-prompt retry that carries cached turn detail', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { - state: 'working', - prompt: 'retryable task', - agentType: 'opencode', - lastAssistantMessage: 'partial answer' - } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'retryable task', - baselineAgentType: 'opencode', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(2_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'retryable task', - agentType: 'opencode', - lastAssistantMessage: 'partial answer' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'retryable task', - agentType: 'opencode', - lastAssistantMessage: 'partial answer', - receivedAt: 2_000, - stateStartedAt: 2_000 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('suppresses replayed same-prompt working events after an inferred interrupt', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'retryable task', - agentType: 'opencode', - lastAssistantMessage: 'partial answer' - } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'retryable task', - baselineAgentType: 'opencode', - intent: 'ctrl-c' - }) - ).toBe(true) - - vi.setSystemTime(20_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - isReplay: true, - payload: { - state: 'working', - prompt: 'retryable task', - agentType: 'opencode', - lastAssistantMessage: 'partial answer' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'done', - prompt: 'retryable task', - agentType: 'opencode', - interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('matches renderer unknown sentinel to an omitted hook agent type', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'custom hook' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot()[0] - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'custom hook', - baselineAgentType: 'unknown', - intent: 'ctrl-c' - }) - ).toBe(true) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'done', - prompt: 'custom hook', - interrupted: true - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('rejects inferred interrupts for stale and non-working rows', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'waiting', prompt: 'permission', agentType: 'codex' } - }, - 'conn-1' - ) - const waiting = server.getStatusSnapshot()[0] - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: waiting.receivedAt, - baselineStateStartedAt: waiting.stateStartedAt, - baselinePrompt: 'permission', - baselineAgentType: 'codex', - intent: 'plain-escape' - }) - ).toBe(false) - - server.ingestRemote( - { - paneKey: FRESH_PANE, - tabId: 'tab-fresh', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'old task', agentType: 'codex' } - }, - 'conn-1' - ) - const stale = server.getStatusSnapshot().find((entry) => entry.paneKey === FRESH_PANE)! - vi.setSystemTime(stale.receivedAt + 30 * 60 * 1000 + 1) - expect( - server.inferInterrupt({ - paneKey: FRESH_PANE, - baselineUpdatedAt: stale.receivedAt, - baselineStateStartedAt: stale.stateStartedAt, - baselinePrompt: 'old task', - baselineAgentType: 'codex', - intent: 'plain-escape' - }) - ).toBe(false) - } finally { - vi.useRealTimers() - } - }) - - it('applies inferred interrupts for arbitrary agent types and Ctrl+C intent', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: GOOD_PANE, - tabId: 'tab-good', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'custom task', agentType: 'custom-agent' } - }, - 'conn-1' - ) - const baseline = server.getStatusSnapshot().find((entry) => entry.paneKey === GOOD_PANE)! - - vi.setSystemTime(1_250) - expect( - server.inferInterrupt({ - paneKey: GOOD_PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'custom task', - baselineAgentType: 'custom-agent', - intent: 'ctrl-c' - }) - ).toBe(true) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: GOOD_PANE, - state: 'done', - prompt: 'custom task', - agentType: 'custom-agent', - interrupted: true - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('allows multiple status-change subscribers to observe the same update', () => { - const server = new AgentHookServer() - const first = vi.fn() - const second = vi.fn() - server.subscribeStatusChanges(first) - server.subscribeStatusChanges(second) - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', agentType: 'claude' } - }, - 'conn-1' - ) - - expect(first).toHaveBeenCalledWith([ - expect.objectContaining({ - state: 'working', - receivedAt: expect.any(Number), - observedInCurrentRuntime: true - }) - ]) - expect(second).toHaveBeenCalledWith([ - expect.objectContaining({ - state: 'working', - receivedAt: expect.any(Number), - observedInCurrentRuntime: true - }) - ]) - }) - - it('notifies provider-session subscribers without changing status listener arguments', () => { - const server = new AgentHookServer() - const statuses = vi.fn() - const sessions = vi.fn() - server.subscribeStatusChanges(statuses) - server.subscribeProviderSessionChanges(sessions) - - server.ingestRemote( - { - paneKey: PANE, - worktreeId: 'wt-1', - providerSession: { - key: 'session_id', - id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl' - }, - providerSessionOnly: true, - payload: { state: 'done', agentType: 'pi' } - }, - 'conn-1' - ) - - expect(statuses).toHaveBeenCalledWith([]) - expect(sessions).toHaveBeenCalledWith([ - { - paneKey: PANE, - sessionId: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl', - worktreeId: 'wt-1' - } - ]) - }) - - it('keeps status-change subscribers when renderer fanout listener is cleared', () => { - const server = new AgentHookServer() - const statusChangeListener = vi.fn() - const rendererListener = vi.fn() - server.subscribeStatusChanges(statusChangeListener) - server.setListener(rendererListener) - server.setListener(null) - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', agentType: 'claude' } - }, - 'conn-1' - ) - - expect(statusChangeListener).toHaveBeenCalledTimes(1) - expect(rendererListener).not.toHaveBeenCalled() - }) - - it('marks listener replay callbacks as replayed', () => { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'cached task', agentType: 'codex' } - }, - 'conn-1' - ) - - const listener = vi.fn() - server.setListener(listener) - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - isReplay: true, - payload: expect.objectContaining({ state: 'working', prompt: 'cached task' }) - }) - ) - }) - - it('unsubscribes status-change listeners without removing the remaining listeners', () => { - const server = new AgentHookServer() - const removed = vi.fn() - const remaining = vi.fn() - const unsubscribe = server.subscribeStatusChanges(removed) - server.subscribeStatusChanges(remaining) - - unsubscribe() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', agentType: 'claude' } - }, - 'conn-1' - ) - - expect(removed).not.toHaveBeenCalled() - expect(remaining).toHaveBeenCalledWith([ - expect.objectContaining({ - state: 'working', - observedInCurrentRuntime: true - }) - ]) - }) - - it('notifies status-change subscribers when a working status is dropped or cleared', () => { - const server = new AgentHookServer() - const listener = vi.fn() - server.subscribeStatusChanges(listener) - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', agentType: 'claude' } - }, - 'conn-1' - ) - server.dropStatusEntry(PANE) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', agentType: 'claude' } - }, - 'conn-1' - ) - server.clearPaneState(PANE) - - expect(listener).toHaveBeenNthCalledWith(2, []) - expect(listener).toHaveBeenNthCalledWith(4, []) - }) - - it('notifies pane-status-clear listener when pane teardown evicts a cached status', () => { - const server = new AgentHookServer() - const listener = vi.fn() - server.setPaneStatusClearListener(listener) - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', agentType: 'claude' } - }, - 'conn-1' - ) - server.clearPaneState(PANE) - server.clearPaneState(PANE) - - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith({ paneKey: PANE }) - }) - - it('batches connection cleanup and retains sibling and local statuses', () => { - const server = new AgentHookServer() - const paneKeyAt = (prefix: string, index: number): string => - makePaneKey( - `${prefix}-tab-${index}`, - `00000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, '0')}` - ) - const targetPaneKeys = Array.from({ length: 100 }, (_, index) => paneKeyAt('target', index)) - const siblingPaneKeys = Array.from({ length: 100 }, (_, index) => - paneKeyAt('sibling', index + 100) - ) - const unstampedPaneKey = paneKeyAt('legacy', 250) - const statusListener = vi.fn() - const clearListener = vi.fn() - const internals = server as unknown as AgentHookServerCacheInternals - const persistSpy = vi.spyOn(internals, 'scheduleStatusPersist') - server.subscribeStatusChanges(statusListener) - server.setPaneStatusClearListener(clearListener) - for (const paneKey of targetPaneKeys) { - server.ingestRemote({ paneKey, payload: { state: 'working', agentType: 'claude' } }, 'ssh-a') - } - for (const paneKey of siblingPaneKeys) { - server.ingestRemote({ paneKey, payload: { state: 'working', agentType: 'claude' } }, 'ssh-b') - } - server.ingestTerminalStatus({ - paneKey: unstampedPaneKey, - payload: { state: 'working', prompt: '', agentType: 'codex' } - }) - statusListener.mockClear() - persistSpy.mockClear() - - server.clearStatusEntriesForConnection('ssh-a') - - expect(statusListener).toHaveBeenCalledOnce() - expect(persistSpy).toHaveBeenCalledOnce() - expect(clearListener).toHaveBeenCalledOnce() - expect(clearListener).toHaveBeenCalledWith({ - transient: true, - connectionId: 'ssh-a', - clearedAt: expect.any(Number) - }) - expect(server.getStatusSnapshot().map((entry) => entry.paneKey)).toEqual([ - ...siblingPaneKeys, - unstampedPaneKey - ]) - }) - - it('emits a connection cutoff after a pane-key collision and orders replay after it', () => { - vi.useFakeTimers() - vi.setSystemTime(1_700_000_000_000) - const server = new AgentHookServer() - const clearListener = vi.fn() - server.setPaneStatusClearListener(clearListener) - server.ingestRemote( - { paneKey: PANE, payload: { state: 'working', agentType: 'claude' } }, - 'ssh-a' - ) - server.ingestRemote( - { paneKey: PANE, payload: { state: 'working', agentType: 'codex' } }, - 'ssh-b' - ) - - server.clearStatusEntriesForConnection('ssh-a') - const clear = clearListener.mock.calls[0]?.[0] as { - transient: true - connectionId: string - clearedAt: number - } - server.ingestRemote( - { paneKey: GOOD_PANE, payload: { state: 'working', agentType: 'claude' }, isReplay: true }, - 'ssh-a' - ) - - expect(clear).toMatchObject({ transient: true, connectionId: 'ssh-a' }) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ paneKey: PANE, connectionId: 'ssh-b' }), - expect.objectContaining({ - paneKey: GOOD_PANE, - connectionId: 'ssh-a', - receivedAt: clear.clearedAt + 1 - }) - ]) - vi.useRealTimers() - }) - - it('drops cached statuses and pane-scoped listener caches under one tab prefix', () => { - vi.useFakeTimers() - try { - const server = new AgentHookServer() - const internals = server as unknown as AgentHookServerCacheInternals - const sameTabPane = makePaneKey('tab-1', LEAF_2) - const siblingPrefixPane = makePaneKey('tab-10', LEAF_3) - const statusListener = vi.fn() - const aliasPersist = vi.fn() - const sameTabRetry = vi.fn() - const siblingRetry = vi.fn() - server.subscribeStatusChanges(statusListener) - server.setPaneKeyAliasPersistenceListener(aliasPersist) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'first', agentType: 'claude' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: sameTabPane, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'second', agentType: 'codex' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: siblingPrefixPane, - tabId: 'tab-10', - worktreeId: 'wt-2', - payload: { state: 'working', prompt: 'sibling', agentType: 'claude' } - }, - 'conn-1' - ) - server.registerPaneKeyAlias('tab-1:0', sameTabPane, 'pty-1') - const state = server._getStateForTests() - state.lastPromptByPaneKey.set(PANE, 'cached prompt') - state.lastToolByPaneKey.set(`${sameTabPane}\0tool`, {} as never) - state.antigravityCompletedTranscriptByPaneKey.set(`${sameTabPane}\0done`, 'cached') - state.ampCompletedCacheKeys.add(`${sameTabPane}\0amp`) - state.lastPromptByPaneKey.set(siblingPrefixPane, 'sibling prompt') - internals.assistantMessageRetryTimers.set(PANE, setTimeout(sameTabRetry, 1_000)) - internals.assistantMessageRetryTimers.set(siblingPrefixPane, setTimeout(siblingRetry, 1_000)) - internals.promptSentDedupeByPaneKey.set(PANE, { promptHash: 'same-tab' }) - internals.promptSentDedupeByPaneKey.set(siblingPrefixPane, { promptHash: 'sibling' }) - const scheduleStatusPersist = vi.spyOn(internals, 'scheduleStatusPersist') - statusListener.mockClear() - aliasPersist.mockClear() - scheduleStatusPersist.mockClear() - - server.dropStatusEntriesByTabPrefix('tab-1') - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ paneKey: siblingPrefixPane, prompt: 'sibling' }) - ]) - expect(state.lastPromptByPaneKey.has(PANE)).toBe(false) - expect(state.lastToolByPaneKey.has(`${sameTabPane}\0tool`)).toBe(false) - expect(state.antigravityCompletedTranscriptByPaneKey.has(`${sameTabPane}\0done`)).toBe(false) - expect(state.ampCompletedCacheKeys.has(`${sameTabPane}\0amp`)).toBe(false) - expect(state.lastPromptByPaneKey.get(siblingPrefixPane)).toBe('sibling prompt') - expect(internals.assistantMessageRetryTimers.has(PANE)).toBe(false) - expect(internals.assistantMessageRetryTimers.has(siblingPrefixPane)).toBe(true) - expect(internals.promptSentDedupeByPaneKey.has(PANE)).toBe(false) - expect(internals.promptSentDedupeByPaneKey.get(siblingPrefixPane)).toEqual({ - promptHash: 'sibling' - }) - expect(internals.runtimeObservedStatusPaneKeys.has(PANE)).toBe(false) - expect(internals.runtimeObservedStatusPaneKeys.has(sameTabPane)).toBe(false) - expect(internals.runtimeObservedStatusPaneKeys.has(siblingPrefixPane)).toBe(true) - expect(statusListener).toHaveBeenCalledTimes(1) - expect(statusListener).toHaveBeenCalledWith([ - expect.objectContaining({ state: 'working', observedInCurrentRuntime: true }) - ]) - expect(aliasPersist).toHaveBeenCalledTimes(1) - expect(aliasPersist).toHaveBeenCalledWith([]) - expect(scheduleStatusPersist).toHaveBeenCalledTimes(1) - - vi.advanceTimersByTime(1_000) - expect(sameTabRetry).not.toHaveBeenCalled() - expect(siblingRetry).toHaveBeenCalledTimes(1) - } finally { - vi.clearAllTimers() - vi.useRealTimers() - } - }) - - it('suppresses late writes for a closed tab for the rest of the server session', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'before close', agentType: 'codex' } - }, - 'conn-1' - ) - - server.dropStatusEntriesByTabPrefix('tab-1') - listener.mockClear() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'late remote', agentType: 'codex' } - }, - 'conn-1' - ) - server.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'late terminal', agentType: 'codex' } - }) - - vi.setSystemTime(16_001) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'future reuse', agentType: 'codex' } - }, - 'conn-1' - ) - - expect(listener).not.toHaveBeenCalled() - expect(server.getStatusSnapshot()).toEqual([]) - } finally { - vi.useRealTimers() - } - }) - - it('accepts statuses for unrelated tabs while another tab is recently closed', () => { - const server = new AgentHookServer() - server.dropStatusEntriesByTabPrefix('tab-1') - server.ingestRemote( - { - paneKey: GOOD_PANE, - tabId: 'tab-good', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'unrelated', agentType: 'claude' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ paneKey: GOOD_PANE, state: 'working', prompt: 'unrelated' }) - ]) - }) - - it('suppresses local HTTP hook writes for a recently closed tab', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postHook = (prompt: string): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody({ hook_event_name: 'UserPromptSubmit', prompt })) - }) - - await expect(postHook('before close')).resolves.toMatchObject({ status: 204 }) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ paneKey: PANE, prompt: 'before close' }) - ]) - - server.dropStatusEntriesByTabPrefix('tab-1') - await expect(postHook('late local')).resolves.toMatchObject({ status: 204 }) - - expect(server.getStatusSnapshot()).toEqual([]) - } finally { - server.stop() - } - }) - - it('accepts a new local prompt after launch authority retires in a reusable pane', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postHook = (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' })) - }) - - await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' }) - server.retirePaneAuthority(PANE) - await postHook({ hook_event_name: 'Stop', last_assistant_message: 'late prior hook' }) - expect(server.getStatusSnapshot()).toEqual([]) - - await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'manual restart' }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ paneKey: PANE, state: 'working', prompt: 'manual restart' }) - ]) - expect( - server.attestCompatibilityAuthority({ - paneKey: PANE, - launchTokenHash: createHash('sha256').update('retired-launch-token').digest('hex'), - connectionId: null, - terminalProvenance: 'current_runtime' - }) - ).toBeNull() - } finally { - server.stop() - } - }) - - it('accepts a resumed-session SessionStart after launch authority retires in a reusable pane', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postHook = (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' })) - }) - - await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' }) - server.retirePaneAuthority(PANE) - - // Why: `claude --resume` in the reused shell pane emits only SessionStart while idle; - // without the un-retire the resumed session stays rowless until a prompt (STA-3386). - await postHook({ hook_event_name: 'SessionStart', source: 'resume' }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ paneKey: PANE, state: 'done', sessionBoundary: true }) - ]) - } finally { - server.stop() - } - }) - - it('accepts a live remote prompt but not replay after launch authority retires', () => { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - hookEventName: 'UserPromptSubmit', - launchToken: 'retired-remote-launch-token', - payload: { state: 'working', prompt: 'first launch', agentType: 'claude' } - }, - 'conn-1' - ) - server.retirePaneAuthority(PANE) - - server.ingestRemote( - { - paneKey: PANE, - hookEventName: 'UserPromptSubmit', - launchToken: 'retired-remote-launch-token', - isReplay: true, - payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' } - }, - 'conn-1' - ) - expect(server.getStatusSnapshot()).toEqual([]) - - server.ingestRemote( - { - paneKey: PANE, - hookEventName: 'UserPromptSubmit', - launchToken: 'retired-remote-launch-token', - payload: { state: 'working', prompt: 'remote manual restart', agentType: 'claude' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - prompt: 'remote manual restart', - connectionId: 'conn-1' - }) - ]) - expect( - server.attestCompatibilityAuthority({ - paneKey: PANE, - launchTokenHash: createHash('sha256').update('retired-remote-launch-token').digest('hex'), - connectionId: 'conn-1', - terminalProvenance: 'current_runtime' - }) - ).toBeNull() - }) - - it('hydrates cached statuses as not observed in the current runtime', async () => { - const dir = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-')) - const firstServer = new AgentHookServer() - const secondServer = new AgentHookServer() - try { - await firstServer.start({ env: 'production', userDataPath: dir }) - firstServer.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', agentType: 'claude' } - }, - 'conn-1' - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - await secondServer.start({ env: 'production', userDataPath: dir }) - - expect(secondServer.getStatusChangeSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - observedInCurrentRuntime: false - }) - ]) - } finally { - firstServer.stop() - secondServer.stop() - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('replays the latest retained pane status when a listener attaches after windowless events', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy() - expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() - - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ - hook_event_name: 'UserPromptSubmit', - prompt: 'replay me' - }) - ) - }) - expect(response.status).toBe(204) - - const listener = vi.fn() - server.setListener(listener) - - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - receivedAt: expect.any(Number), - stateStartedAt: expect.any(Number), - payload: expect.objectContaining({ - state: 'working', - prompt: 'replay me', - agentType: 'claude' - }) - }) - ) - } finally { - server.stop() - } - }) - - it('keeps Claude permission visible when another subagent reports tool activity', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await expect( - postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } - }) - ).resolves.toMatchObject({ status: 204 }) - await expect( - postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'Read', - tool_input: { file_path: '/tmp/other-subagent.txt' } - }) - ).resolves.toMatchObject({ status: 204 }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'rm -rf /tmp/orca-subagent-repro' - }) - ]) - } finally { - server.stop() - } - }) - - it('keeps Claude permission visible when matching tool activity has no execution id', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'rm -rf /tmp/orca-subagent-repro' - }) - ]) - } finally { - server.stop() - } - }) - - it('keeps Claude permission visible when approved tool execution has no identity', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' }, - tool_use_id: 'toolu-approved-1' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'rm -rf /tmp/orca-subagent-repro' - }) - ]) - } finally { - server.stop() - } - }) - - it('lets Claude permission clear when approved PostToolUse matches the preceding tool use id', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }, - tool_use_id: 'toolu-approved-by-claude' - }) - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' } - }) - await postClaudeHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }, - tool_use_id: 'toolu-approved-by-claude' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'rm -rf /tmp/orca-2824-permission-target' - }) - ]) - } finally { - server.stop() - } - }) - - it('lets Claude permission clear by tool use id when tool input is not previewable', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'BespokeTool', - tool_input: { opaque: 'request-a' }, - tool_use_id: 'toolu-approved-opaque' - }) - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'BespokeTool', - tool_input: { opaque: 'request-a' } - }) - await postClaudeHook({ - hook_event_name: 'PostToolUse', - tool_name: 'BespokeTool', - tool_input: { opaque: 'request-a' }, - tool_use_id: 'toolu-approved-opaque' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - toolName: 'BespokeTool' - }) - ]) - } finally { - server.stop() - } - }) - - it('keeps Claude permission visible for unpreviewable tool input with another tool use id', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'BespokeTool', - tool_input: { opaque: 'request-a' }, - tool_use_id: 'toolu-permission-owner-opaque' - }) - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'BespokeTool', - tool_input: { opaque: 'request-a' } - }) - await postClaudeHook({ - hook_event_name: 'PostToolUse', - tool_name: 'BespokeTool', - tool_input: { opaque: 'request-b' }, - tool_use_id: 'toolu-other-opaque' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'BespokeTool' - }) - ]) - } finally { - server.stop() - } - }) - - it('keeps Claude permission visible when another tool use completes after permission', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' }, - tool_use_id: 'toolu-permission-owner' - }) - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' } - }) - await postClaudeHook({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' }, - tool_use_id: 'toolu-other-subagent' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - }) - ]) - } finally { - server.stop() - } - }) - - it('keeps Claude permission visible when an explicit agent type reports another tool use id', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PreToolUse', - agent_type: 'main', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' }, - tool_use_id: 'toolu-permission-owner-type' - }) - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - agent_type: 'main', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' } - }) - await postClaudeHook({ - hook_event_name: 'PostToolUse', - agent_type: 'main', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' }, - tool_use_id: 'toolu-other-type' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - }) - ]) - } finally { - server.stop() - } - }) - - it('lets Claude permission clear when same explicit agent type starts the approved tool', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - agent_type: 'main', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - agent_type: 'main', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' }, - tool_use_id: 'toolu-approved-1' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'rm -rf /tmp/orca-subagent-repro' - }) - ]) - } finally { - server.stop() - } - }) - - it('lets Claude subagent permission clear when the same agent starts the approved tool', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - agent_id: 'agent-subagent-a', - agent_type: 'Review', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - agent_id: 'agent-subagent-a', - agent_type: 'Review', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' }, - tool_use_id: 'toolu-approved-subagent' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - }) - ]) - } finally { - server.stop() - } - }) - - it('lets same Claude subagent clear an unknown approved tool without an input preview', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - agent_id: 'agent-custom-tool', - agent_type: 'Review', - tool_name: 'BespokeTool', - tool_input: { request_id: 'pending-1' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - agent_id: 'agent-custom-tool', - agent_type: 'Review', - tool_name: 'BespokeTool', - tool_input: { request_id: 'pending-1' }, - tool_use_id: 'toolu-custom-approved' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - toolName: 'BespokeTool', - toolInput: undefined - }) - ]) - } finally { - server.stop() - } - }) - - it('keeps Claude permission visible when another same-type subagent reports the same tool execution', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - agent_id: 'agent-subagent-a', - agent_type: 'Review', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - agent_id: 'agent-subagent-b', - agent_type: 'Review', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' }, - tool_use_id: 'toolu-other-subagent' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - }) - ]) - } finally { - server.stop() - } - }) - - it('keeps Claude permission visible when unknown tool previews collide', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'BespokeTool', - tool_input: { request_id: 'pending-1' } - }) - await postClaudeHook({ - hook_event_name: 'PreToolUse', - tool_name: 'BespokeTool', - tool_input: { request_id: 'other-subagent' } - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'waiting', - agentType: 'claude', - toolName: 'BespokeTool', - toolInput: undefined - }) - ]) - } finally { - server.stop() - } - }) - - it('lets Claude permission clear when a new explicit prompt starts', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => - fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } - }) - await postClaudeHook({ - hook_event_name: 'UserPromptSubmit', - prompt: 'start a new task' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude', - prompt: 'start a new task', - toolName: undefined, - toolInput: undefined - }) - ]) - } finally { - server.stop() - } - }) - - it('does not replay cleared pane state to a newly attached listener', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ - hook_event_name: 'UserPromptSubmit', - prompt: 'clear me' - }) - ) - }) - - server.clearPaneState(PANE) - const listener = vi.fn() - server.setListener(listener) - - expect(listener).not.toHaveBeenCalled() - } finally { - server.stop() - } - }) - - it('ignores local nested Claude Stop while a parent Codex hook status is active', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const listener = vi.fn() - server.setListener(listener) - const postHook = async ( - source: 'codex' | 'claude', - payload: Record - ): Promise => { - const response = await fetch( - `http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - } - ) - expect(response.status).toBe(204) - } - - await postHook('codex', { - hook_event_name: 'UserPromptSubmit', - prompt: 'parent codex' - }) - await postHook('claude', { - hook_event_name: 'Stop', - last_assistant_message: 'child finished' - }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - prompt: 'parent codex', - agentType: 'codex' - }) - ]) - const snapshot = server.getStatusSnapshot()[0] - expect(snapshot.lastAssistantMessage).toBeUndefined() - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - state: 'working', - prompt: 'parent codex', - agentType: 'codex' - }) - }) - ) - } finally { - server.stop() - } - }) - - it('does not apply Claude background evidence from a rejected local status', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const postClaudeHook = async (payload: Record): Promise => { - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody(payload)) - }) - expect(response.status).toBe(204) - } - - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - prompt: 'approve shell', - tool_name: 'Bash', - background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }], - session_crons: [{ id: 'cron-1', status: 'running' }] - }) - const waiting = server.getStatusSnapshot()[0] - - await postClaudeHook({ - hook_event_name: 'PreToolUse', - prompt: 'approve shell', - tool_name: 'OtherTool', - background_tasks: [], - session_crons: [] - }) - - expect(server.getStatusSnapshot()[0]).toEqual(waiting) - expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(true) - expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(true) - } finally { - server.stop() - } - }) - - it('maps registered legacy numeric HTTP pane keys to stable pane keys', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - server.registerPaneKeyAlias('tab-1:0', PANE) - const env = server.buildPtyEnv() - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody( - { - hook_event_name: 'UserPromptSubmit', - prompt: 'legacy pane' - }, - { paneKey: 'tab-1:0' } - ) - ) - }) - expect(response.status).toBe(204) - - const listener = vi.fn() - server.setListener(listener) - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: expect.objectContaining({ - state: 'working', - prompt: 'legacy pane', - agentType: 'claude' - }) - }) - ) - } finally { - server.stop() - } - }) - - it('tracks hook posts with an empty paneKey before dropping them', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody( - { - hook_event_name: 'UserPromptSubmit', - prompt: 'missing pane' - }, - { paneKey: '' } - ) - ) - }) - const listener = vi.fn() - server.setListener(listener) - - expect(response.status).toBe(204) - expect(listener).not.toHaveBeenCalled() - expect(trackMock).toHaveBeenCalledWith('agent_hook_unattributed', { - reason: 'empty_pane_key' - }) - } finally { - server.stop() - } - }) - - // Why (agent-status-over-SSH §3): ingestRemote must run the same warn-once diagnostics as the local HTTP path so stale remote hooks signal locally. - it('runs warn-once env/version diagnostics on relay-forwarded events', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const listener = vi.fn() - server.setListener(listener) - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - env: 'development', - version: '999', - payload: { - state: 'working', - paneKey: PANE, - updatedAt: Date.now(), - agentType: 'claude' - } - }, - 'conn-1' - ) - - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - connectionId: 'conn-1', - payload: expect.objectContaining({ state: 'working', agentType: 'claude' }) - }) - ) - - const warnCalls = warn.mock.calls.map((c) => String(c[0])) - expect(warnCalls.some((m) => m.includes('v999'))).toBe(true) - expect(warnCalls.some((m) => m.includes('development') && m.includes('production'))).toBe( - true - ) - - const warnsAfterFirst = warn.mock.calls.length - const secondPane = makePaneKey('tab-2', LEAF_2) - server.ingestRemote( - { - paneKey: secondPane, - env: 'development', - version: '999', - payload: { - state: 'working', - paneKey: secondPane, - updatedAt: Date.now(), - agentType: 'claude' - } - }, - 'conn-1' - ) - expect(warn.mock.calls.length).toBe(warnsAfterFirst) - // Why: assert fanout still fires on the second event too, else a refactor that drops it would pass on warn-count alone. - expect(listener).toHaveBeenCalledTimes(2) - } finally { - server.stop() - } - }) - - it('treats remote env as normal relay traffic and normalizes payload at the trust boundary', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const listener = vi.fn() - server.setListener(listener) - - const oversizedPrompt = 'x'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH + 50) - const remotePane = makePaneKey('tab-3', LEAF_3) - server.ingestRemote( - { - paneKey: ` ${remotePane} `, - tabId: ' tab-3 ', - worktreeId: ' wt-3 ', - env: 'remote', - version: '1', - payload: { - state: 'done', - prompt: oversizedPrompt, - agentType: 'codex' - } - }, - ' conn-9 ' - ) - - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: remotePane, - tabId: 'tab-3', - worktreeId: 'wt-3', - connectionId: 'conn-9', - payload: expect.objectContaining({ - state: 'done', - agentType: 'codex', - prompt: 'x'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH) - }) - }) - ) - expect(warn).not.toHaveBeenCalled() - } finally { - server.stop() - } - }) - - it('accepts form-encoded hook posts from Unix managed scripts', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const params = new URLSearchParams({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'repo::/tmp/worktree with "quotes"', - env: 'production', - version: env.ORCA_AGENT_HOOK_VERSION ?? '', - payload: JSON.stringify({ - hook_event_name: 'UserPromptSubmit', - prompt: 'form encoded' - }) - }) - - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: params - }) - expect(response.status).toBe(204) - - const listener = vi.fn() - server.setListener(listener) - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'repo::/tmp/worktree with "quotes"', - connectionId: null, - receivedAt: expect.any(Number), - stateStartedAt: expect.any(Number), - payload: expect.objectContaining({ - state: 'working', - prompt: 'form encoded', - agentType: 'claude' - }) - }) - ) - } finally { - server.stop() - } - }) - - it('tracks Codex agent statuses from form-encoded managed hook posts', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const listener = vi.fn() - server.setListener(listener) - const postCodexHook = async (payload: Record): Promise => { - const params = new URLSearchParams({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - env: 'production', - version: env.ORCA_AGENT_HOOK_VERSION ?? '', - payload: JSON.stringify(payload) - }) - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: params - }) - expect(response.status).toBe(204) - } - - await postCodexHook({ - hook_event_name: 'UserPromptSubmit', - prompt: 'ship codex hook status' - }) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - state: 'working', - agentType: 'codex', - prompt: 'ship codex hook status', - toolName: undefined, - toolInput: undefined - }) - ]) - - await postCodexHook({ - hook_event_name: 'PreToolUse', - tool_name: 'exec_command', - tool_input: { cmd: 'pnpm test', workdir: '/repo' } - }) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - agentType: 'codex', - prompt: 'ship codex hook status', - toolName: 'exec_command', - toolInput: 'pnpm test' - }) - ]) - - await postCodexHook({ - hook_event_name: 'PermissionRequest', - tool_name: 'exec_command', - tool_input: { cmd: 'git push', workdir: '/repo' } - }) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'waiting', - agentType: 'codex', - prompt: 'ship codex hook status', - toolName: 'exec_command', - toolInput: 'git push' - }) - ]) - - await postCodexHook({ - hook_event_name: 'Stop', - last_assistant_message: 'done' - }) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'done', - agentType: 'codex', - prompt: 'ship codex hook status', - lastAssistantMessage: 'done' - }) - ]) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: expect.objectContaining({ - state: 'done', - agentType: 'codex', - prompt: 'ship codex hook status', - lastAssistantMessage: 'done' - }) - }) - ) - } finally { - server.stop() - } - }) - - it('accepts Hermes plugin hook posts on /hook/hermes', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/hermes`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ - hook_event_name: 'pre_llm_call', - user_message: 'verify Hermes route' - }) - ) - }) - expect(response.status).toBe(204) - - const listener = vi.fn() - server.setListener(listener) - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - payload: expect.objectContaining({ - state: 'working', - prompt: 'verify Hermes route', - agentType: 'hermes' - }) - }) - ) - } finally { - server.stop() - } - }) - - it('accepts Amp plugin hook posts on /hook/amp', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const listener = vi.fn() - server.setListener(listener) - - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/amp`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ - hook_event_name: 'agent.start', - message: 'verify Amp route' - }) - ) - }) - expect(response.status).toBe(204) - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - payload: expect.objectContaining({ - state: 'working', - prompt: 'verify Amp route', - agentType: 'amp' - }) - }) - ) - } finally { - server.stop() - } - }) -}) - -describe('Amp hook normalization', () => { - it('maps agent lifecycle events to working and done states', () => { - const start = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.start', - message: 'wire Amp hooks' - }), - 'production' - ) - - expect(start?.payload).toMatchObject({ - state: 'working', - prompt: 'wire Amp hooks', - agentType: 'amp' - }) - - const done = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.end', - message: 'wire Amp hooks', - status: 'done' - }), - 'production' - ) - - expect(done?.payload).toMatchObject({ - state: 'done', - prompt: 'wire Amp hooks', - agentType: 'amp' - }) - }) - - it('surfaces Amp tool call and result context while preserving the prompt', () => { - _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.start', - message: 'run tests' - }), - 'production' - ) - - const toolCall = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'tool.call', - tool: 'shell_command', - input: { command: 'pnpm test --run src/main/amp/hook-service.test.ts' } - }), - 'production' - ) - - expect(toolCall?.payload).toMatchObject({ - state: 'working', - prompt: 'run tests', - agentType: 'amp', - toolName: 'shell_command', - toolInput: 'pnpm test --run src/main/amp/hook-service.test.ts' - }) - - const result = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'tool.result', - tool: 'shell_command', - input: { command: 'pnpm test --run src/main/amp/hook-service.test.ts' }, - status: 'done', - output: 'tests passed' - }), - 'production' - ) - - expect(result?.payload).toMatchObject({ - state: 'working', - prompt: 'run tests', - agentType: 'amp', - toolName: 'shell_command', - toolInput: 'pnpm test --run src/main/amp/hook-service.test.ts', - lastAssistantMessage: 'tests passed' - }) - }) - - it('does not let Amp tool result messages overwrite the cached prompt', () => { - _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.start', - message: 'run tests' - }), - 'production' - ) - - const result = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'tool.result', - tool: 'shell_command', - input: { command: 'pnpm test' }, - message: 'tests passed' - }), - 'production' - ) - - expect(result?.payload).toMatchObject({ - state: 'working', - prompt: 'run tests', - agentType: 'amp', - lastAssistantMessage: 'tests passed' - }) - - const done = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.end', - status: 'done' - }), - 'production' - ) - - expect(done?.payload).toMatchObject({ - state: 'done', - prompt: 'run tests', - agentType: 'amp' - }) - }) - - it('keeps Amp prompt and tool caches isolated by thread id within one pane', () => { - _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.start', - threadId: 'thread-a', - message: 'first task' - }), - 'production' - ) - - _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.start', - threadId: 'thread-b', - message: 'second task' - }), - 'production' - ) - - const threadAResult = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'tool.result', - threadId: 'thread-a', - tool: 'shell_command', - input: { command: 'pnpm test:a' }, - output: 'first done' - }), - 'production' - ) - - expect(threadAResult?.payload).toMatchObject({ - state: 'working', - prompt: 'first task', - agentType: 'amp', - toolName: 'shell_command', - toolInput: 'pnpm test:a', - lastAssistantMessage: 'first done' - }) - - const threadBDone = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.end', - threadId: 'thread-b', - status: 'done' - }), - 'production' - ) - - expect(threadBDone?.payload).toMatchObject({ - state: 'done', - prompt: 'second task', - agentType: 'amp' - }) - }) - - it('drops stale Amp tool events that arrive after the thread ended', () => { - _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.start', - threadId: 'thread-a', - message: 'run tests' - }), - 'production' - ) - - const done = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.end', - threadId: 'thread-a', - status: 'done' - }), - 'production' - ) - - expect(done?.payload).toMatchObject({ - state: 'done', - prompt: 'run tests', - agentType: 'amp' - }) - - const staleToolResult = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'tool.result', - threadId: 'thread-a', - tool: 'shell_command', - input: { command: 'pnpm test' }, - message: 'tests passed' - }), - 'production' - ) - - expect(staleToolResult).toBeNull() - }) - - it('does not mark Amp tool result messages as explicit prompts', () => { - _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.start', - threadId: 'thread-a', - message: 'run tests' - }), - 'production' - ) - - const result = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'tool.result', - threadId: 'thread-a', - tool: 'shell_command', - input: { command: 'pnpm test' }, - message: 'tests passed' - }), - 'production' - ) - - expect(result?.payload).toMatchObject({ - state: 'working', - prompt: 'run tests', - agentType: 'amp', - lastAssistantMessage: 'tests passed' - }) - expect(result?.hasExplicitPrompt).toBeUndefined() - }) - - it('marks cancelled Amp turns as interrupted done states', () => { - const cancelled = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.end', - message: 'stop this run', - status: 'cancelled' - }), - 'production' - ) - - expect(cancelled?.payload).toMatchObject({ - state: 'done', - prompt: 'stop this run', - agentType: 'amp', - interrupted: true - }) - }) - - it('treats session.start as cache reset without creating a visible row', () => { - _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'agent.start', - message: 'old prompt' - }), - 'production' - ) - - const sessionStart = _internals.normalizeHookPayload( - 'amp', - buildBody({ hook_event_name: 'session.start', threadId: 'thread-1' }), - 'production' - ) - expect(sessionStart).toBeNull() - - const nextTool = _internals.normalizeHookPayload( - 'amp', - buildBody({ - hook_event_name: 'tool.call', - tool: 'Read', - input: { file_path: '/tmp/file.ts' } - }), - 'production' - ) - - expect(nextTool?.payload).toMatchObject({ - state: 'working', - prompt: '', - agentType: 'amp', - toolName: 'Read', - toolInput: '/tmp/file.ts' - }) - }) -}) - -describe('AgentHookServer prompt-sent telemetry', () => { - it('tracks a live local hook explicit prompt with conservative attribution', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ - hook_event_name: 'UserPromptSubmit', - prompt: ' fix the spinner ' - }) - ) - }) - - expect(response.status).toBe(204) - expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', { - agent_kind: 'claude-code', - launch_source: 'unknown', - request_kind: 'followup', - nth_repo_added: 2 - }) - } finally { - server.stop() - } - }) - - it('tracks a live SSH hook explicit prompt through ingestRemote', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'remote prompt', agentType: 'codex' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', { - agent_kind: 'codex', - launch_source: 'unknown', - request_kind: 'followup', - nth_repo_added: 2 - }) - }) - - it('dedupes adjacent same-turn reports without considering hook state', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'same turn', agentType: 'gemini' } - }, - 'conn-1' - ) - vi.setSystemTime(1_500) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'done', prompt: 'same turn', agentType: 'gemini' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledTimes(1) - expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', { - agent_kind: 'gemini', - launch_source: 'unknown', - request_kind: 'followup', - nth_repo_added: 2 - }) - } finally { - vi.useRealTimers() - } - }) - - it('tracks the same prompt again after a completed turn starts over', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'continue', agentType: 'codex' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'continue', agentType: 'codex' } - }, - 'conn-1' - ) - vi.setSystemTime(1_500) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'continue', agentType: 'codex' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - - it('dedupes duplicate Command Code stop hooks but tracks same-prompt reruns', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - promptInteractionKey: 'command-code-transcript-user-1', - payload: { state: 'done', prompt: 'rerun', agentType: 'command-code' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - promptInteractionKey: 'command-code-transcript-user-1', - payload: { state: 'done', prompt: 'rerun', agentType: 'command-code' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - promptInteractionKey: 'command-code-transcript-user-2', - payload: { state: 'done', prompt: 'rerun', agentType: 'command-code' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledTimes(2) - }) - - it('includes Command Code prompt interaction keys in the IPC snapshot', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - promptInteractionKey: 'command-code-transcript-user-1', - payload: { state: 'working', prompt: 'rerun', agentType: 'command-code' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ - paneKey: PANE, - promptInteractionKey: 'command-code-transcript-user-1', - state: 'working', - prompt: 'rerun', - agentType: 'command-code' - }) - }) - - it('dedupes Command Code direct prompt hooks followed by transcript-backed stop hooks', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'same command', agentType: 'command-code' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - promptInteractionKey: 'command-code-transcript-a-1', - payload: { state: 'done', prompt: 'same command', agentType: 'command-code' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledTimes(1) - }) - - it('does not let a reused interaction key suppress different prompt text', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - promptInteractionKey: 'command-code-transcript-reused', - payload: { state: 'done', prompt: 'first command', agentType: 'command-code' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - promptInteractionKey: 'command-code-transcript-reused', - payload: { state: 'done', prompt: 'second command', agentType: 'command-code' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledTimes(2) - }) - - it('does not treat Command Code cached prompts as explicit prompt evidence', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'done', prompt: 'cached prompt', agentType: 'command-code' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: false, - payload: { state: 'done', prompt: 'cached prompt', agentType: 'command-code' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledTimes(1) - }) - - it('preserves prompt dedupe when a live status row is dismissed', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'long turn', agentType: 'codex' } - }, - 'conn-1' - ) - server.dropStatusEntry(PANE) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'long turn', agentType: 'codex' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledTimes(1) - }) - - it('lets a dismissed completed row start the same prompt again', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'done', prompt: 'rerun after done', agentType: 'codex' } - }, - 'conn-1' - ) - server.dropStatusEntry(PANE) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'rerun after done', agentType: 'codex' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledTimes(2) - }) - - it('dedupes the same prompt until a completed turn boundary is observed', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'repeat later', agentType: 'codex' } - }, - 'conn-1' - ) - vi.setSystemTime(32_000) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'repeat later', agentType: 'codex' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - - it('does not track replays, empty prompts, or inherited prompt snapshots', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - isReplay: true, - payload: { state: 'working', prompt: 'replayed prompt', agentType: 'codex' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: GOOD_PANE, - tabId: 'tab-good', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: ' ', agentType: 'codex' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: FRESH_PANE, - tabId: 'tab-fresh', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'inherited prompt', agentType: 'codex' } - }, - 'conn-1' - ) - - expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) - }) - - it('does not track hook status messages that preserve a cached prompt', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'real prompt', agentType: 'droid' } - }, - 'conn-1' - ) - trackMock.mockClear() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: false, - payload: { state: 'waiting', prompt: 'real prompt', agentType: 'droid' } - }, - 'conn-1' - ) - - expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) - }) - - it('tracks OpenCode user MessagePart hooks once per message id', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/opencode`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ - hook_event_name: 'MessagePart', - role: 'user', - text: 'fix', - messageID: 'msg-1' - }) - ) - }) - const updatedResponse = await fetch( - `http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/opencode`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ - hook_event_name: 'MessagePart', - role: 'user', - text: 'fix tests', - messageID: 'msg-1' - }) - ) - } - ) - - expect(response.status).toBe(204) - expect(updatedResponse.status).toBe(204) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - prompt: 'fix tests', - agentType: 'opencode' - }) - expect(trackMock).toHaveBeenCalledTimes(1) - expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', { - agent_kind: 'opencode', - launch_source: 'unknown', - request_kind: 'followup', - nth_repo_added: 2 - }) - } finally { - server.stop() - } - }) - - it('maps custom hook agent types to other', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'custom prompt', agentType: 'my-local-agent' } - }, - 'conn-1' - ) - - expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', { - agent_kind: 'other', - launch_source: 'unknown', - request_kind: 'followup', - nth_repo_added: 2 - }) - }) - - it('does not block status cache mutation or listener fanout when telemetry throws', () => { - const server = new AgentHookServer() - const listener = vi.fn() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - trackMock.mockImplementationOnce(() => { - throw new Error('telemetry unavailable') - }) - server.setListener(listener) - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'keep status moving', agentType: 'codex' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - prompt: 'keep status moving', - agentType: 'codex' - }) - ]) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - payload: expect.objectContaining({ prompt: 'keep status moving' }) - }) - ) - errorSpy.mockRestore() - }) -}) - -describe('Claude hook normalization', () => { - it('PostToolUse for Edit surfaces toolName + file_path preview', () => { - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PostToolUse', - tool_name: 'Edit', - tool_input: { file_path: '/src/config.ts', old_string: 'a', new_string: 'b' }, - tool_response: {} - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('Edit') - expect(result?.payload.toolInput).toBe('/src/config.ts') - }) - - it('PostToolUse for Bash surfaces the command string', () => { - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'pnpm test --run' }, - tool_response: { content: [{ type: 'text', text: 'tests passed' }] } - }), - 'production' - ) - expect(result?.payload.toolName).toBe('Bash') - expect(result?.payload.toolInput).toBe('pnpm test --run') - expect(result?.payload.lastAssistantMessage).toBe('tests passed') - }) - - it('PostToolUse for Grep surfaces the search pattern', () => { - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PostToolUse', - tool_name: 'Grep', - tool_input: { pattern: 'foo.*bar', path: '/src' } - }), - 'production' - ) - expect(result?.payload.toolName).toBe('Grep') - expect(result?.payload.toolInput).toBe('foo.*bar') - }) - - it('PostToolUse for an unknown tool surfaces the name without input', () => { - // Why: a per-tool allowlist picks the preview field; unrecognized tools render name-only to avoid guessing a misleading field (e.g. an opaque ID). - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PostToolUse', - tool_name: 'BespokeTool', - tool_input: { irrelevantFlag: true, summary: 'doing the thing' } - }), - 'production' - ) - expect(result?.payload.toolName).toBe('BespokeTool') - expect(result?.payload.toolInput).toBeUndefined() - }) - - it('PostToolUse for TaskUpdate does not produce a misleading input preview', () => { - // Why: TaskUpdate's tool_input has no meaningful preview (rendering "3" is confusing), so the allowlist leaves toolInput undefined. - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PostToolUse', - tool_name: 'TaskUpdate', - tool_input: { task_id: '3', status: 'in_progress' } - }), - 'production' - ) - expect(result?.payload.toolName).toBe('TaskUpdate') - expect(result?.payload.toolInput).toBeUndefined() - }) - - it('PostToolUseFailure clears stale tool fields until the next PreToolUse', () => { - _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'Edit', - tool_input: { file_path: '/src/config.ts' } - }), - 'production' - ) - const failed = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PostToolUseFailure', - tool_name: 'Edit', - tool_input: { file_path: '/src/config.ts' }, - error: 'file is read-only' - }), - 'production' - ) - expect(failed?.payload).toMatchObject({ - state: 'working', - lastAssistantMessage: 'file is read-only' - }) - expect(failed?.payload.toolName).toBeUndefined() - expect(failed?.payload.toolInput).toBeUndefined() - - const retry = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'Read', - tool_input: { file_path: '/src/config.ts' } - }), - 'production' - ) - expect(retry?.payload).toMatchObject({ - state: 'working', - toolName: 'Read', - toolInput: '/src/config.ts' - }) - }) - - it('PreToolUse normalizes to working + tool fields', () => { - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'Read', - tool_input: { file_path: '/src/index.ts' } - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('Read') - expect(result?.payload.toolInput).toBe('/src/index.ts') - }) - - it('PermissionRequest normalizes to waiting + tool fields', () => { - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PermissionRequest', - tool_name: 'Bash', - tool_input: { command: 'rm -rf build' } - }), - 'production' - ) - expect(result?.payload.state).toBe('waiting') - expect(result?.payload.toolName).toBe('Bash') - expect(result?.payload.toolInput).toBe('rm -rf build') - }) - - it('PermissionRequest for a fresh tool without input preview clears cached tool input', () => { - _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' } - }), - 'production' - ) - - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PermissionRequest', - tool_name: 'BespokeTool', - tool_input: { request_id: 'approval-1' } - }), - 'production' - ) - - expect(result?.payload.state).toBe('waiting') - expect(result?.payload.toolName).toBe('BespokeTool') - expect(result?.payload.toolInput).toBeUndefined() - }) - - it('PermissionRequest for the same tool without input preview clears cached tool input', () => { - _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'BespokeTool', - tool_input: 'old preview' - }), - 'production' - ) - - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PermissionRequest', - tool_name: 'BespokeTool', - tool_input: { request_id: 'approval-1' } - }), - 'production' - ) - - expect(result?.payload.state).toBe('waiting') - expect(result?.payload.toolName).toBe('BespokeTool') - expect(result?.payload.toolInput).toBeUndefined() - }) - - it('PermissionRequest without a tool name does not inherit stale tool details when input is explicit', () => { - _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'pnpm test' } - }), - 'production' - ) - - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PermissionRequest', - tool_input: { request_id: 'approval-1' } - }), - 'production' - ) - - expect(result?.payload.state).toBe('waiting') - expect(result?.payload.toolName).toBeUndefined() - expect(result?.payload.toolInput).toBeUndefined() - }) - - it('UserPromptSubmit clears the cached tool state from the prior turn', () => { - _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PostToolUse', - tool_name: 'Edit', - tool_input: { file_path: '/src/stale.ts' } - }), - 'production' - ) - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'UserPromptSubmit', - prompt: 'Do the next thing' - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.prompt).toBe('Do the next thing') - expect(result?.payload.toolName).toBeUndefined() - expect(result?.payload.toolInput).toBeUndefined() - }) - - it('Stop carries last_assistant_message directly when present', () => { - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'Stop', - last_assistant_message: 'what is up my dude' - }), - 'production' - ) - expect(result?.payload.state).toBe('done') - expect(result?.payload.lastAssistantMessage).toBe('what is up my dude') - }) - - it('StopFailure maps to done without copying provider error text', () => { - _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'UserPromptSubmit', - prompt: 'say hi' - }), - 'production' - ) - - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'StopFailure', - error: 'invalid_request', - error_details: 'model is not supported', - last_assistant_message: 'API Error: model is not supported' - }), - 'production' - ) - - expect(result?.payload.state).toBe('done') - expect(result?.payload.prompt).toBe('say hi') - expect(result?.payload.lastAssistantMessage).toBeUndefined() - }) - - describe('Stop transcript scan', () => { - let tmpDir: string - let transcriptPath: string - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'orca-hook-test-')) - transcriptPath = join(tmpDir, 'transcript.jsonl') - }) - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }) - }) - - it('surfaces the most recent assistant text entry', () => { - const lines = [ - { role: 'user', content: 'hi' }, - { role: 'assistant', message: { role: 'assistant', content: 'earlier reply' } }, - { role: 'user', content: 'do it' }, - { - role: 'assistant', - message: { role: 'assistant', content: [{ type: 'text', text: 'final reply' }] } - } - ] - writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) - - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), - 'production' - ) - expect(result?.payload.lastAssistantMessage).toBe('final reply') - }) - - it('skips tool_use-only assistant entries to find the previous text reply', () => { - const lines = [ - { role: 'assistant', message: { role: 'assistant', content: 'the answer is 42' } }, - { - role: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] - } - } - ] - writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) - - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), - 'production' - ) - expect(result?.payload.lastAssistantMessage).toBe('the answer is 42') - }) - - it('finds an assistant reply that sits past the first chunk boundary', () => { - // Why: large tool_result entries push the final reply past the first 64 KB chunk; the scan must keep reading backward to find it. - const filler = 'x'.repeat(70_000) - const lines = [ - { role: 'assistant', message: { role: 'assistant', content: 'deeply buried reply' } }, - // 70 KB of tool_result content straddling the first chunk boundary. - { - role: 'user', - message: { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: 't1', content: filler }] - } - }, - { - role: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] - } - } - ] - writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) - - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), - 'production' - ) - expect(result?.payload.lastAssistantMessage).toBe('deeply buried reply') - }) - - it('returns undefined when the transcript has no assistant text at all', () => { - const lines = [ - { role: 'user', content: 'hi' }, - { - role: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] - } - } - ] - writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) - - const result = _internals.normalizeHookPayload( - 'claude', - buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), - 'production' - ) - expect(result?.payload.lastAssistantMessage).toBeUndefined() - }) - }) - - it('merges tool fields across consecutive events in the same turn', () => { - _internals.normalizeHookPayload( - 'claude', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'ls -la' } - }), - 'production' - ) - // Stop event has no tool fields, so the merged snapshot must keep the earlier PreToolUse values. - const stop = _internals.normalizeHookPayload( - 'claude', - buildBody({ hook_event_name: 'Stop' }), - 'production' - ) - expect(stop?.payload.state).toBe('done') - expect(stop?.payload.toolName).toBe('Bash') - expect(stop?.payload.toolInput).toBe('ls -la') - }) -}) - -describe('Codex hook normalization', () => { - it('tracks nested subagents with their role, model, and lifecycle state', () => { - const root = _internals.normalizeHookPayload( - 'codex', - buildBody({ - hook_event_name: 'UserPromptSubmit', - prompt: 'Coordinate the review', - model: 'gpt-5.4' - }), - 'production' - ) - expect(root?.payload.model).toBe('gpt-5.4') - - const started = _internals.normalizeHookPayload( - 'codex', - buildBody({ - hook_event_name: 'SubagentStart', - session_id: 'child-session', - agent_id: 'child-session', - agent_type: 'reviewer', - model: 'gpt-5.4-mini' - }), - 'production' - ) - expect(started?.providerSession).toBeUndefined() - expect(started?.payload).toMatchObject({ - state: 'working', - prompt: 'Coordinate the review', - model: 'gpt-5.4', - subagents: [ - { - id: 'child-session', - agentType: 'reviewer', - model: 'gpt-5.4-mini', - state: 'working' - } - ] - }) - - const waiting = _internals.normalizeHookPayload( - 'codex', - buildBody({ - hook_event_name: 'PermissionRequest', - agent_id: 'child-session', - agent_type: 'reviewer', - model: 'gpt-5.4-mini', - tool_name: 'exec_command', - tool_input: { cmd: 'git fetch' } - }), - 'production' - ) - expect(waiting?.payload.state).toBe('waiting') - expect(waiting?.payload.subagents?.[0].state).toBe('waiting') - - const workingAgain = _internals.normalizeHookPayload( - 'codex', - buildBody({ - hook_event_name: 'PreToolUse', - agent_id: 'child-session', - agent_type: 'reviewer', - model: 'gpt-5.4-mini', - tool_name: 'exec_command', - tool_input: { cmd: 'git fetch' } - }), - 'production' - ) - expect(workingAgain?.payload.state).toBe('working') - expect(workingAgain?.payload.subagents?.[0].state).toBe('working') - - const rootStop = _internals.normalizeHookPayload( - 'codex', - buildBody({ hook_event_name: 'Stop', model: 'gpt-5.4' }), - 'production' - ) - expect(rootStop?.payload.state).toBe('done') - expect(rootStop?.payload.subagents).toBeUndefined() - - const resumedChild = _internals.normalizeHookPayload( - 'codex', - buildBody({ - hook_event_name: 'PreToolUse', - agent_id: 'child-session', - agent_type: 'reviewer', - model: 'gpt-5.4-mini', - tool_name: 'exec_command', - tool_input: { cmd: 'pnpm test' } - }), - 'production' - ) - expect(resumedChild?.payload.state).toBe('working') - expect(resumedChild?.payload.subagents?.[0]).toMatchObject({ - id: 'child-session', - state: 'working' - }) - - const stopped = _internals.normalizeHookPayload( - 'codex', - buildBody({ hook_event_name: 'SubagentStop', agent_id: 'child-session' }), - 'production' - ) - expect(stopped?.payload.state).toBe('done') - expect(stopped?.payload.subagents).toBeUndefined() - }) - - it('Stop carries last_assistant_message into lastAssistantMessage', () => { - const result = _internals.normalizeHookPayload( - 'codex', - buildBody({ - hook_event_name: 'Stop', - last_assistant_message: 'Summary of what I did.' - }), - 'production' - ) - expect(result?.payload.state).toBe('done') - expect(result?.payload.lastAssistantMessage).toBe('Summary of what I did.') - }) - - it('PreToolUse surfaces tool name + input preview and stays in working state', () => { - // Why: Codex's PreToolUse fires for every tool call (not an approval), so map it to `working` not `waiting`; approvals flow through PermissionRequest. - const result = _internals.normalizeHookPayload( - 'codex', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'exec_command', - tool_input: { cmd: 'git status', workdir: '/tmp' } - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('exec_command') - expect(result?.payload.toolInput).toBe('git status') - }) - - it('PermissionRequest maps to waiting and surfaces the pending tool input', () => { - // Why: PermissionRequest must map to `waiting` (sidebar red dot); treating it like PreToolUse would leave the pane looking busy while blocked on the user. - const result = _internals.normalizeHookPayload( - 'codex', - buildBody({ - hook_event_name: 'PermissionRequest', - tool_name: 'exec_command', - tool_input: { cmd: 'rm -rf build', workdir: '/tmp' } - }), - 'production' - ) - expect(result?.payload.state).toBe('waiting') - expect(result?.payload.agentType).toBe('codex') - expect(result?.payload.toolName).toBe('exec_command') - expect(result?.payload.toolInput).toBe('rm -rf build') - }) - - it('UserPromptSubmit does not extract tool fields even when the payload carries them', () => { - // Why: UserPromptSubmit is a turn boundary; tool extraction is gated to Pre/PostToolUse so stray tool_name can't leak into the preview. - const result = _internals.normalizeHookPayload( - 'codex', - buildBody({ - hook_event_name: 'UserPromptSubmit', - prompt: 'Hello', - tool_name: 'Edit', - tool_input: { file_path: '/ignored.ts' } - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBeUndefined() - expect(result?.payload.toolInput).toBeUndefined() - }) - - it('SessionStart clears cached tool state from a prior session', () => { - // Seed a Stop snapshot with an assistant message. - _internals.normalizeHookPayload( - 'codex', - buildBody({ - hook_event_name: 'Stop', - last_assistant_message: 'Previous run finished' - }), - 'production' - ) - const result = _internals.normalizeHookPayload( - 'codex', - buildBody({ hook_event_name: 'SessionStart' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.lastAssistantMessage).toBeUndefined() - }) - - it('SessionStart clears the cached prompt from a prior session until a new prompt arrives', () => { - _internals.normalizeHookPayload( - 'codex', - buildBody({ - hook_event_name: 'UserPromptSubmit', - prompt: 'stale prompt' - }), - 'production' - ) - const result = _internals.normalizeHookPayload( - 'codex', - buildBody({ hook_event_name: 'SessionStart' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.prompt).toBe('') - }) -}) - -describe('Gemini hook normalization', () => { - it('BeforeTool surfaces toolName + toolInput', () => { - const result = _internals.normalizeHookPayload( - 'gemini', - buildBody({ - hook_event_name: 'BeforeTool', - tool_name: 'read_file', - tool_input: { path: '/src/index.ts' } - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('read_file') - expect(result?.payload.toolInput).toBe('/src/index.ts') - }) - - it('falls back to args when tool_input is absent', () => { - const result = _internals.normalizeHookPayload( - 'gemini', - buildBody({ - hook_event_name: 'BeforeTool', - tool_name: 'run_shell_command', - args: { command: 'git status' } - }), - 'production' - ) - expect(result?.payload.toolName).toBe('run_shell_command') - expect(result?.payload.toolInput).toBe('git status') - }) - - it('BeforeAgent clears the cached tool state from a prior turn', () => { - _internals.normalizeHookPayload( - 'gemini', - buildBody({ - hook_event_name: 'BeforeTool', - tool_name: 'read_file', - tool_input: { path: '/stale.ts' } - }), - 'production' - ) - const result = _internals.normalizeHookPayload( - 'gemini', - buildBody({ hook_event_name: 'BeforeAgent' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBeUndefined() - expect(result?.payload.toolInput).toBeUndefined() - }) - - it('AfterAgent reports done without introducing tool fields on its own', () => { - const result = _internals.normalizeHookPayload( - 'gemini', - buildBody({ hook_event_name: 'AfterAgent' }), - 'production' - ) - expect(result?.payload.state).toBe('done') - expect(result?.payload.toolName).toBeUndefined() - }) - - it('AfterAgent carries prompt_response into lastAssistantMessage', () => { - const result = _internals.normalizeHookPayload( - 'gemini', - buildBody({ - hook_event_name: 'AfterAgent', - prompt: 'what did you do', - prompt_response: 'I ran the tests and they passed.', - stop_hook_active: false - }), - 'production' - ) - expect(result?.payload.state).toBe('done') - expect(result?.payload.lastAssistantMessage).toBe('I ran the tests and they passed.') - }) -}) - -describe('OpenCode hook normalization', () => { - it('SessionBusy maps to working', () => { - const result = _internals.normalizeHookPayload( - 'opencode', - buildBody({ hook_event_name: 'SessionBusy' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.agentType).toBe('opencode') - }) - - it('SessionBusy does NOT clear the cached user prompt', () => { - // Why: OpenCode caches the user's MessagePart before SessionBusy fires, so the cached prompt is this turn's; clearing it would clobber the dashboard. - _internals.normalizeHookPayload( - 'opencode', - buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'new prompt' }), - 'production' - ) - const result = _internals.normalizeHookPayload( - 'opencode', - buildBody({ hook_event_name: 'SessionBusy' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.prompt).toBe('new prompt') - }) - - it('SessionIdle maps to done', () => { - const result = _internals.normalizeHookPayload( - 'opencode', - buildBody({ hook_event_name: 'SessionIdle' }), - 'production' - ) - expect(result?.payload.state).toBe('done') - expect(result?.payload.agentType).toBe('opencode') - }) - - it('PermissionRequest maps to waiting', () => { - const result = _internals.normalizeHookPayload( - 'opencode', - buildBody({ hook_event_name: 'PermissionRequest' }), - 'production' - ) - expect(result?.payload.state).toBe('waiting') - }) - - it('AskUserQuestion maps to waiting', () => { - // Why: AskUserQuestion leaves the agent idle-but-waiting on a human, so it must map to `waiting` (red dot) like permission.asked, not stay `working`. - const result = _internals.normalizeHookPayload( - 'opencode', - buildBody({ hook_event_name: 'AskUserQuestion' }), - 'production' - ) - expect(result?.payload.state).toBe('waiting') - expect(result?.payload.agentType).toBe('opencode') - }) - - it('unknown event name returns null', () => { - const result = _internals.normalizeHookPayload( - 'opencode', - buildBody({ hook_event_name: 'SomeOtherEvent' }), - 'production' - ) - expect(result).toBeNull() - }) - - it('MessagePart with role=user surfaces text as the prompt and stays working', () => { - const result = _internals.normalizeHookPayload( - 'opencode', - buildBody({ - hook_event_name: 'MessagePart', - role: 'user', - text: 'hi there', - messageID: 'msg-1' - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.prompt).toBe('hi there') - expect(result?.hasExplicitPrompt).toBe(true) - expect(result?.promptInteractionKey).toBe('opencode-message-msg-1') - }) - - it('MessagePart with role=assistant populates lastAssistantMessage', () => { - const result = _internals.normalizeHookPayload( - 'opencode', - buildBody({ - hook_event_name: 'MessagePart', - role: 'assistant', - text: 'Hello! How can I help?' - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.lastAssistantMessage).toBe('Hello! How can I help?') - }) - - it('caps oversized MessagePart text from stale (pre-throttle) plugin builds', () => { - // Why: stale plugin builds re-post the full reply on every part update, so the listener must cap the text to keep per-event work O(cap). - const assistant = _internals.normalizeHookPayload( - 'opencode', - buildBody({ - hook_event_name: 'MessagePart', - role: 'assistant', - text: 'a'.repeat(500_000) - }), - 'production' - ) - expect(assistant?.payload.lastAssistantMessage?.length).toBe(8_000) - - // Why: prompt is capped at 200 by normalizeAgentStatusObject; assert oversized input still stays within that bound. - const user = _internals.normalizeHookPayload( - 'opencode', - buildBody({ - hook_event_name: 'MessagePart', - role: 'user', - text: 'u'.repeat(500_000), - messageID: 'msg-cap' - }), - 'production' - ) - expect(user?.payload.prompt?.length).toBe(200) - }) - - it('subsequent SessionIdle preserves cached prompt + assistant message', () => { - _internals.normalizeHookPayload( - 'opencode', - buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'hi' }), - 'production' - ) - _internals.normalizeHookPayload( - 'opencode', - buildBody({ hook_event_name: 'MessagePart', role: 'assistant', text: 'hello back' }), - 'production' - ) - const done = _internals.normalizeHookPayload( - 'opencode', - buildBody({ hook_event_name: 'SessionIdle' }), - 'production' - ) - expect(done?.payload.state).toBe('done') - expect(done?.payload.prompt).toBe('hi') - expect(done?.payload.lastAssistantMessage).toBe('hello back') - }) -}) - -describe('Cursor hook normalization', () => { - it('beforeSubmitPrompt maps to working and captures the prompt', () => { - const result = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'add a README' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.agentType).toBe('cursor') - expect(result?.payload.prompt).toBe('add a README') - }) - - it('stop maps to done', () => { - const result = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'stop', status: 'completed' }), - 'production' - ) - expect(result?.payload.state).toBe('done') - expect(result?.payload.agentType).toBe('cursor') - expect(result?.payload.interrupted).toBeUndefined() - }) - - it('stop with non-completed status marks the turn interrupted', () => { - const result = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'stop', status: 'cancelled' }), - 'production' - ) - expect(result?.payload.state).toBe('done') - expect(result?.payload.interrupted).toBe(true) - }) - - it('beforeShellExecution maps to working with the pending command as toolInput', () => { - const result = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'beforeShellExecution', command: 'rm -rf /tmp/foo' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('Shell') - expect(result?.payload.toolInput).toBe('rm -rf /tmp/foo') - }) - - it('beforeMCPExecution maps to working', () => { - const result = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'beforeMCPExecution', tool_name: 'fetch', url: 'https://x' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('fetch') - }) - - it('preToolUse surfaces tool name + input preview and stays working', () => { - const result = _internals.normalizeHookPayload( - 'cursor', - buildBody({ - hook_event_name: 'preToolUse', - tool_name: 'Read', - tool_input: { file_path: '/repo/src/app.ts' } - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('Read') - expect(result?.payload.toolInput).toBe('/repo/src/app.ts') - }) - - it('postToolUseFailure surfaces the error and clears stale tool fields', () => { - _internals.normalizeHookPayload( - 'cursor', - buildBody({ - hook_event_name: 'preToolUse', - tool_name: 'Read', - tool_input: { file_path: '/repo/src/app.ts' } - }), - 'production' - ) - const failed = _internals.normalizeHookPayload( - 'cursor', - buildBody({ - hook_event_name: 'postToolUseFailure', - tool_name: 'Read', - tool_input: { file_path: '/repo/src/app.ts' }, - error_message: 'file not found' - }), - 'production' - ) - // Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error. - expect(failed?.payload).toMatchObject({ - state: 'working', - lastAssistantMessage: 'file not found' - }) - expect(failed?.payload.toolName).toBeUndefined() - expect(failed?.payload.toolInput).toBeUndefined() - }) - - it('afterAgentResponse carries text into lastAssistantMessage', () => { - const result = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'afterAgentResponse', text: 'Done — wrote the README.' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.lastAssistantMessage).toBe('Done — wrote the README.') - }) - - it('late afterAgentResponse after stop keeps Cursor done instead of resurrecting working', () => { - const submit = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'add tests' }), - 'production' - ) - expect(submit).not.toBeNull() - if (!submit) { - throw new Error('expected Cursor beforeSubmitPrompt to normalize') - } - agentHookServer.ingestRemote( - { - paneKey: submit.paneKey, - tabId: submit.tabId, - worktreeId: submit.worktreeId, - payload: submit.payload - }, - 'conn-1' - ) - - const stop = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'stop', status: 'completed' }), - 'production' - ) - expect(stop).not.toBeNull() - if (!stop) { - throw new Error('expected Cursor stop to normalize') - } - agentHookServer.ingestRemote( - { - paneKey: stop.paneKey, - tabId: stop.tabId, - worktreeId: stop.worktreeId, - payload: stop.payload - }, - 'conn-1' - ) - - const response = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'afterAgentResponse', text: 'All set.' }), - 'production' - ) - expect(response?.payload.state).toBe('done') - expect(response?.payload.lastAssistantMessage).toBe('All set.') - if (!response) { - throw new Error('expected Cursor afterAgentResponse to normalize') - } - - agentHookServer.ingestRemote( - { - paneKey: response.paneKey, - tabId: response.tabId, - worktreeId: response.worktreeId, - payload: response.payload - }, - 'conn-1' - ) - expect(agentHookServer.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'done', - agentType: 'cursor', - prompt: 'add tests', - lastAssistantMessage: 'All set.' - }) - ]) - }) - - it('tool-heavy turn keeps working across shell and generic tool hooks until stop', () => { - _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'run checks' }), - 'production' - ) - const shell = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'beforeShellExecution', command: 'pnpm test' }), - 'production' - ) - expect(shell?.payload.state).toBe('working') - const tool = _internals.normalizeHookPayload( - 'cursor', - buildBody({ - hook_event_name: 'preToolUse', - tool_name: 'Read', - tool_input: { file_path: '/repo/src/app.ts' } - }), - 'production' - ) - expect(tool?.payload.state).toBe('working') - const stop = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'stop', status: 'completed' }), - 'production' - ) - expect(stop?.payload.state).toBe('done') - expect(stop?.payload.prompt).toBe('run checks') - }) - - it('beforeSubmitPrompt clears the cached tool state from a prior turn', () => { - _internals.normalizeHookPayload( - 'cursor', - buildBody({ - hook_event_name: 'preToolUse', - tool_name: 'Edit', - tool_input: { file_path: '/stale.ts' } - }), - 'production' - ) - const result = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'new turn' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.prompt).toBe('new turn') - expect(result?.payload.toolName).toBeUndefined() - expect(result?.payload.toolInput).toBeUndefined() - }) - - it('subsequent stop preserves the cached prompt from beforeSubmitPrompt', () => { - _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'add tests' }), - 'production' - ) - const stop = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'stop', status: 'completed' }), - 'production' - ) - expect(stop?.payload.state).toBe('done') - expect(stop?.payload.prompt).toBe('add tests') - }) - - it('unknown event name returns null', () => { - const result = _internals.normalizeHookPayload( - 'cursor', - buildBody({ hook_event_name: 'somethingElse' }), - 'production' - ) - expect(result).toBeNull() - }) -}) - -describe('Droid hook normalization', () => { - it('UserPromptSubmit maps to working and captures the prompt', () => { - const result = _internals.normalizeHookPayload( - 'droid', - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'ship this fix' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.agentType).toBe('droid') - expect(result?.payload.prompt).toBe('ship this fix') - }) - - it('Notification maps permission prompts to waiting and idle prompts to done', () => { - const waiting = _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'Notification', - message: 'Droid needs your permission to use Execute' - }), - 'production' - ) - expect(waiting?.payload.state).toBe('waiting') - - const done = _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'Notification', - message: 'Droid is waiting for your input' - }), - 'production' - ) - expect(done?.payload.state).toBe('done') - - const ignored = _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'Notification', - message: 'Task completed successfully' - }), - 'production' - ) - expect(ignored).toBeNull() - }) - - it('Notification preserves the cached user prompt instead of using status text as prompt', () => { - _internals.normalizeHookPayload( - 'droid', - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'write tests' }), - 'production' - ) - - const done = _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'Notification', - message: 'Droid is waiting for your input' - }), - 'production' - ) - - expect(done?.payload.state).toBe('done') - expect(done?.payload.prompt).toBe('write tests') - expect(done?.hasExplicitPrompt).toBe(false) - }) - - it('Notification ignores confirmation status text rather than treating it as permission', () => { - const result = _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'Notification', - message: 'Confirmed configuration loaded' - }), - 'production' - ) - - expect(result).toBeNull() - }) - - it('SubagentStop does not mark Droid done for mission progress', () => { - const result = _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'SubagentStop', - last_assistant_message: '# Completed Wrote the requested validation assertions' - }), - 'production' - ) - - expect(result).toBeNull() - }) - - it('PreToolUse maps to working and surfaces the tool name and input preview', () => { - const result = _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'Read', - tool_input: { file_path: '/tmp/example.ts' } - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('Read') - expect(result?.payload.toolInput).toBe('/tmp/example.ts') - }) - - it('PreToolUse falls back to the `name` and `input` fields when tool_name/tool_input are absent', () => { - const result = _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'PreToolUse', - name: 'Bash', - input: { command: 'pnpm typecheck' } - }), - 'production' - ) - expect(result?.payload.toolName).toBe('Bash') - expect(result?.payload.toolInput).toBe('pnpm typecheck') - }) - - it('PreToolUse AskUser maps to waiting for human input', () => { - const result = _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'AskUser', - tool_input: { question: 'Which permission-requiring action should I perform?' } - }), - 'production' - ) - - expect(result?.payload.state).toBe('waiting') - expect(result?.payload.toolName).toBe('AskUser') - }) - - it('PreToolUse high-risk Execute maps to waiting for approval', () => { - const result = _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'Execute', - tool_input: { - command: 'echo "test modification" >> ~/.claude/config.json', - riskLevel: 'high', - riskLevelReason: "This command modifies the user's Claude Code config file." - } - }), - 'production' - ) - - expect(result?.payload.state).toBe('waiting') - expect(result?.payload.toolName).toBe('Execute') - expect(result?.payload.toolInput).toBe('echo "test modification" >> ~/.claude/config.json') - }) - - it('PermissionRequest maps low-impact Edit approvals to waiting and carries cached tool', () => { - _internals.normalizeHookPayload( - 'droid', - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'edit it to none' }), - 'production' - ) - _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'Edit', - tool_input: { - file_path: '/Users/thebr/.claude/settings.json', - old_str: '"preferredNotifChannel": "terminal_bell"', - new_str: '"preferredNotifChannel": "none"' - } - }), - 'production' - ) - - const result = _internals.normalizeHookPayload( - 'droid', - buildBody({ hook_event_name: 'PermissionRequest' }), - 'production' - ) - - expect(result?.payload.state).toBe('waiting') - expect(result?.payload.prompt).toBe('edit it to none') - expect(result?.payload.toolName).toBe('Edit') - expect(result?.payload.toolInput).toBe('/Users/thebr/.claude/settings.json') - }) - - it('SessionStart resets turn caches without marking Droid working', () => { - _internals.normalizeHookPayload( - 'droid', - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'old prompt' }), - 'production' - ) - _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'Read', - tool_input: { file_path: '/tmp/old.ts' } - }), - 'production' - ) - - const sessionStart = _internals.normalizeHookPayload( - 'droid', - buildBody({ hook_event_name: 'SessionStart' }), - 'production' - ) - expect(sessionStart).toBeNull() - - const nextTool = _internals.normalizeHookPayload( - 'droid', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'Execute', - tool_input: { command: 'pwd' } - }), - 'production' - ) - expect(nextTool?.payload.state).toBe('working') - expect(nextTool?.payload.prompt).toBe('') - expect(nextTool?.payload.toolName).toBe('Execute') - expect(nextTool?.payload.toolInput).toBe('pwd') - }) - - it('SubagentStop does not close the primary session row', () => { - const result = _internals.normalizeHookPayload( - 'droid', - buildBody({ hook_event_name: 'SubagentStop' }), - 'production' - ) - expect(result).toBeNull() - }) - - it('Stop maps to done and preserves the cached prompt', () => { - _internals.normalizeHookPayload( - 'droid', - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'write tests' }), - 'production' - ) - const stop = _internals.normalizeHookPayload( - 'droid', - buildBody({ hook_event_name: 'Stop' }), - 'production' - ) - expect(stop?.payload.state).toBe('done') - expect(stop?.payload.prompt).toBe('write tests') - }) -}) - -describe('Pi hook normalization', () => { - it('before_agent_start maps to working and captures the prompt', () => { - const result = _internals.normalizeHookPayload( - 'pi', - buildBody({ hook_event_name: 'before_agent_start', prompt: 'rename this fn' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.agentType).toBe('pi') - expect(result?.payload.prompt).toBe('rename this fn') - }) - - it('OMP uses Pi-compatible events but keeps OMP agent attribution', () => { - const started = _internals.normalizeHookPayload( - 'omp', - buildBody({ hook_event_name: 'before_agent_start', prompt: 'status for omp' }), - 'production' - ) - expect(started?.payload).toMatchObject({ - state: 'working', - prompt: 'status for omp', - agentType: 'omp' - }) - - const done = _internals.normalizeHookPayload( - 'omp', - buildBody({ hook_event_name: 'agent_end' }), - 'production' - ) - expect(done?.payload).toMatchObject({ - state: 'done', - prompt: 'status for omp', - agentType: 'omp' - }) - }) - - it('agent_start without a prompt keeps the cached prompt from the current turn', () => { - _internals.normalizeHookPayload( - 'pi', - buildBody({ hook_event_name: 'before_agent_start', prompt: 'first prompt' }), - 'production' - ) - const result = _internals.normalizeHookPayload( - 'pi', - buildBody({ hook_event_name: 'agent_start' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.prompt).toBe('first prompt') - }) - - it('before_agent_start clears the previous turn’s tool cache', () => { - _internals.normalizeHookPayload( - 'pi', - buildBody({ - hook_event_name: 'tool_call', - tool_name: 'bash', - tool_input: { command: 'ls' } - }), - 'production' - ) - const result = _internals.normalizeHookPayload( - 'pi', - buildBody({ hook_event_name: 'before_agent_start', prompt: 'next' }), - 'production' - ) - expect(result?.payload.toolName).toBeUndefined() - expect(result?.payload.toolInput).toBeUndefined() - }) - - it('tool_call surfaces tool_name + tool_input preview', () => { - const result = _internals.normalizeHookPayload( - 'pi', - buildBody({ - hook_event_name: 'tool_call', - tool_name: 'bash', - tool_input: { command: 'pnpm test' } - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('bash') - expect(result?.payload.toolInput).toBe('pnpm test') - }) - - it('tool_execution_start also populates the tool preview', () => { - const result = _internals.normalizeHookPayload( - 'pi', - buildBody({ - hook_event_name: 'tool_execution_start', - tool_name: 'read', - tool_input: { path: 'src/main/index.ts' } - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('read') - expect(result?.payload.toolInput).toBe('src/main/index.ts') - }) - - it('message_end (assistant) stays in working but captures lastAssistantMessage', () => { - const result = _internals.normalizeHookPayload( - 'pi', - buildBody({ - hook_event_name: 'message_end', - role: 'assistant', - text: 'Done — I refactored the helper.' - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.lastAssistantMessage).toBe('Done — I refactored the helper.') - }) - - it('message_end (user) is ignored', () => { - const result = _internals.normalizeHookPayload( - 'pi', - buildBody({ hook_event_name: 'message_end', role: 'user', text: 'hi' }), - 'production' - ) - // Why: pi captures the user prompt via before_agent_start, so a user-role message_end must not flip lastAssistantMessage. - expect(result?.payload.lastAssistantMessage).toBeUndefined() - }) - - it('agent_end maps to done', () => { - const result = _internals.normalizeHookPayload( - 'pi', - buildBody({ hook_event_name: 'agent_end' }), - 'production' - ) - expect(result?.payload.state).toBe('done') - expect(result?.payload.agentType).toBe('pi') - }) - - it('session_shutdown leaves a running Pi status intact', () => { - const result = _internals.normalizeHookPayload( - 'pi', - buildBody({ hook_event_name: 'session_shutdown' }), - 'production' - ) - // Why: Pi emits shutdown on reload/replace while the PTY stays alive; only agent_end proves turn completion. - expect(result).toBeNull() - }) - - it('done preserves the cached lastAssistantMessage from a prior message_end', () => { - _internals.normalizeHookPayload( - 'pi', - buildBody({ - hook_event_name: 'message_end', - role: 'assistant', - text: 'final reply' - }), - 'production' - ) - const result = _internals.normalizeHookPayload( - 'pi', - buildBody({ hook_event_name: 'agent_end' }), - 'production' - ) - expect(result?.payload.lastAssistantMessage).toBe('final reply') - }) - - it('unknown event names are dropped', () => { - const result = _internals.normalizeHookPayload( - 'pi', - buildBody({ hook_event_name: 'never_heard_of_it' }), - 'production' - ) - expect(result).toBeNull() - }) -}) - -describe('Copilot hook normalization', () => { - it('UserPromptSubmit maps to working and captures the prompt', () => { - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'add a migration' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.agentType).toBe('copilot') - expect(result?.payload.prompt).toBe('add a migration') - }) - - it('accepts camelCase Copilot event names from older hook configs', () => { - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ hook_event_name: 'userPromptSubmitted', prompt: 'camel event' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.prompt).toBe('camel event') - }) - - it('infers Copilot user prompt payloads that omit hook_event_name', () => { - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ prompt: 'raw prompt payload' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.prompt).toBe('raw prompt payload') - }) - - it('captures initialPrompt from Copilot sessionStart payloads', () => { - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ initialPrompt: 'first prompt' }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.prompt).toBe('first prompt') - }) - - it('PreToolUse stays working and surfaces tool context', () => { - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ - hook_event_name: 'PreToolUse', - toolName: 'bash', - toolInput: { command: 'pnpm test' } - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('bash') - expect(result?.payload.toolInput).toBe('pnpm test') - }) - - it('PostToolUseFailure surfaces the error and clears stale tool fields', () => { - _internals.normalizeHookPayload( - 'copilot', - buildBody({ - hook_event_name: 'PreToolUse', - toolName: 'bash', - toolInput: { command: 'pnpm test' } - }), - 'production' - ) - const failed = _internals.normalizeHookPayload( - 'copilot', - buildBody({ - hook_event_name: 'PostToolUseFailure', - toolName: 'bash', - toolInput: { command: 'pnpm test' }, - error_message: 'command not found' - }), - 'production' - ) - // Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error. - expect(failed?.payload).toMatchObject({ - state: 'working', - lastAssistantMessage: 'command not found' - }) - expect(failed?.payload.toolName).toBeUndefined() - expect(failed?.payload.toolInput).toBeUndefined() - }) - - it('PreToolUse ask_user maps to blocked and surfaces the question', () => { - _internals.normalizeHookPayload( - 'copilot', - buildBody({ prompt: 'ask me a question' }), - 'production' - ) - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ - toolCalls: [ - { - name: 'ask_user', - args: JSON.stringify({ question: 'Which deployment target should I use?' }) - } - ] - }), - 'production' - ) - expect(result?.payload.state).toBe('blocked') - expect(result?.payload.prompt).toBe('ask me a question') - expect(result?.payload.toolName).toBe('ask_user') - expect(result?.payload.toolInput).toBe('Which deployment target should I use?') - expect(result?.payload.lastAssistantMessage).toBe('Which deployment target should I use?') - }) - - it('PermissionRequest stays working and preserves tool context', () => { - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ - hook_event_name: 'PermissionRequest', - tool_name: 'bash', - tool_input: { command: 'rm -rf /tmp/orca-test' } - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('bash') - expect(result?.payload.toolInput).toBe('rm -rf /tmp/orca-test') - }) - - it('surfaces lowercase Copilot file tool input previews', () => { - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ - hook_event_name: 'PreToolUse', - tool_name: 'edit', - tool_input: { path: '/repo/src/app.ts' } - }), - 'production' - ) - expect(result?.payload.toolName).toBe('edit') - expect(result?.payload.toolInput).toBe('/repo/src/app.ts') - }) - - it('Notification(permission_prompt) maps to blocked and surfaces message text', () => { - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ - hook_event_name: 'Notification', - notification_type: 'permission_prompt', - title: 'Approval needed', - message: 'Allow Bash to run?' - }), - 'production' - ) - expect(result?.payload.state).toBe('blocked') - expect(result?.payload.lastAssistantMessage).toBe('Allow Bash to run?') - }) - - it('Notification(elicitation_dialog) preserves the cached prompt', () => { - _internals.normalizeHookPayload( - 'copilot', - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'deploy the app' }), - 'production' - ) - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ - hook_event_name: 'Notification', - notification_type: 'elicitation_dialog', - message: 'Which environment?' - }), - 'production' - ) - expect(result?.payload.state).toBe('blocked') - expect(result?.payload.prompt).toBe('deploy the app') - expect(result?.payload.lastAssistantMessage).toBe('Which environment?') - expect(result?.hasExplicitPrompt).toBe(false) - }) - - it('Notification(elicitation_dialog) accepts camelCase type and surfaces the question', () => { - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ - hook_event_name: 'Notification', - notificationType: 'elicitation_dialog', - message: 'Which deployment target should I use?' - }), - 'production' - ) - expect(result?.payload.state).toBe('blocked') - expect(result?.payload.lastAssistantMessage).toBe('Which deployment target should I use?') - }) - - it('later progress clears a prior blocked state for the same pane', () => { - _internals.normalizeHookPayload( - 'copilot', - buildBody({ - hook_event_name: 'PermissionRequest', - tool_name: 'bash', - tool_input: { command: 'pnpm build' } - }), - 'production' - ) - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ - hook_event_name: 'PostToolUse', - tool_name: 'bash', - tool_input: { command: 'pnpm build' }, - tool_result: { text_result_for_llm: 'build passed' } - }), - 'production' - ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.lastAssistantMessage).toBe('build passed') - }) - - it('Stop reads the final assistant message from Copilot transcript events', () => { - const tmpDir = mkdtempSync(join(tmpdir(), 'orca-copilot-transcript-')) - const transcriptPath = join(tmpDir, 'events.jsonl') - try { - const lines = [ - { - type: 'assistant.message', - data: { - content: '', - toolRequests: [{ name: 'bash', arguments: { command: 'pnpm test' } }] - } - }, - { - type: 'assistant.message', - data: { content: 'Done - tests pass now.', toolRequests: [] } - } - ] - writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`) - - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), - 'production' - ) - - expect(result?.payload.state).toBe('done') - expect(result?.payload.lastAssistantMessage).toBe('Done - tests pass now.') - } finally { - rmSync(tmpDir, { recursive: true, force: true }) - } - }) - - it('unknown event name returns null', () => { - const result = _internals.normalizeHookPayload( - 'copilot', - buildBody({ hook_event_name: 'somethingElse' }), - 'production' - ) - expect(result).toBeNull() - }) - - it('accepts authenticated HTTP posts on /hook/copilot', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const listener = vi.fn() - server.setListener(listener) - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ hook_event_name: 'Notification', notificationType: 'permission_prompt' }) - ) - }) - - expect(response.status).toBe(204) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - payload: expect.objectContaining({ state: 'blocked', agentType: 'copilot' }) - }) - ) - } finally { - server.stop() - } - }) - - it('updates Copilot Stop with final transcript text after a non-blocking retry', async () => { - const server = new AgentHookServer() - const tmpDir = mkdtempSync(join(tmpdir(), 'orca-copilot-transcript-retry-')) - const transcriptPath = join(tmpDir, 'events.jsonl') - writeFileSync(transcriptPath, '') - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const listener = vi.fn() - server.setListener(listener) - - await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ - hook_event_name: 'PostToolUse', - tool_result: { text_result_for_llm: 'stale tool output' } - }) - ) - }) - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }) - ) - }) - - expect(response.status).toBe(204) - await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody({ hook_event_name: 'SessionEnd', reason: 'complete' })) - }) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - state: 'done', - lastAssistantMessage: undefined - }) - }) - ) - - // Let the first 50ms retry miss so continuation across SessionEnd is proven. - await new Promise((resolve) => setTimeout(resolve, 70)) - writeFileSync( - transcriptPath, - `${JSON.stringify({ - type: 'assistant.message', - data: { content: 'Done after transcript flush.' } - })}\n` - ) - await new Promise((resolve) => setTimeout(resolve, 120)) - - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - state: 'done', - lastAssistantMessage: 'Done after transcript flush.' - }) - }) - ) - } finally { - server.stop() - rmSync(tmpDir, { recursive: true, force: true }) - } - }) - - it('updates Grok Stop with final chat-history text after a non-blocking retry', async () => { - const server = new AgentHookServer() - const tmpDir = mkdtempSync(join(tmpdir(), 'orca-grok-chat-history-retry-')) - const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf528' - const cwd = join(tmpDir, 'workspace') - const sessionDir = join(tmpDir, '.grok', 'sessions', encodeURIComponent(cwd), sessionId) - mkdirSync(sessionDir, { recursive: true }) - writeFileSync(join(sessionDir, 'chat_history.jsonl'), '') - vi.stubEnv('HOME', tmpDir) - vi.stubEnv('USERPROFILE', tmpDir) - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const listener = vi.fn() - server.setListener(listener) - - await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/grok`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody({ hookEventName: 'user_prompt_submit', prompt: 'hihi' })) - }) - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/grok`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(buildBody({ hookEventName: 'Stop', sessionId, cwd })) - }) - - expect(response.status).toBe(204) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - state: 'done', - lastAssistantMessage: undefined - }) - }) - ) - - writeFileSync( - join(sessionDir, 'chat_history.jsonl'), - `${JSON.stringify({ type: 'assistant', content: 'Hi! How can I help you today?' })}\n` - ) - await new Promise((resolve) => setTimeout(resolve, 120)) - - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - state: 'done', - lastAssistantMessage: 'Hi! How can I help you today?' - }) - }) - ) - } finally { - server.stop() - vi.unstubAllEnvs() - rmSync(tmpDir, { recursive: true, force: true }) - } - }) -}) - -describe('Endpoint file lifecycle', () => { - let userDataPath: string - - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-endpoint-')) - }) - - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('writes the endpoint file with the expected shell-sourceable shape', async () => { - const server = new AgentHookServer() - await server.start({ env: 'development', userDataPath }) - try { - const filePath = server.endpointFilePath - expect(filePath).toBeTruthy() - expect(existsSync(filePath!)).toBe(true) - const contents = readFileSync(filePath!, 'utf8') - const expectedPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT - const expectedToken = server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN - const prefix = process.platform === 'win32' ? 'set ' : '' - expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_PORT=${expectedPort}`) - expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TOKEN=${expectedToken}`) - expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_ENV=development`) - expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_VERSION=1`) - } finally { - server.stop() - } - }) - - it('writes the endpoint file with owner-only permissions on POSIX', async () => { - if (process.platform === 'win32') { - return - } - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const filePath = server.endpointFilePath! - // Why: mask to the rwx octet so we assert only the file's mode:0o600, not umask-leaked bits on the parent dir. - const mode = statSync(filePath).mode & 0o777 - expect(mode).toBe(0o600) - } finally { - server.stop() - } - }) - - it('rewrites the endpoint file with a new port after restart on the same path', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - const firstPath = server.endpointFilePath - const firstToken = server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN - server.stop() - - await server.start({ env: 'production', userDataPath }) - try { - const secondPath = server.endpointFilePath - const secondPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT - const secondToken = server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN - // Path is stable (so PTYs stamped before restart can still find the file) - expect(secondPath).toBe(firstPath) - // Contents refresh with a new token so stale-env survivors reach the live server. - expect(secondToken).toBeTruthy() - expect(secondToken).not.toBe(firstToken) - const contents = readFileSync(secondPath!, 'utf8') - // Why: assert on token (randomUUID, can't collide), not port — listen(0) may legitimately reuse the ephemeral port and flake a port check. - expect(contents).toContain(`ORCA_AGENT_HOOK_PORT=${secondPort}`) - expect(contents).toContain(`ORCA_AGENT_HOOK_TOKEN=${secondToken}`) - expect(contents).not.toContain(`ORCA_AGENT_HOOK_TOKEN=${firstToken}`) - } finally { - server.stop() - } - }) - - it('leaves the endpoint file in place on stop()', async () => { - // Why: stop() leaves the file (stale = fail-open); unlinking would race a concurrent Orca instance rewriting it between token-check and unlink (TOCTOU). - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - const filePath = server.endpointFilePath! - expect(existsSync(filePath)).toBe(true) - server.stop() - expect(existsSync(filePath)).toBe(true) - }) - - it('buildPtyEnv includes ORCA_AGENT_HOOK_ENDPOINT when the server is running', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const env = server.buildPtyEnv() - expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBe(server.endpointFilePath) - } finally { - server.stop() - } - }) - - it('buildPtyEnv includes namespaced ORCA_AGENT_HOOK_ENDPOINT for development servers', async () => { - const server = new AgentHookServer() - await server.start({ - env: 'development', - userDataPath, - endpointNamespace: 'com.stablyai.orca.dev.test123' - }) - try { - const env = server.buildPtyEnv() - expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBe(server.endpointFilePath) - expect(env.ORCA_AGENT_HOOK_ENDPOINT).toContain('com.stablyai.orca.dev.test123') - expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy() - expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() - } finally { - server.stop() - } - }) - - it('keeps endpoint files separate for parallel dev namespaces', async () => { - const firstServer = new AgentHookServer() - const secondServer = new AgentHookServer() - await firstServer.start({ env: 'development', userDataPath, endpointNamespace: 'dev-a' }) - await secondServer.start({ env: 'development', userDataPath, endpointNamespace: 'dev-b' }) - try { - expect(firstServer.endpointFilePath).not.toBe(secondServer.endpointFilePath) - expect(firstServer.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBe(firstServer.endpointFilePath) - expect(secondServer.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBe( - secondServer.endpointFilePath - ) - expect(existsSync(firstServer.endpointFilePath!)).toBe(true) - expect(existsSync(secondServer.endpointFilePath!)).toBe(true) - } finally { - firstServer.stop() - secondServer.stop() - } - }) - - it('buildPtyEnv omits ORCA_AGENT_HOOK_ENDPOINT when no userDataPath was provided', async () => { - // Why: the endpoint file is opt-in via userDataPath; without it, hooks fall back to v1 behavior (no ENDPOINT key). - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() - expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy() - expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() - } finally { - server.stop() - } - }) - - it('buildPtyEnv returns empty when the server is not running', () => { - const server = new AgentHookServer() - expect(server.buildPtyEnv()).toEqual({}) - }) - - it('sweeps stale .endpoint-*.tmp orphans older than 5 minutes on start', async () => { - // Why: a crash between tmp-write and rename orphans a tmp; sweep must drop stale ones (>5min) but spare a concurrent writer's fresh in-flight tmp. - const dir = join(userDataPath, 'agent-hooks') - mkdirSync(dir, { recursive: true }) - const staleTmp = join(dir, '.endpoint-999-stale.tmp') - const freshTmp = join(dir, '.endpoint-999-fresh.tmp') - writeFileSync(staleTmp, 'stale') - writeFileSync(freshTmp, 'fresh') - const sixMinAgo = (Date.now() - 6 * 60 * 1000) / 1000 - utimesSync(staleTmp, sixMinAgo, sixMinAgo) - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - expect(existsSync(staleTmp)).toBe(false) - expect(existsSync(freshTmp)).toBe(true) - } finally { - server.stop() - } - }) - - it('refuses to write the endpoint file when a value contains shell metacharacters', async () => { - // Why: written values are sourced as shell, so isShellSafeEndpointValue must reject metacharacters to prevent command injection. - const server = new AgentHookServer() - await server.start({ env: 'bad;value', userDataPath }) - try { - expect(existsSync(server.endpointFilePath!)).toBe(false) - expect(server.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() - // PORT/TOKEN still flow via PTY env — fail-open to v1 behavior. - expect(server.buildPtyEnv().ORCA_AGENT_HOOK_PORT).toBeTruthy() - expect(server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN).toBeTruthy() - } finally { - server.stop() - } - }) - - it('ingestRemote stamps connectionId and feeds the listener bypassing HTTP', () => { - const server = new AgentHookServer() - const events: { paneKey: string; connectionId: string | null; payload: unknown }[] = [] - server.setListener((evt) => { - events.push({ - paneKey: evt.paneKey, - connectionId: evt.connectionId, - payload: evt.payload - }) - }) - try { - const remotePane = makePaneKey('tab-3', LEAF_3) - server.ingestRemote( - { - paneKey: remotePane, - tabId: 'tab-3', - worktreeId: 'wt-3', - payload: { - state: 'working', - prompt: 'remote prompt', - agentType: 'claude' - } - }, - 'conn-42' - ) - expect(events).toHaveLength(1) - expect(events[0].paneKey).toBe(remotePane) - expect(events[0].connectionId).toBe('conn-42') - expect(events[0].payload).toMatchObject({ - state: 'working', - prompt: 'remote prompt', - agentType: 'claude' - }) - } finally { - server.setListener(null) - } - }) - - it('ingestRemote ignores malformed envelopes (fail-open)', () => { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - try { - // Missing paneKey - server.ingestRemote({ paneKey: '', payload: { state: 'working' } } as never, 'conn-x') - // Missing payload state - server.ingestRemote({ paneKey: 'tab-1:0', payload: { foo: 'bar' } }, 'conn-x') - // Invalid payload state - server.ingestRemote({ paneKey: 'tab-1:0', payload: { state: 'nonsense' } }, 'conn-x') - // Empty connection id - server.ingestRemote({ paneKey: 'tab-1:0', payload: { state: 'working' } }, ' ') - // Wrong types - server.ingestRemote( - { paneKey: 'tab-1:0', payload: 'not-an-object' as unknown } as never, - 'conn-x' - ) - expect(listener).not.toHaveBeenCalled() - } finally { - server.setListener(null) - } - }) - - it('endpoint file contents are re-parseable by /bin/sh', async () => { - if (process.platform === 'win32') { - return - } - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const filePath = server.endpointFilePath! - const expectedPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT - // Why: source the file exactly as the managed hook script does, catching drift from the KEY=VALUE shape before users do. - const out = execFileSync('/bin/sh', ['-c', `. "${filePath}" && echo "$ORCA_AGENT_HOOK_PORT"`]) - .toString() - .trim() - expect(out).toBe(expectedPort) - } finally { - server.stop() - } - }) -}) - -describe('Last-status persistence', () => { - let userDataPath: string - - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-laststatus-')) - }) - - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - function lastStatusPath(): string { - return join(userDataPath, 'agent-hooks', 'last-status.json') - } - - // Why: use a recent-but-not-now timestamp so fixtures survive hydrate's 7d drop (HYDRATE_MAX_AGE_MS) without racing the wall clock. - function recentTs(offsetMs = 0): number { - return Date.now() - 60 * 60 * 1000 + offsetMs - } - - async function postHookEvent( - server: AgentHookServer, - body: Body, - path: string = '/hook/claude' - ): Promise { - const env = server.buildPtyEnv() - return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}${path}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify(body) - }) - } - - it('writes last-status.json after a hook event', async () => { - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - await postHookEvent( - server, - buildBody( - { hook_event_name: 'UserPromptSubmit', prompt: 'persist me' }, - { launchToken: 'launch-bearer-must-not-persist' } - ) - ) - await postHookEvent( - server, - buildBody( - { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }, - { launchToken: 'launch-bearer-must-not-persist' } - ) - ) - // Synchronous flush via stop() captures the trailing-debounced write. - server.flushStatusPersistSync() - expect(existsSync(lastStatusPath())).toBe(true) - const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) - expect(file.version).toBe(2) - expect(file.entries[PANE]).toMatchObject({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - receivedAt: expect.any(Number), - stateStartedAt: expect.any(Number), - payload: expect.objectContaining({ - state: 'working', - workingMode: 'monitoring', - prompt: 'persist me' - }) - }) - expect(file.entries[PANE].launchToken).toBeUndefined() - expect(file.entries[PANE].launchTokenHash).toBe( - createHash('sha256').update('launch-bearer-must-not-persist').digest('hex') - ) - expect(file.entries[PANE].claudeRunningNonAgentTask).toBeUndefined() - expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('claudeRunningNonAgentTask') - expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('launch-bearer-must-not-persist') - } finally { - server.stop() - } - }) - - it('scrubs a legacy persisted launch bearer while retaining its authority commitment', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const receivedAt = recentTs() - const launchToken = 'legacy-launch-bearer' - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - [PANE]: { - paneKey: PANE, - launchToken, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - receivedAt, - stateStartedAt: receivedAt, - payload: { - state: 'working', - prompt: 'legacy worker', - agentType: 'codex' - } - } - } - }), - 'utf8' - ) - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const launchTokenHash = createHash('sha256').update(launchToken).digest('hex') - expect(server.getHydratedAuthorityCommitments()).toEqual([ - expect.objectContaining({ paneKey: PANE, launchTokenHash }) - ]) - expect(server.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined() - - const persisted = readFileSync(lastStatusPath(), 'utf8') - expect(persisted).not.toContain(launchToken) - expect(JSON.parse(persisted).entries[PANE]).toMatchObject({ launchTokenHash }) - } finally { - server.stop() - } - - const restartedServer = new AgentHookServer() - await restartedServer.start({ env: 'production', userDataPath }) - try { - expect(restartedServer.getHydratedAuthorityCommitments()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - launchTokenHash: createHash('sha256').update(launchToken).digest('hex') - }) - ]) - expect(restartedServer.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined() - } finally { - restartedServer.stop() - } - }) - - it('persists SSH authority through transient clears until explicit retirement', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const receivedAt = recentTs() - const launchToken = 'retained-ssh-launch-bearer' - const launchTokenHash = createHash('sha256').update(launchToken).digest('hex') - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - [PANE]: { - paneKey: PANE, - launchTokenHash, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: 'ssh-target', - receivedAt, - stateStartedAt: receivedAt, - payload: { - state: 'working', - prompt: 'retained SSH worker', - agentType: 'codex' - } - } - } - }), - 'utf8' - ) - - const first = new AgentHookServer() - await first.start({ env: 'production', userDataPath }) - first.clearStatusEntriesForConnection('ssh-target') - first.flushStatusPersistSync() - first.stop() - - const afterClear = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) - expect(afterClear.entries).toEqual({}) - expect(afterClear.authorityCommitments[PANE]).toMatchObject({ - paneKey: PANE, - launchTokenHash, - connectionId: 'ssh-target' - }) - - const restored = new AgentHookServer() - await restored.start({ env: 'production', userDataPath }) - expect( - restored.attestCompatibilityAuthority({ - paneKey: PANE, - launchTokenHash, - connectionId: 'ssh-target', - terminalProvenance: 'restored' - }) - ).toEqual({ paneKey: PANE, source: 'hydrated_commitment' }) - restored.retirePaneAuthority(PANE) - restored.flushStatusPersistSync() - restored.stop() - - const retired = new AgentHookServer() - await retired.start({ env: 'production', userDataPath }) - try { - expect( - retired.attestCompatibilityAuthority({ - paneKey: PANE, - launchTokenHash, - connectionId: 'ssh-target', - terminalProvenance: 'restored' - }) - ).toBeNull() - } finally { - retired.stop() - } - }) - - it('persists and hydrates Pi session identity without creating status telemetry', async () => { - const firstServer = new AgentHookServer() - const firstRendererListener = vi.fn() - const statusChangeListener = vi.fn() - firstServer.setListener(firstRendererListener) - firstServer.subscribeStatusChanges(statusChangeListener) - await firstServer.start({ env: 'production', userDataPath }) - try { - const response = await postHookEvent( - firstServer, - buildBody({ - hook_event_name: 'session_start', - session_id: 'pi-session-1', - session_file: '/tmp/pi-session-1.jsonl' - }), - '/hook/pi' - ) - expect(response.status).toBe(204) - expect(firstRendererListener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - providerSessionOnly: true, - providerSession: { - key: 'session_id', - id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl' - } - }) - ) - expect(statusChangeListener).toHaveBeenCalledWith([]) - expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) - - firstServer.flushStatusPersistSync() - const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) - expect(file.entries[PANE]).toMatchObject({ - providerSessionOnly: true, - providerSession: { - key: 'session_id', - id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl' - } - }) - } finally { - firstServer.stop() - } - - const hydratedServer = new AgentHookServer() - await hydratedServer.start({ env: 'production', userDataPath }) - try { - const hydratedListener = vi.fn() - hydratedServer.setListener(hydratedListener) - expect(hydratedListener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - providerSessionOnly: true, - providerSession: expect.objectContaining({ transcriptPath: '/tmp/pi-session-1.jsonl' }), - isReplay: true - }) - ) - expect(hydratedServer.getStatusChangeSnapshot()).toEqual([]) - } finally { - hydratedServer.stop() - } - }) - - it('does not write prompt interaction keys to last-status.json', async () => { - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - await postHookEvent( - server, - buildBody({ - hook_event_name: 'MessagePart', - role: 'user', - text: 'persist status only', - messageID: 'opencode-local-message-id' - }), - '/hook/opencode' - ) - server.flushStatusPersistSync() - const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) - expect(file.entries[PANE].payload.prompt).toBe('persist status only') - expect(file.entries[PANE].promptInteractionKey).toBeUndefined() - } finally { - server.stop() - } - }) - - it('hydrates last-status.json into the cache before listener registration', async () => { - // Pre-populate the file directly to simulate a prior session. - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const receivedAt = recentTs() - const stateStartedAt = recentTs(-1000) - const fileContents = { - version: 2, - entries: { - [PANE]: { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - receivedAt, - stateStartedAt, - payload: { - state: 'done', - prompt: 'survived restart', - agentType: 'claude' - } - } - } - } - writeFileSync(lastStatusPath(), JSON.stringify(fileContents), 'utf8') - - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - const listener = vi.fn() - server.setListener(listener) - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - receivedAt, - stateStartedAt, - payload: expect.objectContaining({ - state: 'done', - prompt: 'survived restart', - agentType: 'claude' - }) - }) - ) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - receivedAt, - stateStartedAt, - state: 'done', - prompt: 'survived restart', - agentType: 'claude' - }) - ]) - } finally { - server.stop() - } - }) - - it('restores hydrated nonterminal statuses as unconfirmed until a live event lands', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - firstServer.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'may finish offline', agentType: 'claude' } - }, - 'conn-1' - ) - firstServer.ingestRemote( - { - paneKey: GOOD_PANE, - tabId: 'tab-good', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'finished before restart', agentType: 'claude' } - }, - 'conn-1' - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const listener = vi.fn() - server.setListener(listener) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ paneKey: PANE, restoredUnconfirmed: true }) - ) - const byPane = new Map(server.getStatusSnapshot().map((entry) => [entry.paneKey, entry])) - expect(byPane.get(PANE)).toMatchObject({ state: 'working', restoredUnconfirmed: true }) - // Why: `done` is a terminal fact — no later transition can have been missed, so it restores confirmed. - expect(byPane.get(GOOD_PANE)?.restoredUnconfirmed).toBeUndefined() - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'confirmed live', agentType: 'codex' } - }, - 'conn-1' - ) - const confirmed = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) - expect(confirmed).toMatchObject({ - state: 'done', - prompt: 'confirmed live', - agentType: 'codex' - }) - expect(confirmed?.restoredUnconfirmed).toBeUndefined() - } finally { - server.stop() - } - }) - - it('lets an identical live OSC observation confirm a hydrated row', async () => { - const payload = { - state: 'working' as const, - prompt: 'still running', - agentType: 'claude' as const - } - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - firstServer.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - payload - }) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const restored = server.getStatusSnapshot()[0] - if (!restored) { - throw new Error('expected hydrated status') - } - expect(restored?.restoredUnconfirmed).toBe(true) - vi.spyOn(Date, 'now').mockReturnValue(restored.receivedAt - 1_000) - server.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - payload - }) - const confirmed = server.getStatusSnapshot()[0] - expect(confirmed?.restoredUnconfirmed).toBeUndefined() - expect(confirmed?.receivedAt).toBe(restored.receivedAt + 1) - } finally { - server.stop() - } - }) - - it('does not let a hydrated Claude permission suppress the first live working event', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - firstServer.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'PermissionRequest', - payload: { - state: 'waiting', - prompt: 'review command', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'dangerous command' - } - }, - 'conn-1' - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'PreToolUse', - payload: { - state: 'working', - prompt: 'review command', - agentType: 'claude', - toolName: 'Read', - toolInput: 'package.json' - } - }, - 'conn-1' - ) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - toolName: 'Read' - }) - expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() - } finally { - server.stop() - } - }) - - it('does not inherit a stale tool id from hydrated Claude progress', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - firstServer.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'PreToolUse', - toolUseId: 'tool-old', - toolAgentType: 'main', - payload: { - state: 'working', - prompt: 'run tests', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - } - }, - 'conn-1' - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'PermissionRequest', - toolAgentType: 'main', - payload: { - state: 'waiting', - prompt: 'run tests', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'PostToolUse', - toolUseId: 'tool-new', - toolAgentType: 'main', - payload: { - state: 'working', - prompt: 'run tests', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - } - }, - 'conn-1' - ) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) - } finally { - server.stop() - } - }) - - it('never persists the unconfirmed flag and re-stamps it on every hydrate', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - firstServer.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'still running', agentType: 'claude' } - }, - 'conn-1' - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const secondServer = new AgentHookServer() - await secondServer.start({ env: 'production', userDataPath }) - secondServer.flushStatusPersistSync() - secondServer.stop() - expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('restoredUnconfirmed') - - const thirdServer = new AgentHookServer() - await thirdServer.start({ env: 'production', userDataPath }) - try { - expect(thirdServer.getStatusSnapshot()).toEqual([ - expect.objectContaining({ paneKey: PANE, state: 'working', restoredUnconfirmed: true }) - ]) - } finally { - thirdServer.stop() - } - }) - - it('refuses interrupt inference on an unconfirmed hydrated row', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - firstServer.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'long task', agentType: 'codex' } - }, - 'conn-1' - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const baseline = server.getStatusSnapshot()[0] - expect(baseline).toMatchObject({ paneKey: PANE, restoredUnconfirmed: true }) - const applied = server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'long task', - baselineAgentType: 'codex', - intent: 'plain-escape' - }) - // Why: synthesizing `done` onto a never-confirmed `working` would fabricate a transition from stale disk state. - expect(applied).toBe(false) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) - expect(server.getStatusSnapshot()[0]?.interrupted).toBeUndefined() - } finally { - server.stop() - } - }) - - it('keeps SSH status ordering monotonic across hydration and clock rollback', async () => { - const now = 1_700_000_000_000 - vi.spyOn(Date, 'now').mockReturnValue(now) - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const receivedAt = now + 1_000 - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - [PANE]: { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: 'ssh-a', - receivedAt, - stateStartedAt: receivedAt, - payload: { state: 'working', prompt: 'before restart', agentType: 'claude' } - } - } - }), - 'utf8' - ) - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const clearListener = vi.fn() - server.setPaneStatusClearListener(clearListener) - server.ingestRemote( - { paneKey: PANE, payload: { state: 'working', agentType: 'codex' } }, - 'ssh-a' - ) - - expect(server.getStatusSnapshot()[0]?.receivedAt).toBe(receivedAt + 1) - server.clearStatusEntriesForConnection('ssh-a') - const clearedAt = receivedAt + 2 - expect(clearListener).toHaveBeenCalledWith({ - transient: true, - connectionId: 'ssh-a', - clearedAt - }) - server.ingestRemote( - { - paneKey: GOOD_PANE, - isReplay: true, - payload: { state: 'working', agentType: 'claude' } - }, - 'ssh-a' - ) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: GOOD_PANE, - connectionId: 'ssh-a', - receivedAt: clearedAt + 1 - }) - ]) - } finally { - server.stop() - } - }) - - it('drops persisted idle Claude child rows from hydration replay', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const receivedAt = recentTs() - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - [PANE]: { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - receivedAt, - stateStartedAt: recentTs(-1000), - payload: { - state: 'done', - prompt: 'finished orchestration', - agentType: 'claude', - subagents: [ - { id: 'aweb-research-8a76b7d7', state: 'idle', startedAt: receivedAt - 5000 }, - { id: 'apr-history-9b87c6e6', state: 'idle', startedAt: receivedAt - 4000 } - ] - } - } - } - }), - 'utf8' - ) - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const listener = vi.fn() - server.setListener(listener) - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ subagents: undefined }) - }) - ) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'done', - prompt: 'finished orchestration', - subagents: undefined - }) - ]) - // Why: migration must be one-time, else every launch re-prunes the same persisted idle rows. - const persisted = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) - expect(persisted.entries[PANE].payload.subagents).toBeUndefined() - } finally { - server.stop() - } - }) - - it('restores Codex child hierarchy and reaps unconfirmed children on the next root Stop', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const receivedAt = recentTs() - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - [PANE]: { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - receivedAt, - stateStartedAt: recentTs(-1000), - payload: { - state: 'working', - prompt: 'coordinate reviews', - agentType: 'codex', - model: 'gpt-5.4', - subagents: [ - { - id: '11111111-2222-4333-8444-555555555555', - state: 'working', - startedAt: receivedAt - 5000, - agentType: 'reviewer', - model: 'gpt-5.4-mini' - } - ] - } - } - } - }), - 'utf8' - ) - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - model: 'gpt-5.4', - subagents: [ - expect.objectContaining({ - id: '11111111-2222-4333-8444-555555555555', - model: 'gpt-5.4-mini' - }) - ] - }) - ]) - - await postHookEvent( - server, - buildBody({ - hook_event_name: 'PreToolUse', - agent_id: '11111111-2222-4333-8444-555555555555', - agent_type: 'reviewer', - model: 'gpt-5.4-mini', - tool_name: 'exec_command', - tool_input: { cmd: 'pnpm test' } - }), - '/hook/codex' - ) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - model: 'gpt-5.4', - subagents: [expect.objectContaining({ model: 'gpt-5.4-mini' })] - }) - ]) - - await postHookEvent( - server, - buildBody({ hook_event_name: 'Stop', model: 'gpt-5.4' }), - '/hook/codex' - ) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ state: 'done', model: 'gpt-5.4', subagents: undefined }) - ]) - } finally { - server.stop() - } - }) - - it('treats a corrupt file as empty hydration without throwing', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - writeFileSync(lastStatusPath(), 'not-json{{', 'utf8') - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - const listener = vi.fn() - server.setListener(listener) - expect(listener).not.toHaveBeenCalled() - expect(warnSpy).toHaveBeenCalled() - } finally { - server.stop() - warnSpy.mockRestore() - } - }) - - it('drops hydrated metadata-only entries without a resumable Pi session', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const receivedAt = recentTs() - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - [PANE]: { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - receivedAt, - stateStartedAt: receivedAt, - providerSessionOnly: true, - providerSession: { key: 'session_id', id: 'pi-session-without-file' }, - payload: { state: 'done', prompt: '', agentType: 'pi' } - } - } - }), - 'utf8' - ) - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - expect(server.getStatusSnapshot()).toEqual([]) - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('last-status hydrate dropped 1 entries') - ) - } finally { - server.stop() - warnSpy.mockRestore() - } - }) - - it('rejects a stale version mismatch on hydrate', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 1, - entries: { - [PANE]: { - paneKey: PANE, - receivedAt: 1_700_000_000_000, - stateStartedAt: 1_699_999_999_000, - payload: { state: 'done', prompt: 'old version', agentType: 'claude' } - } - } - }), - 'utf8' - ) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - const listener = vi.fn() - server.setListener(listener) - expect(listener).not.toHaveBeenCalled() - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('version mismatch')) - } finally { - server.stop() - warnSpy.mockRestore() - } - }) - - it('drops entries with malformed paneKeys but keeps valid ones', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - // Missing colon — drop. - 'no-colon': { - paneKey: 'no-colon', - receivedAt: 1_700_000_000_000, - stateStartedAt: 1_699_999_999_000, - payload: { state: 'done', prompt: 'bad', agentType: 'claude' } - }, - // Embedded paneKey mismatch — drop. - [PANE]: { - paneKey: makePaneKey('tab-x', LEAF_2), - receivedAt: 1_700_000_000_000, - stateStartedAt: 1_699_999_999_000, - payload: { state: 'done', prompt: 'mismatch', agentType: 'claude' } - }, - // Valid. - [GOOD_PANE]: { - paneKey: GOOD_PANE, - tabId: 'tab-good', - receivedAt: recentTs(), - stateStartedAt: recentTs(-1000), - payload: { state: 'done', prompt: 'survived', agentType: 'claude' } - } - } - }), - 'utf8' - ) - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - const listener = vi.fn() - server.setListener(listener) - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: GOOD_PANE, - payload: expect.objectContaining({ prompt: 'survived' }) - }) - ) - } finally { - server.stop() - } - }) - - it('drops hydrate entries older than the TTL cutoff', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const eightDaysAgoMs = Date.now() - 8 * 24 * 60 * 60 * 1000 - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - // Stale — should be dropped. - [OLD_PANE]: { - paneKey: OLD_PANE, - tabId: 'tab-old', - receivedAt: eightDaysAgoMs, - stateStartedAt: eightDaysAgoMs - 1000, - payload: { state: 'done', prompt: 'old', agentType: 'claude' } - }, - // Recent — should survive. - [FRESH_PANE]: { - paneKey: FRESH_PANE, - tabId: 'tab-fresh', - receivedAt: recentTs(), - stateStartedAt: recentTs(-1000), - payload: { state: 'done', prompt: 'fresh', agentType: 'claude' } - } - } - }), - 'utf8' - ) - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - const snapshot = server.getStatusSnapshot() - expect(snapshot.map((e) => e.paneKey)).toEqual([FRESH_PANE]) - } finally { - server.stop() - } - }) - - it('hydrates registered legacy numeric pane keys as stable pane status entries', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - 'tab-1:0': { - paneKey: 'tab-1:0', - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - receivedAt: recentTs(), - stateStartedAt: recentTs(-1000), - payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' } - } - } - }), - 'utf8' - ) - const server = new AgentHookServer() - server.registerPaneKeyAlias('tab-1:0', PANE) - await server.start({ - env: 'production', - userDataPath - }) - try { - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - state: 'working', - prompt: 'legacy cached', - agentType: 'claude' - }) - ]) - } finally { - server.stop() - } - }) - - it('clears hydrated stable statuses when their persisted legacy alias PTY is cleared', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - 'tab-1:0': { - paneKey: 'tab-1:0', - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - receivedAt: recentTs(), - stateStartedAt: recentTs(-1000), - payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' } - } - } - }), - 'utf8' - ) - const server = new AgentHookServer() - const statusListener = vi.fn() - server.registerPaneKeyAlias('tab-1:0', PANE, 'pty-1') - server.subscribeStatusChanges(statusListener) - await server.start({ - env: 'production', - userDataPath - }) - try { - expect(server.getStatusSnapshot()).toHaveLength(1) - - server.clearPaneKeyAliasesForPty('pty-1') - - expect(server.getStatusSnapshot()).toEqual([]) - expect(statusListener).toHaveBeenCalledWith([]) - } finally { - server.stop() - } - }) - - it('does not clear a stable status when alias cleanup no longer owns that pane', () => { - const server = new AgentHookServer() - server.registerPaneKeyAlias('tab-1:0', PANE, 'old-pty') - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', agentType: 'claude' } - }, - 'conn-1' - ) - - server.clearPaneKeyAliasesForPty('old-pty', { shouldClearStablePaneKey: () => false }) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - agentType: 'claude' - }) - ]) - }) - - it('drops a hydrate entry whose tabId disagrees with the paneKey prefix', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - [TAB_A_PANE]: { - paneKey: TAB_A_PANE, - // Why: paneKey says tab-A but entry claims tab-B; sanitizer must drop the inconsistent row, not hydrate it. - tabId: 'tab-B', - receivedAt: recentTs(), - stateStartedAt: recentTs(-1000), - payload: { state: 'done', prompt: 'mismatch', agentType: 'claude' } - } - } - }), - 'utf8' - ) - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - expect(server.getStatusSnapshot()).toEqual([]) - } finally { - server.stop() - } - }) - - it('clearPaneState evicts the entry from the on-disk file', async () => { - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - await postHookEvent( - server, - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'about to drop' }) - ) - server.flushStatusPersistSync() - let parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) - expect(parsed.entries[PANE]).toBeTruthy() - - server.clearPaneState(PANE) - server.flushStatusPersistSync() - parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) - expect(parsed.entries[PANE]).toBeUndefined() - } finally { - server.stop() - } - }) - - it('skips a write when the serialized contents are byte-identical to the previous write', async () => { - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - await postHookEvent( - server, - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'first' }) - ) - server.flushStatusPersistSync() - const firstMtime = statSync(lastStatusPath()).mtimeMs - - // Why: clearPaneState on a paneKey not in the cache must not trigger a redundant write (clear bails when nothing was evicted). - server.clearPaneState(makePaneKey('non-existent', LEAF_5)) - server.flushStatusPersistSync() - // Assert no rewrite happened: mtime unchanged after a forced sync flush. - const secondMtime = statSync(lastStatusPath()).mtimeMs - expect(secondMtime).toBe(firstMtime) - } finally { - server.stop() - } - }) - - it('stop() flushes pending debounced writes synchronously', async () => { - const server = new AgentHookServer() - await server.start({ - env: 'production', - userDataPath - }) - try { - await postHookEvent( - server, - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'flush me' }) - ) - // Note: do NOT call flushStatusPersistSync explicitly — let stop() do it. - } finally { - server.stop() - } - // Why: stop() must synchronously drain the pending trailing-debounced timer even though we never explicitly flushed. - expect(existsSync(lastStatusPath())).toBe(true) - const parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) - expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me') - }) -}) - -describe('AgentHookServer ingestRemote', () => { - it('caches and replays Pi session identity without exposing a turn-status change', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const rendererListener = vi.fn() - const statusChangeListener = vi.fn() - server.setListener(rendererListener) - server.subscribeStatusChanges(statusChangeListener) - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - providerSession: { - key: 'session_id', - id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl' - }, - providerSessionOnly: true, - payload: { state: 'done', prompt: '', agentType: 'pi' } - }, - 'conn-1' - ) - - expect(rendererListener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - connectionId: 'conn-1', - providerSessionOnly: true, - providerSession: { - key: 'session_id', - id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl' - } - }) - ) - expect(statusChangeListener).toHaveBeenCalledWith([]) - expect(server.getStatusChangeSnapshot()).toEqual([]) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - providerSessionOnly: true, - providerSession: expect.objectContaining({ transcriptPath: '/tmp/pi-session-1.jsonl' }) - }) - ]) - expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) - - const replayListener = vi.fn() - server.setListener(replayListener) - expect(replayListener).toHaveBeenCalledWith( - expect.objectContaining({ paneKey: PANE, providerSessionOnly: true, isReplay: true }) - ) - } finally { - vi.useRealTimers() - } - }) - - it('fans a Pi session-only status out to plugins, not just the renderer', () => { - const server = new AgentHookServer() - const rendererListener = vi.fn() - const pluginListener = vi.fn() - server.setListener(rendererListener) - server.subscribeEnrichedStatus(pluginListener) - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - providerSession: { - key: 'session_id', - id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl' - }, - providerSessionOnly: true, - payload: { state: 'done', prompt: '', agentType: 'pi' } - }, - 'conn-1' - ) - - // The session-only path returns early, so it must not skip the plugin tap. - expect(rendererListener).toHaveBeenCalledWith( - expect.objectContaining({ paneKey: PANE, providerSessionOnly: true }) - ) - expect(pluginListener).toHaveBeenCalledWith( - expect.objectContaining({ paneKey: PANE, providerSessionOnly: true }) - ) - }) - - it('rejects invalid remote metadata-only session envelopes', () => { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - providerSessionOnly: true, - payload: { state: 'done', prompt: '', agentType: 'pi' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - providerSessionOnly: true, - providerSession: { key: 'session_id', id: 'pi-session-1' }, - payload: { state: 'done', prompt: '', agentType: 'pi' } - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - providerSessionOnly: true, - providerSession: { - key: 'session_id', - id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl' - }, - payload: { state: 'done', prompt: '', agentType: 'claude' } - }, - 'conn-1' - ) - - expect(listener).not.toHaveBeenCalled() - expect(server.getStatusSnapshot()).toEqual([]) - }) - - it('stamps connectionId and forwards a valid relay envelope to the listener', () => { - const server = new AgentHookServer() - const payload = parseAgentStatusPayload( - JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) - ) - if (!payload) { - throw new Error('parseAgentStatusPayload returned null for a known-good fixture') - } - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1') - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: 'conn-1', - receivedAt: expect.any(Number), - stateStartedAt: expect.any(Number), - payload - }) - ) - }) - - it('preserves active pane identity when a nested remote hook reports another agent', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' } - }, - 'conn-1' - ) - - vi.setSystemTime(1_100) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'nested claude', - agentType: 'claude', - toolName: 'Read', - toolInput: '00-review-context.md' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - prompt: 'nested claude', - agentType: 'codex', - toolName: 'Read', - toolInput: '00-review-context.md', - receivedAt: 1_100 - }) - ]) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - prompt: 'nested claude', - agentType: 'codex' - }) - }) - ) - expect(trackMock).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - - it('ignores nested remote done while the parent pane agent is still active', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' } - }, - 'conn-1' - ) - - vi.setSystemTime(1_100) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { - state: 'done', - prompt: 'nested claude', - agentType: 'claude', - toolName: 'Read', - toolInput: '00-review-context.md', - lastAssistantMessage: 'child finished' - } - }, - 'conn-1' - ) - - const snapshot = server.getStatusSnapshot() - expect(snapshot).toHaveLength(1) - expect(snapshot[0]).toMatchObject({ - paneKey: PANE, - state: 'working', - prompt: 'parent codex', - agentType: 'codex', - receivedAt: 1_000, - stateStartedAt: 1_000 - }) - expect(snapshot[0].toolName).toBeUndefined() - expect(snapshot[0].toolInput).toBeUndefined() - expect(snapshot[0].lastAssistantMessage).toBeUndefined() - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - state: 'working', - prompt: 'parent codex', - agentType: 'codex' - }) - }) - ) - expect(trackMock).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - - it('allows remote pane identity to change after the prior turn is done', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'parent codex', agentType: 'codex' } - }, - 'conn-1' - ) - - vi.setSystemTime(1_100) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'real claude turn', agentType: 'claude' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'real claude turn', - agentType: 'claude', - receivedAt: 1_100 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('allows stale active remote pane identity to change', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'old codex turn', agentType: 'codex' } - }, - 'conn-1' - ) - - vi.setSystemTime(1_000 + AGENT_STATUS_STALE_AFTER_MS + 1) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'new claude turn', agentType: 'claude' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - state: 'working', - prompt: 'new claude turn', - agentType: 'claude', - receivedAt: 1_000 + AGENT_STATUS_STALE_AFTER_MS + 1 - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('lets remote Claude permission clear when matching approved tool execution starts', () => { - const server = new AgentHookServer() - const waiting = parseAgentStatusPayload( - JSON.stringify({ - state: 'waiting', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - }) - ) - const working = parseAgentStatusPayload( - JSON.stringify({ - state: 'working', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - }) - ) - if (!waiting || !working) { - throw new Error('parseAgentStatusPayload returned null for a known-good fixture') - } - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'PermissionRequest', - toolAgentId: 'agent-subagent-a', - toolAgentType: 'Review', - payload: waiting - }, - 'conn-1' - ) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'PreToolUse', - toolUseId: 'toolu-approved-remote', - toolAgentId: 'agent-subagent-a', - toolAgentType: 'Review', - payload: working - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - connectionId: 'conn-1', - state: 'working', - agentType: 'claude', - toolName: 'Bash', - toolInput: 'pnpm test' - }) - ]) - }) - - it('drops envelopes whose payload state is not in AGENT_STATUS_STATES', () => { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - // Why: bypass parseAgentStatusPayload with an invalid payload — ingestRemote is the trust boundary under test, not the parser. - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'nonsense', prompt: '', agentType: 'claude' } - }, - 'conn-1' - ) - expect(listener).not.toHaveBeenCalled() - }) - - it('drops envelopes whose paneKey exceeds MAX_PANE_KEY_LEN', () => { - const server = new AgentHookServer() - const payload = parseAgentStatusPayload( - JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) - ) - if (!payload) { - throw new Error('parseAgentStatusPayload returned null for a known-good fixture') - } - const listener = vi.fn() - server.setListener(listener) - // 201 chars — one past the listener's 200-char cap. - const oversized = 'a'.repeat(201) - server.ingestRemote( - { paneKey: oversized, tabId: 'tab-1', worktreeId: 'wt-1', payload }, - 'conn-1' - ) - expect(listener).not.toHaveBeenCalled() - }) - - it('drops remote relay envelopes with legacy numeric paneKeys before cache mutation', () => { - const server = new AgentHookServer() - const payload = parseAgentStatusPayload( - JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) - ) - if (!payload) { - throw new Error('parseAgentStatusPayload returned null for a known-good fixture') - } - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { paneKey: 'tab-1:0', tabId: 'tab-1', worktreeId: 'wt-1', payload }, - 'conn-1' - ) - expect(listener).not.toHaveBeenCalled() - expect(server.getStatusSnapshot()).toEqual([]) - }) - - it('maps registered legacy numeric relay pane keys to stable pane keys', () => { - const server = new AgentHookServer() - const payload = parseAgentStatusPayload( - JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) - ) - if (!payload) { - throw new Error('parseAgentStatusPayload returned null for a known-good fixture') - } - const listener = vi.fn() - server.registerPaneKeyAlias('tab-1:0', PANE) - server.setListener(listener) - server.ingestRemote( - { paneKey: 'tab-1:0', tabId: 'tab-1', worktreeId: 'wt-1', payload }, - 'conn-1' - ) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: 'conn-1', - payload - }) - ) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - state: 'working', - prompt: 'p' - }) - ]) - }) - - it('drops remote relay envelopes whose tabId disagrees with the paneKey tab', () => { - const server = new AgentHookServer() - const payload = parseAgentStatusPayload( - JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) - ) - if (!payload) { - throw new Error('parseAgentStatusPayload returned null for a known-good fixture') - } - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { paneKey: PANE, tabId: 'tab-other', worktreeId: 'wt-1', payload }, - 'conn-1' - ) - expect(listener).not.toHaveBeenCalled() - expect(server.getStatusSnapshot()).toEqual([]) - }) - - it('rejects empty connectionId', () => { - const server = new AgentHookServer() - const payload = parseAgentStatusPayload( - JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) - ) - if (!payload) { - throw new Error('parseAgentStatusPayload returned null for a known-good fixture') - } - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, '') - expect(listener).not.toHaveBeenCalled() - }) - - it('rejects whitespace-only connectionId', () => { - const server = new AgentHookServer() - const payload = parseAgentStatusPayload( - JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) - ) - if (!payload) { - throw new Error('parseAgentStatusPayload returned null for a known-good fixture') - } - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, ' ') - expect(listener).not.toHaveBeenCalled() - }) - - it('rejects non-string tabId', () => { - const server = new AgentHookServer() - const payload = parseAgentStatusPayload( - JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) - ) - if (!payload) { - throw new Error('parseAgentStatusPayload returned null for a known-good fixture') - } - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { paneKey: PANE, tabId: 123 as unknown as string, worktreeId: 'wt-1', payload }, - 'conn-1' - ) - expect(listener).not.toHaveBeenCalled() - }) - - it('rejects empty paneKey after trim', () => { - const server = new AgentHookServer() - const payload = parseAgentStatusPayload( - JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) - ) - if (!payload) { - throw new Error('parseAgentStatusPayload returned null for a known-good fixture') - } - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote({ paneKey: ' ', tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1') - expect(listener).not.toHaveBeenCalled() - expect(trackMock).toHaveBeenCalledWith('agent_hook_unattributed', { - reason: 'empty_pane_key' - }) - }) - - it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => { - // Why: a buggy/malicious relay could forward an over-cap field, so ingestRemote re-runs the normalizer to enforce the AGENT_STATUS_MAX_FIELD_LENGTH cap at the trust boundary. - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'working', prompt: 'x'.repeat(500), agentType: 'claude' } - }, - 'conn-1' - ) - expect(listener).toHaveBeenCalledTimes(1) - const event = listener.mock.calls[0][0] as { payload: { prompt: string } } - expect(event.payload.prompt.length).toBe(200) - }) -}) - -describe('AgentHookServer ingestTerminalStatus', () => { - it('keeps hook monitoring mode across an equivalent OSC ping until a hook clears it', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - source: 'claude', - hookEventName: 'Stop', - payload: { - state: 'working', - workingMode: 'monitoring', - prompt: 'watch the build', - agentType: 'claude' - } - }, - 'conn-1' - ) - server.ingestTerminalStatus({ - paneKey: PANE, - connectionId: 'conn-1', - payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' } - }) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - workingMode: 'monitoring' - }) - - server.ingestRemote( - { - paneKey: PANE, - source: 'claude', - hookEventName: 'PreToolUse', - payload: { - state: 'working', - prompt: 'watch the build', - agentType: 'claude', - toolName: 'Read' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()[0]?.workingMode).toBeUndefined() - }) - - // Why: the OSC 9999 payload cannot carry a provider session, so letting it overwrite the row - // erased the session id from persisted rows and from headless `orca serve` — which serves these - // rows straight to mobile — leaving Chat UI with no transcript to subscribe to (#10630). - it('keeps the cached provider session when an OSC status completes the turn', () => { - const server = new AgentHookServer() - const providerSession = { key: 'session_id' as const, id: 'claude-session-1' } - - server.ingestRemote( - { - paneKey: PANE, - providerSession, - payload: { state: 'working', prompt: 'summarize the diff', agentType: 'claude' } - }, - 'conn-1' - ) - server.ingestTerminalStatus({ - paneKey: PANE, - connectionId: 'conn-1', - payload: { state: 'done', prompt: 'summarize the diff', agentType: 'claude' } - }) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', providerSession }) - }) - - // Why: an OSC ping that names no agent — or the literal 'unknown', which - // resolveAgentStatusIdentity treats identically — makes no claim about the pane's identity, so it - // must not read as a mismatch. Missing this stripped the session from persisted and headless rows - // while the live renderer kept it, blanking mobile chat only after a restart (#10630). - it.each([undefined, 'unknown' as const])( - 'keeps the cached provider session when an OSC status claims agentType %s', - (agentType) => { - const server = new AgentHookServer() - const providerSession = { key: 'session_id' as const, id: 'claude-session-1' } - - server.ingestRemote( - { - paneKey: PANE, - providerSession, - payload: { state: 'working', prompt: 'summarize the diff', agentType: 'claude' } - }, - 'conn-1' - ) - server.ingestTerminalStatus({ - paneKey: PANE, - connectionId: 'conn-1', - payload: { - state: 'done', - prompt: 'summarize the diff', - ...(agentType ? { agentType } : {}) - } - }) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ providerSession }) - } - ) - - it('drops the cached provider session when an OSC status starts a new turn', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE, - providerSession: { key: 'session_id' as const, id: 'claude-session-1' }, - payload: { state: 'done', prompt: 'first', agentType: 'claude' } - }, - 'conn-1' - ) - server.ingestTerminalStatus({ - paneKey: PANE, - connectionId: 'conn-1', - payload: { state: 'working', prompt: 'second', agentType: 'claude' } - }) - - expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() - }) - - it('forwards runtime terminal status through the normal listener and snapshot path', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - - server.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { - state: 'working', - prompt: 'ship it', - agentType: 'codex' - } - }) - - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - receivedAt: 1_000, - stateStartedAt: 1_000, - payload: { - state: 'working', - prompt: 'ship it', - agentType: 'codex' - } - }) - ) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - receivedAt: 1_000, - stateStartedAt: 1_000, - state: 'working', - prompt: 'ship it', - agentType: 'codex' - }) - ]) - expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) - } finally { - vi.useRealTimers() - } - }) - - it('suppresses exact duplicate runtime terminal status observations', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - const event = { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { - state: 'working' as const, - prompt: 'same turn', - agentType: 'codex' as const - } - } - - server.ingestTerminalStatus(event) - vi.setSystemTime(1_250) - server.ingestTerminalStatus(event) - - expect(listener).toHaveBeenCalledTimes(1) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - receivedAt: 1_000, - stateStartedAt: 1_000, - state: 'working', - prompt: 'same turn' - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('preserves runtime terminal status connection identity', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - - server.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: 'ssh-conn-1', - payload: { - state: 'working', - prompt: 'ship it', - agentType: 'codex' - } - }) - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: 'ssh-conn-1', - payload: { - state: 'working', - prompt: 'ship it', - agentType: 'codex' - } - }) - ) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - connectionId: 'ssh-conn-1', - state: 'working', - prompt: 'ship it' - }) - ]) - } finally { - vi.useRealTimers() - } - }) - - it('rejects runtime terminal status with mismatched tab identity', () => { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - - server.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'other-tab', - worktreeId: 'wt-1', - payload: { - state: 'working', - prompt: 'bad tab', - agentType: 'codex' - } - }) - - expect(listener).not.toHaveBeenCalled() - expect(server.getStatusSnapshot()).toEqual([]) - }) -}) - -describe('AgentHookServer closed-tab suppression bound', () => { - it('bounds closedAgentStatusTabIds with LRU eviction as tabs close', () => { - const server = new AgentHookServer() - const internals = server as unknown as { - markTabClosedForAgentStatus: (tabId: string) => void - closedAgentStatusTabIds: Set - } - - const total = CLOSED_AGENT_STATUS_TAB_IDS_MAX + 200 - for (let i = 0; i < total; i += 1) { - internals.markTabClosedForAgentStatus(`closed-tab-${i}`) - } - - // Set stays bounded; oldest ids are evicted, most-recent are retained. - expect(internals.closedAgentStatusTabIds.size).toBe(CLOSED_AGENT_STATUS_TAB_IDS_MAX) - expect(internals.closedAgentStatusTabIds.has('closed-tab-0')).toBe(false) - expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true) - }) -}) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 9d75836cd11..54fa168dda6 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -62,6 +62,7 @@ --color-ring: var(--ring); --color-agent-question: var(--agent-question); --color-agent-question-text: var(--agent-question-text); + --color-agent-monitoring: var(--agent-monitoring); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); @@ -181,6 +182,13 @@ --agent-question: var(--color-orange-600); /* Legible weight for text/glyphs on a tinted --agent-question surface. */ --agent-question-text: var(--color-orange-700); + /* "The turn is over; background work is still registered." Turquoise, not a + green or a yellow: it has to stay separable from the emerald done/active dot + at dot size, and from working-yellow on the map. Same literal in both themes, + like --workspace-status-done — a status keeps its identity when the theme + flips. Deliberately NOT read from the terminal theme at runtime: this is a + fixed UI value like every other status colour. */ + --agent-monitoring: #8abeb7; --status-success: #15803d; --status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent); --status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent); @@ -291,6 +299,7 @@ --ai-action-accent: var(--color-violet-400); --agent-question: var(--color-orange-500); --agent-question-text: var(--color-orange-300); + --agent-monitoring: #8abeb7; --status-success: #86efac; --status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent); --status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent); diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 6304b1cfd68..cf689be4d3c 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -42,13 +42,15 @@ describe('AgentStateDot', () => { expect(markup).toContain('motion-reduce:border-t-yellow-500') }) - it('renders monitoring as a static yellow radio glyph', () => { + it('renders monitoring as a static turquoise dot, never a spinner', () => { const markup = renderMarkup('monitoring') expect(markup).toContain('aria-label="Monitoring background tasks"') - expect(markup).toContain('lucide-radio') - expect(markup).toContain('text-yellow-500') + expect(markup).toContain('bg-agent-monitoring') + // Why: the turn is over — a spinner here is the bug this state exists to fix. expect(markup).not.toContain('data-agent-spinner') + // Why: must stay visually separable from the done/active dot. + expect(markup).not.toContain('bg-emerald-500') }) it('renders done as an emerald check icon', () => { diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 4f361d01f87..19973949a8d 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { CircleCheck, Radio } from 'lucide-react' +import { CircleCheck } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' @@ -84,17 +84,6 @@ export const AgentStateDot = React.memo(function AgentStateDot({ ) } - if (state === 'monitoring') { - return ( - - - ) - } - if (state === 'done') { // Why: the dashboard lists many agents, so a check glyph scans well for // agent-reported completion and keeps 'done' visually distinct from @@ -132,7 +121,11 @@ export const AgentStateDot = React.memo(function AgentStateDot({ inner, state === 'blocked' || state === 'interrupted' || state === 'failed' ? 'bg-red-500' - : 'bg-neutral-500/40' + : // Why: static dot, not a spinner — the turn is over. Turquoise stays + // separable from the emerald done dot at this size. + state === 'monitoring' + ? 'bg-agent-monitoring' + : 'bg-neutral-500/40' )} /> diff --git a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx index d0a9ea6b619..13f6f03e60f 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx @@ -136,7 +136,7 @@ describe('palette live status', () => { await render() expect(testContainer.querySelector('[data-spinner]')).toBeNull() - expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500') + expect(testContainer.querySelector('.bg-agent-monitoring')).not.toBeNull() expect(dotLabels()).toEqual(['Monitoring background tasks']) }) @@ -276,7 +276,7 @@ describe('palette live status', () => { }) expect(testContainer.querySelector('[data-spinner]')).toBeNull() - expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500') + expect(testContainer.querySelector('.bg-agent-monitoring')).not.toBeNull() expect(dotLabels()).toEqual(['Monitoring background tasks']) expect(testContainer.querySelector('[title="Monitoring background tasks"]')).not.toBeNull() }) diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index 189540305a7..4d20e76946e 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -420,8 +420,10 @@ stroke: var(--color-yellow-500); } +/* Why: static turquoise, distinct from working-yellow — the turn is over but + background work is still registered. Same token as the sidebar/dashboard dot. */ .fleet-status-monitoring .agent-map-agent-mark { - stroke: var(--color-yellow-500); + stroke: var(--agent-monitoring); } .fleet-status-blocked .agent-map-agent-mark { diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 9a95a6293e8..c312517bdd1 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -362,8 +362,7 @@ describe('DashboardAgentRow', () => { ) expect(markup).toContain('Monitoring background tasks') - expect(markup).toContain('lucide-radio') - expect(classTokens(markup)).toContain('text-yellow-500') + expect(classTokens(markup)).toContain('bg-agent-monitoring') expect(markup).not.toContain('data-agent-spinner') expect(markup).not.toContain('data-agent-row-tool-slot') }) diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index 89fdcb12f75..f28c43cb140 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -31,13 +31,13 @@ describe('StatusIndicator', () => { expect(markup).toContain('motion-reduce:border-t-yellow-500') }) - it('renders monitoring as a static radio glyph', () => { + it('renders monitoring as a static turquoise dot, never a spinner', () => { const markup = renderMarkup('monitoring') expect(markup).toContain('title="Monitoring background tasks"') - expect(markup).toContain('lucide-radio') - expect(markup).toContain('text-yellow-500') + expect(markup).toContain('bg-agent-monitoring') expect(markup).not.toContain('data-agent-spinner') + expect(markup).not.toContain('bg-emerald-500') }) it('renders permission as the shared question glyph', () => { diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index 97726487c19..1d63739c813 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -1,5 +1,4 @@ import React from 'react' -import { Radio } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' @@ -41,18 +40,6 @@ const StatusIndicator = React.memo(function StatusIndicator({ ) } - if (status === 'monitoring') { - return ( - - - ) - } - if (status === 'permission') { return ( diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts new file mode 100644 index 00000000000..f0b2aab1520 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { useAgentCompletionCoordinatorLifecycle } from './agent-completion-coordinator-test-harness' +import { + createHookListenerState, + normalizeHookPayload +} from '../../../../shared/agent-hook-listener' +import { makePaneKey } from '../../../../shared/stable-pane-id' + +// Why: STA-4119's second complaint is the missing completion notification. This drives the REAL +// hook listener and feeds its real output into the REAL coordinator, rather than hand-writing a +// payload — the whole question is whether the two layers actually agree about a monitoring turn. +const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const RUNNING_SHELL = { + id: 'shell-1', + type: 'shell', + status: 'running', + description: 'Run the dev server', + command: 'pnpm dev' +} + +function hookPayload( + state: ReturnType, + payload: Record +) { + const parsed = normalizeHookPayload( + state, + 'claude', + { paneKey: PANE, payload }, + 'production' + )?.payload + if (!parsed) { + throw new Error('listener produced no payload') + } + // The hook server stamps stateStartedAt on the way to the renderer. + return { ...parsed, stateStartedAt: 1_700_000_000_000 } +} + +function createCoordinator() { + const dispatchCompletion = vi.fn() + const dispatchHookLifecycle = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: PANE, + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + dispatchHookLifecycle, + isLive: () => true + }) + return { coordinator, dispatchCompletion, dispatchHookLifecycle } +} + +/** Meta the coordinator hands the notification dispatcher. */ +function completionMeta(dispatchCompletion: ReturnType) { + return dispatchCompletion.mock.calls[0]?.[1] as + | { source?: string; agentStatus?: { state?: string; workingMode?: string } } + | undefined +} + +describe('completion notification when a lead turn ends into monitoring', () => { + useAgentCompletionCoordinatorLifecycle() + + it('announces completion when the turn ends but the pane stays working for a background shell', () => { + const listener = createHookListenerState() + const { coordinator, dispatchCompletion, dispatchHookLifecycle } = createCoordinator() + + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) + ) + const monitoring = hookPayload(listener, { + hook_event_name: 'Stop', + background_tasks: [RUNNING_SHELL] + }) + + // Precondition: the pane really is in the monitoring state, not done. + expect(monitoring).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + expect(typeof monitoring.turnCompletedAt).toBe('number') + + coordinator.observeHookStatus(monitoring) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + const meta = completionMeta(dispatchCompletion) + expect(meta?.source).toBe('hook') + // Why: the announcement is a synthesized `done` while the reported row stays `working` — + // the notification follows the TURN boundary, not the rendered state. + expect(meta?.agentStatus?.state).toBe('done') + // Why: announce only. Running pane lifecycle here would settle a pane that is still working. + expect(dispatchHookLifecycle).not.toHaveBeenCalledWith( + expect.objectContaining({ state: 'done' }) + ) + }) + + it('does not announce twice when the background shell later clears to done', () => { + const listener = createHookListenerState() + const { coordinator, dispatchCompletion } = createCoordinator() + + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) + ) + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) + ) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + // The shell exits; the same turn's all-clear must not re-announce. + coordinator.observeHookStatus( + hookPayload(listener, { + hook_event_name: 'PostToolUse', + tool_name: 'KillShell', + background_tasks: [] + }) + ) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('does not announce mid-turn while the agent is still working in the foreground', () => { + const listener = createHookListenerState() + const { coordinator, dispatchCompletion } = createCoordinator() + + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' }) + ) + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + ) + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) +}) diff --git a/src/shared/claude-background-task-status.test.ts b/src/shared/claude-background-task-status.test.ts index efeb8ed8854..6722dded5d3 100644 --- a/src/shared/claude-background-task-status.test.ts +++ b/src/shared/claude-background-task-status.test.ts @@ -145,6 +145,88 @@ describe('Claude background task status', () => { expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(false) }) + // Why: STA-4119's second complaint is the missing completion notification. The renderer's + // completion coordinator announces off `turnCompletedAt` on a still-`working` payload, so the + // stamp — not the rendered state — is what makes the notification fire when a turn ends into + // monitoring. The stamp predicate and the monitoring predicate are computed from the same + // "lead said done but the pane resolves to working" expression, so a refactor that renames one + // can silently drop the other with nothing else going red. These pin both halves. + it('stamps the turn end when a finished lead falls back to monitoring', () => { + const state = createHookListenerState() + + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'UserPromptSubmit', + prompt: 'start the dev server' + }) + const monitoring = claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [RUNNING_SHELL] + }) + + expect(monitoring).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + expect(typeof monitoring?.turnCompletedAt).toBe('number') + }) + + it('stamps a lead that ends into a working subagent, which carries no monitoring mode', () => { + const state = createHookListenerState() + + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'SubagentStart', agent_id: 'child-1' }) + const childWorking = claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'child-1', type: 'subagent', status: 'running' }] + }) + + // Why: real agent work keeps the spinner (no workingMode) but the lead turn still ended, + // so the stamp must be present. The two predicates are deliberately not the same. + expect(childWorking).toMatchObject({ state: 'working', workingMode: undefined }) + expect(typeof childWorking?.turnCompletedAt).toBe('number') + }) + + it('does not stamp a turn that is still running in the foreground', () => { + const state = createHookListenerState() + + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'work' }) + const midTurn = claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'PreToolUse', + tool_name: 'Bash' + }) + + expect(midTurn).toMatchObject({ state: 'working', workingMode: undefined }) + expect(midTurn?.turnCompletedAt).toBeUndefined() + }) + + it('does not stamp an interrupted lead, which settles done instead of monitoring', () => { + const state = createHookListenerState() + + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'start it' }) + markClaudeLeadTurnInterrupted(state, SOURCE_PANE) + const interrupted = claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [RUNNING_SHELL] + }) + + expect(interrupted?.state).toBe('done') + expect(interrupted?.workingMode).toBeUndefined() + expect(interrupted?.turnCompletedAt).toBeUndefined() + }) + + it('does not stamp a turn that simply finished with nothing left running', () => { + const state = createHookListenerState() + + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'quick job' }) + const finished = claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [], + session_crons: [] + }) + + // Why: the stamp exists to announce a turn whose pane STAYS working. A plain done + // needs no stamp — the ordinary done path already notifies. + expect(finished?.state).toBe('done') + expect(finished?.turnCompletedAt).toBeUndefined() + }) + it('keeps foreground child work active before falling back to monitoring', () => { const state = createHookListenerState() claudeEvent(state, SOURCE_PANE, {