Files
orca/src/main/agent-hooks/server.test.ts
T

6851 lines
205 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 'child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync
} from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { AgentHookServer, agentHookServer, _internals } from './server'
import {
AGENT_STATUS_MAX_FIELD_LENGTH,
AGENT_STATUS_STALE_AFTER_MS,
parseAgentStatusPayload
} from '../../shared/agent-status-types'
import { makePaneKey } from '../../shared/stable-pane-id'
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)
type Body = {
paneKey: string
tabId?: string
worktreeId?: string
env?: string
version?: string
payload: Record<string, unknown>
}
type AgentHookServerCacheInternals = {
assistantMessageRetryTimers: Map<string, number | ReturnType<typeof globalThis.setTimeout>>
promptSentDedupeByPaneKey: Map<string, unknown>
runtimeObservedStatusPaneKeys: Set<string>
scheduleStatusPersist: () => void
}
function buildBody(payload: Record<string, unknown>, overrides: Partial<Body> = {}): 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('applies inferred interrupts through the cached status lifecycle', () => {
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' },
payload: { state: 'working', prompt: 'long task', agentType: 'codex' }
},
'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 })
})
)
} 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<AgentHookServer['inferInterrupt']>[0])
).not.toThrow()
expect(
server.inferInterrupt(request as Parameters<AgentHookServer['inferInterrupt']>[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('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(PANE)
})
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<Response> =>
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('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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>): Promise<Response> =>
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<string, unknown>
): Promise<void> => {
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('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
// cross-build diagnostics the local HTTP path runs, so a remote source of
// genuinely stale hooks emits the same 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: pin both invariants — warn-once dedupe AND fanout still fires for
// the second event. Without the second assertion, a future refactor that
// drops the second event silently would still leave warn-count unchanged.
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<string, unknown>): Promise<void> => {
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('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: we use a per-tool allowlist to decide which field to preview.
// Tools we do not recognize render as name-only rather than guessing at
// a field, which avoids noisy/misleading previews (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 (e.g. { task_id: "3", status: "in_progress" })
// has no meaningful preview — rendering "3" is actively confusing. The
// allowlist approach leaves toolInput undefined for unlisted tools.
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 surfaces the error text as lastAssistantMessage', () => {
const result = _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(result?.payload.state).toBe('working')
expect(result?.payload.toolName).toBe('Edit')
expect(result?.payload.lastAssistantMessage).toBe('file is read-only')
})
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: a turn with many large tool_result entries pushes the final text
// reply well past the first 64 KB chunk; the chunked scan should keep
// reading backward until it finds 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 of its own — merged snapshot should still
// carry 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('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 is NOT an approval prompt — it fires for every
// tool call. We map it to `working` (never `waiting`) and use it only to
// give the dashboard a live readout during the gap between prompt and
// Stop. Real approval signals 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: Codex asks for user attention through PermissionRequest. Orca's
// sidebar red dot depends on this becoming `waiting`; treating it like
// PreToolUse would leave the pane looking busy while it is 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 event; any tool_name on it
// would be leftover noise and should not leak into the working-state
// preview. Tool extraction is gated to PreToolUse/PostToolUse.
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 emits the user's MessagePart (message.updated) *before*
// SessionBusy fires — the session goes idle→busy only after OpenCode begins
// processing the prompt. So the cached prompt at SessionBusy is the current
// turn's prompt, not the previous turn's. Clearing on SessionBusy would
// clobber the data the dashboard needs to render for this turn.
_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: OpenCode emits `question.asked` when the agent uses an ask-the-user
// tool (distinct from `permission.asked`, which blocks on tool approval).
// Both leave the agent idle-but-waiting on a human, so both must render
// the same red "needs attention" indicator. Without this mapping the pane
// silently stays in `working` and the user has no visual cue that the
// agent is waiting on them.
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: plugin builds installed before the throttle/cap fix re-post the
// full accumulated reply on every streamed part update. The listener must
// bound the text so each event's status compare, IPC fanout, and renderer
// store update stay O(cap) instead of O(reply length).
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 has always been single-line-capped at 200 by
// normalizeAgentStatusObject; this asserts the oversized input still
// flows through without blowing past 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('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, not via
// message_end. A user-role message_end should 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 maps to done', () => {
const result = _internals.normalizeHookPayload(
'pi',
buildBody({ hook_event_name: 'session_shutdown' }),
'production'
)
expect(result?.payload.state).toBe('done')
})
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('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
})
})
)
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 off type/setuid bits so we assert only the rwx octet that
// writeFileSync(mode:0o600) sets. A leaky umask at dir-create time can
// leave group/other bits on the *parent* dir but not on the file itself.
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)
// But contents are refreshed with the new token (and port) — that is the
// whole point of the design: survivors reading a stale-env file reach the
// live server. Why token-first: the token is randomUUID()-minted per
// start(), so it is guaranteed to differ across restarts. The port comes
// from listen(0) and the kernel can legitimately reassign the same
// ephemeral port, so asserting port-inequality would be a latent flake.
expect(secondToken).toBeTruthy()
expect(secondToken).not.toBe(firstToken)
const contents = readFileSync(secondPath!, 'utf8')
// Why: token-based content check is the rewrite signal. A strict
// "contents does NOT contain firstPort" assertion would flake on the
// (rare but legitimate) case where listen(0) reuses the same ephemeral
// port across restarts. The token is randomUUID() and cannot collide.
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() deliberately does NOT unlink the endpoint file. A stale file
// points at a dead port — the fail-open path (hook POSTs silently fail,
// same as pre-endpoint-file). Unlinking would introduce a TOCTOU race with a
// concurrent Orca instance sharing userData that could rewrite the file
// between our token check and unlink. The next successful start()
// overwrites the file atomically; tmp-file orphan hygiene is handled by
// the sweep inside writeEndpointFile().
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 start({ userDataPath }). In tests
// and in the packaged main-process path where userData is unset for any
// reason, hooks should fall back to the 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: writeEndpointFile() writes to a unique tmp path then renames. A crash
// between write and rename leaves an orphan tmp; the sweep inside
// writeEndpointFile() must drop ones older than 5 min without touching
// fresh ones (a concurrent writer's 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: every value written is sourced as shell. The isShellSafeEndpointValue
// allowlist must reject a metacharacter-bearing value so a future caller
// cannot command-inject via the sourced file. `env` is the only caller-
// provided field we can easily poison from a test — feed it a semicolon
// and assert the file is not written and buildPtyEnv() omits the ENDPOINT
// key (gated on endpointFileWritten).
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: sources the file in a subshell and echoes the resulting env var,
// exactly as the managed hook script does at runtime. If the file shape
// ever drifts from `KEY=VALUE` (e.g. someone adds shell metacharacters
// without quoting), this test catches it 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: hydrate now drops entries older than 7d (HYDRATE_MAX_AGE_MS). Use
// a recent-but-not-Date.now() timestamp in fixtures so the tests assert
// hydration behavior rather than 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<Response> {
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' })
)
// 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', prompt: 'persist me' })
})
} finally {
server.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('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('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: deliberately divergent — paneKey says tab-A, the entry
// claims tab-B. Sanitizer must drop rather than hydrate this
// inconsistent row.
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: a no-op clearPaneState on a paneKey not in the cache is a
// mutation site that should NOT trigger a redundant write. (clear was
// designed to bail when nothing was evicted.)
server.clearPaneState(makePaneKey('non-existent', LEAF_5))
server.flushStatusPersistSync()
// Touch back to the same mtime would let the test pass spuriously, so
// assert no rewrite happened by checking that mtime is 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: file written even though we never explicitly flushed before stop —
// stop() must synchronously drain the pending trailing-debounced timer.
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('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 (which itself rejects bad states) by
// constructing an obviously-invalid payload — `ingestRemote` is the trust
// boundary we're testing, 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: the relay normally normalizes the payload on the wire, but a buggy
// or malicious relay could forward an over-cap field. ingestRemote must
// re-run the canonical normalizer so the AGENT_STATUS_MAX_FIELD_LENGTH
// cap (200 chars) is enforced 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('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([])
})
})