Files
orca/src/main/ipc/agent-hooks.test.ts
T
a651e81843 refactor(agents): remove dead hook IPC and derive shared agent defaults (#16089)
* refactor(agent-hooks): drop the unused per-agent hook status IPC surface

No renderer, CLI, or mobile caller invoked window.api.agentHooks.*Status; main
already reads install status through MANAGED_AGENT_HOOK_STATUS_READERS. The
14 handlers had also drifted (kimiStatus existed in main/preload but not in
AgentHooksApi or the web stub).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tui-agent-config): default launchCmd and expectedProcess to detectCmd

32 of 36 entries repeated the binary name three times. Entries are now
authored in a source form where both default to detectCmd and resolved once
at module load, so TUI_AGENT_CONFIG keeps its exact shape for consumers
(verified equal to the previous table).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(mobile): derive the agent order, labels, and picker from src/shared

The mobile mirror (and its regex-over-desktop-source parity test) predates
mobile importing runtime values from src/shared, which it now does in a dozen
modules. Only the favicon-domain map stays mobile-local because desktop's lives
in the renderer catalog next to bundled ?url imports. The parity test now
imports the real registries and also checks label parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(web): align preload surface after hook IPC removal

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-30 19:02:14 -07:00

395 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as AgentHookServerModule from '../agent-hooks/server'
import { makePaneKey } from '../../shared/stable-pane-id'
// Why: cover the agentStatus:drop IPC handler — it must propagate the
// renderer dismissal to dropStatusEntry so the on-disk last-status file
// evicts the entry.
const dropStatusEntry = vi.fn()
const dropStatusEntriesByTabPrefix = vi.fn()
const retirePaneAuthority = vi.fn()
const transferPaneAuthority = vi.fn()
const canTransferPaneAuthority = vi.fn(() => true)
const getStatusSnapshot = vi.fn()
const inferInterrupt = vi.fn()
const clearMigrationUnsupportedPtysByTabPrefix = vi.fn()
const clearMigrationUnsupportedPtysForPaneKey = vi.fn()
const onHandlers = new Map<string, (event: unknown, ...args: unknown[]) => void>()
const handleHandlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
const removeHandler = vi.fn()
const removeAllListeners = vi.fn()
const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
const CHILD_PANE_KEY = makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222')
vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
handleHandlers.set(channel, handler)
},
on: (channel: string, handler: (event: unknown, ...args: unknown[]) => void) => {
onHandlers.set(channel, handler)
},
removeHandler,
removeAllListeners
}
}))
vi.mock('../agent-hooks/server', async () => {
// Why: import the real isValidPaneKey so this test stays in sync with any
// tightening of the validator (length cap, character allow-list, etc).
const actual = await vi.importActual<typeof AgentHookServerModule>('../agent-hooks/server')
return {
...actual,
agentHookServer: {
dropStatusEntry,
dropStatusEntriesByTabPrefix,
retirePaneAuthority,
transferPaneAuthority,
canTransferPaneAuthority,
getStatusSnapshot,
inferInterrupt
}
}
})
vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({
clearMigrationUnsupportedPtysByTabPrefix,
clearMigrationUnsupportedPtysForPaneKey,
getMigrationUnsupportedPtySnapshot: vi.fn(() => [])
}))
vi.mock('../claude/hook-service', () => ({
claudeHookService: { getStatus: vi.fn(() => ({ agent: 'claude', state: 'absent' })) }
}))
vi.mock('../openclaude/hook-service', () => ({
openClaudeHookService: { getStatus: vi.fn(() => ({ agent: 'openclaude', state: 'absent' })) }
}))
vi.mock('../codex/hook-service', () => ({
codexHookService: { getStatus: vi.fn(() => ({ agent: 'codex', state: 'absent' })) }
}))
vi.mock('../gemini/hook-service', () => ({
geminiHookService: { getStatus: vi.fn(() => ({ agent: 'gemini', state: 'absent' })) }
}))
vi.mock('../antigravity/hook-service', () => ({
antigravityHookService: { getStatus: vi.fn(() => ({ agent: 'antigravity', state: 'absent' })) }
}))
vi.mock('../amp/hook-service', () => ({
ampHookService: { getStatus: vi.fn(() => ({ agent: 'amp', state: 'absent' })) }
}))
vi.mock('../cursor/hook-service', () => ({
cursorHookService: { getStatus: vi.fn(() => ({ agent: 'cursor', state: 'absent' })) }
}))
vi.mock('../droid/hook-service', () => ({
droidHookService: { getStatus: vi.fn(() => ({ agent: 'droid', state: 'absent' })) }
}))
vi.mock('../command-code/hook-service', () => ({
commandCodeHookService: { getStatus: vi.fn(() => ({ agent: 'command-code', state: 'absent' })) }
}))
vi.mock('../grok/hook-service', () => ({
grokHookService: { getStatus: vi.fn(() => ({ agent: 'grok', state: 'absent' })) }
}))
vi.mock('../copilot/hook-service', () => ({
copilotHookService: { getStatus: vi.fn(() => ({ agent: 'copilot', state: 'absent' })) }
}))
vi.mock('../hermes/hook-service', () => ({
hermesHookService: { getStatus: vi.fn(() => ({ agent: 'hermes', state: 'absent' })) }
}))
vi.mock('../devin/hook-service', () => ({
devinHookService: { getStatus: vi.fn(() => ({ agent: 'devin', state: 'absent' })) }
}))
vi.mock('../kimi/hook-service', () => ({
kimiHookService: { getStatus: vi.fn(() => ({ agent: 'kimi', state: 'absent' })) }
}))
beforeEach(() => {
dropStatusEntry.mockReset()
dropStatusEntriesByTabPrefix.mockReset()
retirePaneAuthority.mockReset()
transferPaneAuthority.mockReset()
canTransferPaneAuthority.mockReset()
canTransferPaneAuthority.mockReturnValue(true)
getStatusSnapshot.mockReset()
inferInterrupt.mockReset()
clearMigrationUnsupportedPtysByTabPrefix.mockReset()
clearMigrationUnsupportedPtysForPaneKey.mockReset()
onHandlers.clear()
handleHandlers.clear()
removeHandler.mockReset()
removeAllListeners.mockReset()
})
afterEach(() => {
vi.resetModules()
})
describe('agentStatus:getSnapshot IPC', () => {
it('returns the hook cache snapshot', async () => {
const snapshot = [
{
paneKey: PANE_KEY,
state: 'done',
prompt: 'p',
agentType: 'claude',
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000
}
]
getStatusSnapshot.mockReturnValue(snapshot)
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentStatus:getSnapshot')
expect(handler).toBeDefined()
expect(handler!({})).toEqual(snapshot)
})
it('enriches the hook cache snapshot with runtime lineage metadata', async () => {
const snapshot = [
{
paneKey: PANE_KEY,
state: 'done',
prompt: 'parent',
agentType: 'codex',
connectionId: null,
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000
},
{
paneKey: CHILD_PANE_KEY,
state: 'done',
prompt: 'child',
agentType: 'codex',
connectionId: null,
receivedAt: 1_700_000_001_000,
stateStartedAt: 1_700_000_000_500
}
]
getStatusSnapshot.mockReturnValue(snapshot)
const runtime = {
getAgentStatusTerminalHandleForPaneKey: vi.fn((paneKey: string) =>
paneKey === PANE_KEY ? 'term-parent' : paneKey === CHILD_PANE_KEY ? 'term-child' : undefined
),
getAgentStatusOrchestrationContextForPaneKey: vi.fn((paneKey: string) =>
paneKey === CHILD_PANE_KEY
? {
taskId: 'task-child',
dispatchId: 'dispatch-child',
parentTerminalHandle: 'term-parent',
parentPaneKey: PANE_KEY,
coordinatorHandle: 'term-parent'
}
: undefined
)
}
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers(runtime)
const handler = handleHandlers.get('agentStatus:getSnapshot')
expect(handler).toBeDefined()
expect(handler!({})).toEqual([
{
...snapshot[0],
terminalHandle: 'term-parent'
},
{
...snapshot[1],
terminalHandle: 'term-child',
orchestration: {
taskId: 'task-child',
dispatchId: 'dispatch-child',
parentTerminalHandle: 'term-parent',
parentPaneKey: PANE_KEY,
coordinatorHandle: 'term-parent'
}
}
])
})
})
describe('agentStatus:inferInterrupt IPC', () => {
it('forwards valid inference requests to the hook server', async () => {
inferInterrupt.mockReturnValue(true)
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentStatus:inferInterrupt')
expect(handler).toBeDefined()
const request = {
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'long task',
baselineAgentType: 'codex',
intent: 'ctrl-c'
}
expect(handler!({}, request)).toBe(true)
expect(inferInterrupt).toHaveBeenCalledWith(request)
})
it('rejects malformed requests before the hook server boundary', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentStatus:inferInterrupt')
expect(handler).toBeDefined()
for (const value of [null, undefined, '', 123, true]) {
expect(handler!({}, value)).toBe(false)
}
expect(inferInterrupt).not.toHaveBeenCalled()
})
})
describe('agentStatus:drop IPC', () => {
it('forwards drop to dropStatusEntry', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = onHandlers.get('agentStatus:drop')
expect(handler).toBeDefined()
handler!({}, PANE_KEY)
expect(dropStatusEntry).toHaveBeenCalledWith(PANE_KEY)
expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY)
})
it('rejects non-string paneKey (defensive against a malformed renderer message)', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = onHandlers.get('agentStatus:drop')!
const bad: unknown[] = [
123,
undefined,
'',
null,
{},
[],
'tab-1:0', // legacy numeric pane-key suffix
'no-colon', // missing colon — rejected by isValidPaneKey
':leading', // empty tabId half
'trailing:', // empty leafId half
'a:b:c' // multiple colons
]
for (const value of bad) {
expect(() => handler({}, value)).not.toThrow()
}
expect(dropStatusEntry).not.toHaveBeenCalled()
})
})
describe('agentStatus:dropByTabPrefix IPC', () => {
it('forwards valid tab ids to tab-prefix cache eviction', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = onHandlers.get('agentStatus:dropByTabPrefix')
expect(handler).toBeDefined()
handler!({}, 'tab-1')
expect(dropStatusEntriesByTabPrefix).toHaveBeenCalledWith('tab-1')
expect(clearMigrationUnsupportedPtysByTabPrefix).toHaveBeenCalledWith('tab-1')
})
it('rejects malformed tab ids', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = onHandlers.get('agentStatus:dropByTabPrefix')!
const bad: unknown[] = [
123,
undefined,
'',
null,
{},
[],
'tab-1:leaf',
' leading-space',
'trailing-space ',
'x'.repeat(161)
]
for (const value of bad) {
expect(() => handler({}, value)).not.toThrow()
}
expect(dropStatusEntriesByTabPrefix).not.toHaveBeenCalled()
expect(clearMigrationUnsupportedPtysByTabPrefix).not.toHaveBeenCalled()
})
it('removes any existing listener before registering the tab-prefix channel', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:dropByTabPrefix')
})
})
describe('agent pane authority IPC', () => {
it('retires one validated pane authority', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
onHandlers.get('agentStatus:retirePaneAuthority')!({}, PANE_KEY)
expect(retirePaneAuthority).toHaveBeenCalledWith(PANE_KEY)
expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY)
})
it('transfers validated pane authority with its provider PTY', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
onHandlers.get('agentStatus:transferPaneAuthority')!(
{},
{
fromPaneKey: PANE_KEY,
toPaneKey: CHILD_PANE_KEY,
ptyId: 'pty-1'
}
)
expect(transferPaneAuthority).toHaveBeenCalledWith(PANE_KEY, CHILD_PANE_KEY, 'pty-1')
})
it('rejects malformed pane authority messages and replaces prior listeners', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
onHandlers.get('agentStatus:retirePaneAuthority')!({}, 'tab-1:0')
onHandlers.get('agentStatus:transferPaneAuthority')!(
{},
{
fromPaneKey: PANE_KEY,
toPaneKey: 'invalid',
ptyId: ''
}
)
expect(retirePaneAuthority).not.toHaveBeenCalled()
expect(transferPaneAuthority).not.toHaveBeenCalled()
expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:retirePaneAuthority')
expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:transferPaneAuthority')
})
it('rejects unowned and oversized pane authority transfers', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const transfer = onHandlers.get('agentStatus:transferPaneAuthority')!
canTransferPaneAuthority.mockReturnValue(false)
transfer({}, { fromPaneKey: PANE_KEY, toPaneKey: CHILD_PANE_KEY, ptyId: 'forged-pty' })
transfer({}, { fromPaneKey: PANE_KEY, toPaneKey: CHILD_PANE_KEY })
canTransferPaneAuthority.mockReturnValue(true)
transfer({}, { fromPaneKey: PANE_KEY, toPaneKey: CHILD_PANE_KEY, ptyId: 'x'.repeat(513) })
transfer(
{},
{
fromPaneKey: `${'x'.repeat(180)}:11111111-1111-4111-8111-111111111111`,
toPaneKey: CHILD_PANE_KEY,
ptyId: 'pty-1'
}
)
expect(transferPaneAuthority).not.toHaveBeenCalled()
})
})