1041 lines
36 KiB
TypeScript
1041 lines
36 KiB
TypeScript
/* 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, _internals } from './server'
|
|
|
|
const PANE = 'tab-1:0'
|
|
|
|
type Body = {
|
|
paneKey: string
|
|
tabId?: string
|
|
worktreeId?: string
|
|
env?: string
|
|
version?: string
|
|
payload: Record<string, unknown>
|
|
}
|
|
|
|
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()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('AgentHookServer listener replay', () => {
|
|
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({
|
|
paneKey: PANE,
|
|
tabId: 'tab-1',
|
|
worktreeId: 'wt-1',
|
|
payload: expect.objectContaining({
|
|
state: 'working',
|
|
prompt: 'replay me',
|
|
agentType: 'claude'
|
|
})
|
|
})
|
|
} 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('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({
|
|
paneKey: PANE,
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo::/tmp/worktree with "quotes"',
|
|
payload: expect.objectContaining({
|
|
state: 'working',
|
|
prompt: 'form encoded',
|
|
agentType: 'claude'
|
|
})
|
|
})
|
|
} finally {
|
|
server.stop()
|
|
}
|
|
})
|
|
})
|
|
|
|
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('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')
|
|
})
|
|
|
|
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 Codex's `notify` callback.
|
|
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('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('PreToolUse surfaces toolName + toolInput', () => {
|
|
const result = _internals.normalizeHookPayload(
|
|
'gemini',
|
|
buildBody({
|
|
hook_event_name: 'PreToolUse',
|
|
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: 'PreToolUse',
|
|
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: 'PreToolUse',
|
|
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('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' }),
|
|
'production'
|
|
)
|
|
expect(result?.payload.state).toBe('working')
|
|
expect(result?.payload.prompt).toBe('hi there')
|
|
})
|
|
|
|
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('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 waiting 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('waiting')
|
|
expect(result?.payload.toolName).toBe('Shell')
|
|
expect(result?.payload.toolInput).toBe('rm -rf /tmp/foo')
|
|
})
|
|
|
|
it('beforeMCPExecution maps to waiting', () => {
|
|
const result = _internals.normalizeHookPayload(
|
|
'cursor',
|
|
buildBody({ hook_event_name: 'beforeMCPExecution', tool_name: 'fetch', url: 'https://x' }),
|
|
'production'
|
|
)
|
|
expect(result?.payload.state).toBe('waiting')
|
|
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('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('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 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('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()
|
|
}
|
|
})
|
|
})
|