mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
* 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>
395 lines
13 KiB
TypeScript
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()
|
|
})
|
|
})
|