mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
257 lines
9.8 KiB
TypeScript
257 lines
9.8 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest'
|
|
import { createHookListenerState } from './agent-hook-listener/listener-state'
|
|
import { normalizeHookPayload } from './agent-hook-listener'
|
|
import { makePaneKey } from './stable-pane-id'
|
|
|
|
const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
|
|
|
|
/**
|
|
* OpenCode reports an approval prompt as `permission.asked`; the status plugin forwards
|
|
* that event's `properties` under `hook_event_name: 'PermissionRequest'`. The shape is
|
|
* fixed by @opencode-ai/sdk 1.18.18 (`EventPermissionAsked`): `permission` names what is
|
|
* being requested, `patterns` carries the concrete commands/paths it applies to, and
|
|
* `metadata` holds tool-specific detail.
|
|
*
|
|
* Only those outer names are typed — `metadata` is `Record<string, unknown>`, so the keys
|
|
* inside it come from each tool. The payloads below were captured from a live opencode
|
|
* 1.18.18 rather than invented, because the first version of this suite guessed a shape
|
|
* (`metadata: {}`) that OpenCode never emits and so never exercised the real `edit` path.
|
|
*
|
|
* `normalizeOpenCodeFamilyEvent` serves opencode and mimo-code from one path, so every
|
|
* case here runs for both.
|
|
*/
|
|
describe('OpenCode-family permission request status', () => {
|
|
let state: ReturnType<typeof createHookListenerState>
|
|
|
|
beforeEach(() => {
|
|
state = createHookListenerState()
|
|
})
|
|
|
|
const SOURCES = ['opencode', 'mimo-code'] as const
|
|
|
|
function permissionEvent(
|
|
source: (typeof SOURCES)[number],
|
|
properties: Record<string, unknown>
|
|
): ReturnType<typeof normalizeHookPayload> {
|
|
return normalizeHookPayload(
|
|
state,
|
|
source,
|
|
{
|
|
paneKey: PANE_KEY,
|
|
payload: { hook_event_name: 'PermissionRequest', ...properties }
|
|
},
|
|
'production'
|
|
)
|
|
}
|
|
|
|
function lifecycleEvent(
|
|
source: (typeof SOURCES)[number],
|
|
eventName: string,
|
|
properties: Record<string, unknown> = {}
|
|
): ReturnType<typeof normalizeHookPayload> {
|
|
return normalizeHookPayload(
|
|
state,
|
|
source,
|
|
{ paneKey: PANE_KEY, payload: { hook_event_name: eventName, ...properties } },
|
|
'production'
|
|
)
|
|
}
|
|
|
|
const BASH_PERMISSION = {
|
|
id: 'per_01',
|
|
sessionID: 'ses_root',
|
|
permission: 'bash',
|
|
patterns: ['rm -rf build/'],
|
|
metadata: { command: 'rm -rf build/' },
|
|
always: []
|
|
}
|
|
|
|
it.each(SOURCES)('reports a permission request as waiting for %s', (source) => {
|
|
const event = permissionEvent(source, BASH_PERMISSION)
|
|
|
|
expect(event?.payload.state).toBe('waiting')
|
|
expect(event?.payload.agentType).toBe(source)
|
|
})
|
|
|
|
it.each(SOURCES)('names the requested permission as the tool for %s', (source) => {
|
|
const event = permissionEvent(source, BASH_PERMISSION)
|
|
|
|
// Why: a bare `waiting` row cannot tell the user what is blocked; the permission
|
|
// name is the only field that always identifies it (STA-3160).
|
|
expect(event?.payload.toolName).toBe('bash')
|
|
})
|
|
|
|
it.each(SOURCES)('surfaces the blocked command as tool input for %s', (source) => {
|
|
const event = permissionEvent(source, BASH_PERMISSION)
|
|
|
|
expect(event?.payload.toolInput).toBe('rm -rf build/')
|
|
})
|
|
|
|
it.each(SOURCES)('names the edited file for %s', (source) => {
|
|
// Captured verbatim from opencode 1.18.18 — an `edit` request keys its path as
|
|
// `filepath` (one word) and ships the whole patch alongside it.
|
|
const event = permissionEvent(source, {
|
|
id: 'per_02',
|
|
sessionID: 'ses_root',
|
|
permission: 'edit',
|
|
patterns: ['private/tmp/oc-perm-probe/notes.md'],
|
|
metadata: {
|
|
filepath: '/private/tmp/oc-perm-probe/notes.md',
|
|
diff: '@@ -0,0 +1,1 @@\n+hello world\n\\ No newline at end of file\n'
|
|
},
|
|
always: ['*']
|
|
})
|
|
|
|
expect(event?.payload.toolName).toBe('edit')
|
|
expect(event?.payload.toolInput).toBe('/private/tmp/oc-perm-probe/notes.md')
|
|
})
|
|
|
|
it.each(SOURCES)('never previews a diff as the blocked target for %s', (source) => {
|
|
const event = permissionEvent(source, {
|
|
id: 'per_03',
|
|
sessionID: 'ses_root',
|
|
permission: 'edit',
|
|
patterns: ['src/a.ts'],
|
|
metadata: { diff: '@@ -1 +1 @@\n-before\n+after\n' },
|
|
always: []
|
|
})
|
|
|
|
// Why: `diff` is the largest field OpenCode sends and would swamp a one-line row.
|
|
expect(event?.payload.toolInput).not.toContain('@@')
|
|
})
|
|
|
|
it.each(SOURCES)('names the fetched url for %s', (source) => {
|
|
// Captured verbatim from opencode 1.18.18.
|
|
const event = permissionEvent(source, {
|
|
id: 'per_04',
|
|
sessionID: 'ses_root',
|
|
permission: 'webfetch',
|
|
patterns: ['https://example.com'],
|
|
metadata: { url: 'https://example.com', format: 'markdown' },
|
|
always: ['*']
|
|
})
|
|
|
|
expect(event?.payload.toolName).toBe('webfetch')
|
|
expect(event?.payload.toolInput).toBe('https://example.com')
|
|
})
|
|
|
|
it.each(SOURCES)('falls back to patterns when metadata is unrecognized for %s', (source) => {
|
|
// Why: `metadata` is typed `Record<string, unknown>` by the SDK, so a tool Orca has
|
|
// never seen (an MCP server's, say) can key its detail anything — patterns is the one
|
|
// field every permission carries.
|
|
const event = permissionEvent(source, {
|
|
id: 'per_05',
|
|
sessionID: 'ses_root',
|
|
permission: 'some_mcp_tool',
|
|
patterns: ['src/a.ts', 'src/b.ts'],
|
|
metadata: { unrecognizedKey: 'ignored' },
|
|
always: []
|
|
})
|
|
|
|
expect(event?.payload.toolName).toBe('some_mcp_tool')
|
|
expect(event?.payload.toolInput).toBe('src/a.ts, src/b.ts')
|
|
})
|
|
|
|
it.each(SOURCES)(
|
|
'still reports waiting when the payload names no permission for %s',
|
|
(source) => {
|
|
// Why: an unrecognized/absent permission must not downgrade the blocked state — the
|
|
// user still has to answer, they just get less detail.
|
|
const event = permissionEvent(source, { id: 'per_06', sessionID: 'ses_root' })
|
|
|
|
expect(event?.payload.state).toBe('waiting')
|
|
expect(event?.payload.toolName).toBeUndefined()
|
|
}
|
|
)
|
|
|
|
it.each(SOURCES)('leaves the AskUserQuestion route untouched for %s', (source) => {
|
|
const properties = { questions: [{ question: 'Choose', options: ['x', 'y'] }] }
|
|
const event = normalizeHookPayload(
|
|
state,
|
|
source,
|
|
{
|
|
paneKey: PANE_KEY,
|
|
payload: { hook_event_name: 'AskUserQuestion', ...properties }
|
|
},
|
|
'production'
|
|
)
|
|
|
|
expect(event?.payload.state).toBe('waiting')
|
|
expect(event?.payload.interactivePrompt).toBe(JSON.stringify(properties))
|
|
})
|
|
|
|
it.each(SOURCES)('clears permission metadata once the turn resumes for %s', (source) => {
|
|
permissionEvent(source, BASH_PERMISSION)
|
|
const resumed = lifecycleEvent(source, 'SessionBusy')
|
|
|
|
// Why: a stale approval card must not outlive the approval — once work resumes the
|
|
// row is working again and the blocked command is no longer what the pane is on.
|
|
expect(resumed?.payload.state).toBe('working')
|
|
expect(resumed?.payload.toolName).toBeUndefined()
|
|
expect(resumed?.payload.toolInput).toBeUndefined()
|
|
})
|
|
|
|
it.each(SOURCES)('retires the permission when the session goes idle for %s', (source) => {
|
|
permissionEvent(source, BASH_PERMISSION)
|
|
const idle = lifecycleEvent(source, 'SessionIdle')
|
|
|
|
expect(idle?.payload.state).toBe('done')
|
|
expect(idle?.payload.toolName).toBeUndefined()
|
|
expect(idle?.payload.toolInput).toBeUndefined()
|
|
})
|
|
|
|
it.each(SOURCES)(
|
|
'does not label a later question with the answered permission for %s',
|
|
(source) => {
|
|
permissionEvent(source, BASH_PERMISSION)
|
|
const question = lifecycleEvent(source, 'AskUserQuestion', {
|
|
questions: [{ question: 'Choose', options: ['x', 'y'] }]
|
|
})
|
|
|
|
expect(question?.payload.state).toBe('waiting')
|
|
expect(question?.payload.toolName).toBeUndefined()
|
|
expect(question?.payload.toolInput).toBeUndefined()
|
|
}
|
|
)
|
|
|
|
it.each(SOURCES)('does not attribute the answered permission to a reply for %s', (source) => {
|
|
permissionEvent(source, BASH_PERMISSION)
|
|
const part = lifecycleEvent(source, 'MessagePart', { role: 'assistant', text: 'Ran it.' })
|
|
|
|
expect(part?.payload.lastAssistantMessage).toBe('Ran it.')
|
|
expect(part?.payload.toolName).toBeUndefined()
|
|
expect(part?.payload.toolInput).toBeUndefined()
|
|
})
|
|
|
|
it.each(SOURCES)('does not carry an answered permission into a later wait for %s', (source) => {
|
|
// Why: the row now shows tool fields on 'waiting' as well as 'working' (that is the whole
|
|
// point of STA-3160), so a later non-permission wait must arrive with none — otherwise
|
|
// relaxing the row gate turns an answered command into the question's caption.
|
|
permissionEvent(source, BASH_PERMISSION)
|
|
lifecycleEvent(source, 'SessionBusy')
|
|
lifecycleEvent(source, 'SessionIdle')
|
|
const laterQuestion = lifecycleEvent(source, 'AskUserQuestion', {
|
|
questions: [{ question: 'Which branch?', options: ['main', 'dev'] }]
|
|
})
|
|
|
|
expect(laterQuestion?.payload.state).toBe('waiting')
|
|
expect(laterQuestion?.payload.toolInput).toBeUndefined()
|
|
expect(laterQuestion?.payload.toolName).toBeUndefined()
|
|
})
|
|
|
|
it.each(SOURCES)('does not carry an answered permission into a later turn for %s', (source) => {
|
|
// Why: no isNewTurnEvent boundary fires mid-session for this family, so nothing else
|
|
// resets the cached tool. Without an explicit retire, one permission pins its command to
|
|
// every later working frame in the pane — the exact stale-tool-line the row gate guards against.
|
|
permissionEvent(source, BASH_PERMISSION)
|
|
lifecycleEvent(source, 'SessionBusy')
|
|
lifecycleEvent(source, 'SessionIdle')
|
|
const laterTurn = lifecycleEvent(source, 'SessionBusy')
|
|
|
|
expect(laterTurn?.payload.state).toBe('working')
|
|
// Why: assert the command first — it is the string a user would misread as live work.
|
|
expect(laterTurn?.payload.toolInput).toBeUndefined()
|
|
expect(laterTurn?.payload.toolName).toBeUndefined()
|
|
})
|
|
})
|