mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(pi): show input modals as waiting instead of working (#18836)
* fix(pi): show input modals as waiting instead of working * test(pi): verify real input dialogs through Electron CDP --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
@@ -101,6 +101,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin
|
||||
'// Orca receiver from building an unbounded queue of obsolete snapshots.',
|
||||
'const HOOK_POST_TIMEOUT_MS = 1000',
|
||||
'let activePost = false',
|
||||
...(kind === 'pi' ? ['let piUiPromptActive = false'] : []),
|
||||
'let pendingPost: { hookEventName: string; extra: Record<string, unknown>; metadata: Record<string, unknown>; ompRuntime: boolean } | null = null',
|
||||
...sessionMetadataSourceLines,
|
||||
'',
|
||||
@@ -164,7 +165,10 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin
|
||||
' const ompRuntime = isOmpRuntime()',
|
||||
' pendingPost = {',
|
||||
' hookEventName,',
|
||||
' extra,',
|
||||
// Why: every coalesced snapshot must retain an open modal, not just its start event.
|
||||
kind === 'pi'
|
||||
? ' extra: { ...extra, ...(!ompRuntime && piUiPromptActive ? { ui_prompt_active: true } : {}) },'
|
||||
: ' extra,',
|
||||
' metadata: getPostSessionMetadata(ompRuntime),',
|
||||
' ompRuntime,',
|
||||
' }',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PiAgentKind } from '../../shared/pi-agent-kind'
|
||||
import { getPiAgentStatusUiPromptHandlerSourceLines } from './agent-status-ui-prompt-source'
|
||||
|
||||
// Why: keep the generated handler registrations separate from hook transport;
|
||||
// both are independently sizeable and the installed extension concatenates them.
|
||||
@@ -131,6 +132,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
' })',
|
||||
'',
|
||||
...approvalHandlers,
|
||||
...getPiAgentStatusUiPromptHandlerSourceLines(kind),
|
||||
" // Why: capture the assistant's final text on each completed message",
|
||||
' // so the dashboard preview reflects the most recent reply even before',
|
||||
' // agent_end fires. message_end is the right hook because pi guarantees',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { PiAgentKind } from '../../shared/pi-agent-kind'
|
||||
|
||||
/** Pi owns nested prompt depth and emits one pair around select/confirm/input/editor/custom. */
|
||||
export function getPiAgentStatusUiPromptHandlerSourceLines(kind: PiAgentKind): string[] {
|
||||
if (kind !== 'pi') {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
" pi.on('ui_prompt_start', () => {",
|
||||
' if (isOmpRuntime()) return',
|
||||
' piUiPromptActive = true',
|
||||
" post('ui_prompt_start')",
|
||||
' })',
|
||||
'',
|
||||
" pi.on('ui_prompt_end', (_event, ctx) => {",
|
||||
' if (isOmpRuntime() || !piUiPromptActive) return',
|
||||
' piUiPromptActive = false',
|
||||
" post('ui_prompt_end', { is_idle: ctx?.isIdle?.() === true })",
|
||||
' })',
|
||||
''
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeHookPayload } from '../../shared/agent-hook-listener'
|
||||
import { PANE_KEY } from '../../shared/agent-hook-listener-test-harness'
|
||||
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
|
||||
import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness'
|
||||
|
||||
const HOOK_ENV = { ORCA_PANE_KEY: PANE_KEY, ORCA_AGENT_HOOK_ENV: 'production' }
|
||||
|
||||
function createHarness() {
|
||||
const state = createHookListenerState()
|
||||
const statuses: ReturnType<typeof normalizeHookPayload>[] = []
|
||||
const harness = createAgentStatusExtensionHarness({
|
||||
kind: 'pi',
|
||||
env: HOOK_ENV,
|
||||
fetchImpl: async (_url, init) => {
|
||||
statuses.push(normalizeHookPayload(state, 'pi', JSON.parse(String(init?.body)), 'production'))
|
||||
return { ok: true }
|
||||
}
|
||||
})
|
||||
return { ...harness, statuses }
|
||||
}
|
||||
|
||||
async function flushPosts(): Promise<void> {
|
||||
// Each delivery has a bounded promise chain; no wall-clock sleeps in the harness.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
async function post(harness: ReturnType<typeof createHarness>, name: string, event = {}) {
|
||||
await harness.callHook(name, event)
|
||||
await flushPosts()
|
||||
}
|
||||
|
||||
describe('Pi UI prompt status', () => {
|
||||
it.each(['select', 'confirm', 'input', 'editor', 'custom'])(
|
||||
'blocks for %s without exposing modal contents and resumes work on close',
|
||||
async (kind) => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'agent_start')
|
||||
await post(harness, 'ui_prompt_start', { kind, title: 'Private title' })
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('waiting')
|
||||
const body = JSON.parse(String(harness.fetchMock.mock.calls.at(-1)?.[1]?.body))
|
||||
expect(body.payload).toEqual({ hook_event_name: 'ui_prompt_start', ui_prompt_active: true })
|
||||
|
||||
await harness.callHook('ui_prompt_end', { kind }, { isIdle: () => false })
|
||||
await flushPosts()
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('working')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
'tool_call',
|
||||
'tool_execution_start',
|
||||
'tool_execution_end',
|
||||
'message_end',
|
||||
'agent_settled'
|
||||
])('%s cannot clear an open modal', async (name) => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await post(harness, name, {
|
||||
toolName: 'ask_user_question',
|
||||
input: { questions: [{ question: 'Stale question' }] },
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Still here' }] }
|
||||
})
|
||||
expect(harness.statuses.at(-1)?.payload).toMatchObject({ state: 'waiting', agentType: 'pi' })
|
||||
expect(harness.statuses.at(-1)?.payload.toolName).toBeUndefined()
|
||||
expect(harness.statuses.at(-1)?.payload.interactivePrompt).toBeUndefined()
|
||||
await harness.callHook('ui_prompt_end', {}, { isIdle: () => true })
|
||||
await flushPosts()
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
it('clears stale question cards when a generic modal opens', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'tool_call', {
|
||||
toolName: 'ask_user_question',
|
||||
input: { questions: [{ question: 'Pick one' }] }
|
||||
})
|
||||
expect(harness.statuses.at(-1)?.payload.interactivePrompt).toBeDefined()
|
||||
await post(harness, 'ui_prompt_start')
|
||||
expect(harness.statuses.at(-1)?.payload.toolName).toBeUndefined()
|
||||
expect(harness.statuses.at(-1)?.payload.toolInput).toBeUndefined()
|
||||
expect(harness.statuses.at(-1)?.payload.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns an idle session to done after its modal closes', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await harness.callHook('ui_prompt_end', {}, { isIdle: () => true })
|
||||
await flushPosts()
|
||||
expect(harness.statuses.map((status) => status?.payload.state)).toEqual(['waiting', 'done'])
|
||||
})
|
||||
|
||||
it('does not infer done when the context cannot establish idleness', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await post(harness, 'ui_prompt_end')
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('working')
|
||||
})
|
||||
|
||||
it('lets the normal settlement hook finish work after a modal closes', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'agent_start')
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await harness.callHook('ui_prompt_end', {}, { isIdle: () => false })
|
||||
await flushPosts()
|
||||
await post(harness, 'agent_settled')
|
||||
expect(harness.statuses.map((status) => status?.payload.state)).toEqual([
|
||||
'working',
|
||||
'waiting',
|
||||
'working',
|
||||
'done'
|
||||
])
|
||||
})
|
||||
|
||||
it('retains modal state across an in-process registration reload', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'ui_prompt_start')
|
||||
harness.reload()
|
||||
await post(harness, 'session_start', { reason: 'reload' })
|
||||
await post(harness, 'tool_execution_end', { toolName: 'bash' })
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('waiting')
|
||||
})
|
||||
|
||||
it('keeps a session-switching modal blocked until it actually closes', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'before_agent_start', { prompt: 'Old session prompt' })
|
||||
await post(harness, 'ui_prompt_start')
|
||||
await post(harness, 'session_start', { reason: 'switch' })
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('waiting')
|
||||
expect(harness.statuses.at(-1)?.payload.prompt).toBe('')
|
||||
await harness.callHook('ui_prompt_end', {}, { isIdle: () => true })
|
||||
await flushPosts()
|
||||
expect(harness.statuses.at(-1)?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
it('ignores an unmatched prompt end', async () => {
|
||||
const harness = createHarness()
|
||||
await post(harness, 'ui_prompt_end')
|
||||
expect(harness.fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isolates prompt state between Pi processes', async () => {
|
||||
const first = createHarness()
|
||||
const second = createHarness()
|
||||
await post(first, 'ui_prompt_start')
|
||||
await post(second, 'agent_start')
|
||||
expect(first.statuses.at(-1)?.payload.state).toBe('waiting')
|
||||
expect(second.statuses.at(-1)?.payload.state).toBe('working')
|
||||
})
|
||||
|
||||
it('preserves blocked when a stalled sender coalesces away the start event', async () => {
|
||||
let finish: (() => void) | undefined
|
||||
const harness = createAgentStatusExtensionHarness({
|
||||
kind: 'pi',
|
||||
env: HOOK_ENV,
|
||||
fetchImpl: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
})
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('ui_prompt_start')
|
||||
await harness.callHook('tool_execution_end', { toolName: 'bash' })
|
||||
expect(harness.fetchMock).toHaveBeenCalledTimes(1)
|
||||
finish?.()
|
||||
await flushPosts()
|
||||
const state = createHookListenerState()
|
||||
const latest = JSON.parse(String(harness.fetchMock.mock.calls.at(-1)?.[1]?.body))
|
||||
expect(latest.payload.hook_event_name).toBe('tool_execution_end')
|
||||
expect(normalizeHookPayload(state, 'pi', latest, 'production')?.payload.state).toBe('waiting')
|
||||
|
||||
await harness.callHook('ui_prompt_end', {}, { isIdle: () => false })
|
||||
await harness.callHook('tool_execution_start', { toolName: 'bash', args: { command: 'pwd' } })
|
||||
finish?.()
|
||||
await flushPosts()
|
||||
const resumed = JSON.parse(String(harness.fetchMock.mock.calls.at(-1)?.[1]?.body))
|
||||
expect(normalizeHookPayload(state, 'pi', resumed, 'production')?.payload.state).toBe('working')
|
||||
finish?.()
|
||||
await flushPosts()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ kind: 'omp' as const },
|
||||
{ kind: 'prime-agent' as const },
|
||||
{ kind: 'pi' as const, title: 'omp' }
|
||||
])('does not add Pi prompt status to $kind ($title)', async (args) => {
|
||||
const harness = createAgentStatusExtensionHarness(args)
|
||||
await harness.callHook('ui_prompt_start')
|
||||
await harness.callHook('ui_prompt_end', {}, { isIdle: () => true })
|
||||
expect(harness.fetchMock).not.toHaveBeenCalled()
|
||||
await harness.callHook('tool_call', { toolName: 'bash', input: { command: 'pwd' } })
|
||||
const body = JSON.parse(String(harness.fetchMock.mock.calls[0]?.[1]?.body))
|
||||
expect(body.payload.ui_prompt_active).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,10 @@ export function normalizePiCompatibleEvent(
|
||||
if (agentType !== 'omp' && eventName === 'session_start') {
|
||||
// Why: Pi's session_start fires on TUI open/resume; discard stale turn details, no working row before user activity.
|
||||
clearPaneTurnCacheState(state, paneKey)
|
||||
return null
|
||||
// Why: a custom modal can switch sessions before its promise resolves.
|
||||
if (agentType !== 'pi' || hookPayload.ui_prompt_active !== true) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: gate on the event's own tool_name so a stale cached question can't re-enter blocked.
|
||||
@@ -30,8 +33,11 @@ export function normalizePiCompatibleEvent(
|
||||
(eventName === 'tool_call' || eventName === 'tool_execution_start')
|
||||
const isOmpApprovalRequest = agentType === 'omp' && eventName === 'tool_approval_requested'
|
||||
const isOmpApprovalResolution = agentType === 'omp' && eventName === 'tool_approval_resolved'
|
||||
const isPiUiPrompt =
|
||||
agentType === 'pi' && (eventName === 'ui_prompt_start' || hookPayload.ui_prompt_active === true)
|
||||
const isPiUiPromptEnd = agentType === 'pi' && eventName === 'ui_prompt_end'
|
||||
|
||||
const stateName =
|
||||
let stateName =
|
||||
isPiCompatibleAsk || isOmpApprovalRequest
|
||||
? 'blocked'
|
||||
: isOmpApprovalResolution ||
|
||||
@@ -46,6 +52,13 @@ export function normalizePiCompatibleEvent(
|
||||
? 'done'
|
||||
: null
|
||||
|
||||
if (isPiUiPrompt) {
|
||||
// Why: waiting uses the same orange question icon as Claude/Codex input prompts.
|
||||
stateName = 'waiting'
|
||||
} else if (isPiUiPromptEnd) {
|
||||
stateName = hookPayload.is_idle === true ? 'done' : 'working'
|
||||
}
|
||||
|
||||
if (!stateName) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ToolSnapshot } from '../listener-event'
|
||||
import { isAskUserQuestionTool } from '../../agent-question-answered-intent'
|
||||
import { deriveToolInputPreview, hasOwnField, readString, toolUpdate } from '../tool-input-preview'
|
||||
import { deriveInteractivePrompt } from '../interactive-tool'
|
||||
import { clearActiveToolFieldsUpdate, deriveInteractivePrompt } from '../interactive-tool'
|
||||
|
||||
/** OMP's `ask` carries the same questions/options payload as Pi's question tool. */
|
||||
function serializeQuestionPrompt(toolInput: unknown): string | undefined {
|
||||
@@ -29,6 +29,15 @@ export function extractPiToolFields(
|
||||
hookPayload: Record<string, unknown>,
|
||||
agentKind: 'pi' | 'omp' | 'prime-agent'
|
||||
): ToolSnapshot {
|
||||
// Why: arbitrary modals are not tool approvals or structured question cards.
|
||||
if (
|
||||
agentKind === 'pi' &&
|
||||
(hookPayload.ui_prompt_active === true ||
|
||||
eventName === 'ui_prompt_start' ||
|
||||
eventName === 'ui_prompt_end')
|
||||
) {
|
||||
return clearActiveToolFieldsUpdate()
|
||||
}
|
||||
if (
|
||||
eventName === 'tool_call' ||
|
||||
eventName === 'tool_execution_start' ||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { readHookEndpoint } from './helpers/agent-hook-endpoint'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
sendToTerminal,
|
||||
waitForActivePaneHookDescriptor,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
|
||||
test('Pi modal hooks show the existing waiting-for-input indicator', async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const endpoint = await readHookEndpoint(electronApp)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const marker = '__PI_MODAL_STATUS_READY__'
|
||||
await sendToTerminal(orcaPage, ptyId, `printf '${marker}\\n'\r`)
|
||||
await waitForTerminalOutput(orcaPage, marker)
|
||||
const { paneKey, worktreeId } = await waitForActivePaneHookDescriptor(orcaPage)
|
||||
|
||||
async function emit(payload: Record<string, unknown>): Promise<void> {
|
||||
const response = await fetch(`http://127.0.0.1:${endpoint.port}/hook/pi`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': endpoint.token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey,
|
||||
tabId: paneKey.split(':')[0],
|
||||
worktreeId,
|
||||
env: endpoint.env,
|
||||
version: endpoint.version,
|
||||
payload
|
||||
})
|
||||
})
|
||||
expect(response.status).toBe(204)
|
||||
}
|
||||
|
||||
// Terminal tabs present both waiting and blocked as "Needs attention".
|
||||
const waiting = orcaPage.locator('[aria-label="Needs attention"]')
|
||||
await emit({ hook_event_name: 'before_agent_start', prompt: 'Pi modal status check' })
|
||||
await expect(orcaPage.locator('[aria-label="Working"]').first()).toBeVisible()
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('before-working.png') })
|
||||
|
||||
await emit({ hook_event_name: 'ui_prompt_start', ui_prompt_active: true })
|
||||
await expect
|
||||
.poll(() =>
|
||||
orcaPage.evaluate(
|
||||
(key) => window.__store?.getState().agentStatusByPaneKey[key]?.state,
|
||||
paneKey
|
||||
)
|
||||
)
|
||||
.toBe('waiting')
|
||||
await expect(waiting.first()).toBeVisible()
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('after-waiting.png') })
|
||||
await emit({ hook_event_name: 'tool_execution_end', tool_name: 'bash', ui_prompt_active: true })
|
||||
await expect(waiting.first()).toBeVisible()
|
||||
|
||||
await emit({ hook_event_name: 'ui_prompt_end', is_idle: false })
|
||||
await expect(waiting).toHaveCount(0)
|
||||
await expect(orcaPage.locator('[aria-label="Working"]').first()).toBeVisible()
|
||||
await emit({ hook_event_name: 'agent_end' })
|
||||
await expect(orcaPage.locator('[aria-label="Working"]')).toHaveCount(0)
|
||||
await expect(waiting).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// Run against an isolated Orca dev instance with Pi and pi-ui-prompt-extension.mjs loaded.
|
||||
// Usage: node tests/tools/pi-ui-prompt-cdp-smoke.mjs http://127.0.0.1:9333 /path/to/proof
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { chromium, expect } from '@stablyai/playwright-test'
|
||||
|
||||
const [endpoint, outputDirectory] = process.argv.slice(2)
|
||||
assert.ok(endpoint && outputDirectory, 'Pass the CDP endpoint and screenshot directory')
|
||||
const output = resolve(outputDirectory)
|
||||
await mkdir(output, { recursive: true })
|
||||
const browser = await chromium.connectOverCDP(endpoint)
|
||||
try {
|
||||
const page = browser.contexts().flatMap((context) => context.pages())[0]
|
||||
assert.ok(page, 'Orca renderer must be open')
|
||||
const identity = await page.evaluate(() => window.api.app.getIdentity())
|
||||
assert.equal(identity.isDev, true, 'Use an isolated development instance')
|
||||
console.log(JSON.stringify(identity))
|
||||
const terminals = page.locator('[data-pty-id]')
|
||||
await expect(terminals).toHaveCount(1)
|
||||
const terminal = terminals.first()
|
||||
const input = page.getByRole('textbox', { name: 'Terminal input' })
|
||||
const attention = page.getByLabel('Needs attention', { exact: true })
|
||||
const waitForState = (state) =>
|
||||
expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
Object.values(window.__store.getState().agentStatusByPaneKey)
|
||||
.filter((entry) => entry.agentType === 'pi')
|
||||
.map((entry) => entry.state)
|
||||
)
|
||||
)
|
||||
.toEqual([state])
|
||||
|
||||
for (const kind of ['select', 'confirm', 'input', 'editor', 'custom']) {
|
||||
for (const ending of kind === 'select' ? ['answer', 'cancel'] : ['cancel']) {
|
||||
await input.pressSequentially(`/orca-modal ${kind}`, { delay: 10 })
|
||||
await input.press('Enter')
|
||||
await waitForState('waiting')
|
||||
await expect(attention).toBeVisible()
|
||||
await expect(terminal).toBeVisible()
|
||||
await page.screenshot({ path: join(output, `${kind}-${ending}-waiting.png`) })
|
||||
await (kind === 'custom'
|
||||
? page.evaluate(() => {
|
||||
const id = document.querySelector('[data-pty-id]')?.getAttribute('data-pty-id')
|
||||
if (!id) {
|
||||
throw new Error('Terminal lost its PTY')
|
||||
}
|
||||
window.api.pty.write(id, '\u001b')
|
||||
})
|
||||
: input.press(ending === 'answer' ? 'Enter' : 'Escape'))
|
||||
await waitForState('done')
|
||||
await expect(attention).toHaveCount(0)
|
||||
await expect(page.getByLabel('Done', { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: join(output, `${kind}-${ending}-done.png`) })
|
||||
console.log(`PASS: ${kind}/${ending}: waiting -> done, visible icon agrees`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Load with Pi's -e flag; /orca-modal exercises real dialogs without a model or API key.
|
||||
export default function (pi) {
|
||||
pi.registerCommand('orca-modal', {
|
||||
description: 'Verify Orca status: select, confirm, input, editor, or custom',
|
||||
handler: async (args, ctx) => {
|
||||
const kind = args.trim() || 'select'
|
||||
const title = `Orca verification: ${kind}`
|
||||
let answer
|
||||
switch (kind) {
|
||||
case 'select':
|
||||
answer = await ctx.ui.select(title, ['Continue verification', 'Second option'])
|
||||
break
|
||||
case 'confirm':
|
||||
answer = await ctx.ui.confirm(title, 'Continue verification?')
|
||||
break
|
||||
case 'input':
|
||||
answer = await ctx.ui.input(title, 'Type a test answer')
|
||||
break
|
||||
case 'editor':
|
||||
answer = await ctx.ui.editor(title, 'Test answer')
|
||||
break
|
||||
case 'custom':
|
||||
answer = await ctx.ui.custom((_tui, _theme, keys, done) => ({
|
||||
render: () => [title, 'Press Enter to answer or Escape to cancel.'],
|
||||
invalidate() {},
|
||||
handleInput: (data) => {
|
||||
if (keys.matches(data, 'tui.select.confirm')) {
|
||||
done('answered')
|
||||
}
|
||||
if (keys.matches(data, 'tui.select.cancel')) {
|
||||
done(undefined)
|
||||
}
|
||||
}
|
||||
}))
|
||||
break
|
||||
default:
|
||||
ctx.ui.notify('Use select, confirm, input, editor, or custom', 'error')
|
||||
return
|
||||
}
|
||||
ctx.ui.notify(`Orca verification: ${kind} ${answer === undefined ? 'cancelled' : 'answered'}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Run with: node tests/tools/pi-ui-prompt-runtime-smoke.mjs /path/to/pi-coding-agent
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { build } from 'esbuild'
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const piRoot = process.argv[2]
|
||||
assert.ok(piRoot, 'Pass the installed pi-coding-agent package directory (Pi >= 0.84.4)')
|
||||
const cwd = process.cwd()
|
||||
const require = createRequire(join(cwd, 'package.json'))
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'orca-pi-ui-prompt-'))
|
||||
|
||||
try {
|
||||
const bundle = join(scratch, 'orca-status.cjs')
|
||||
await build({
|
||||
stdin: {
|
||||
contents: [
|
||||
"export { getPiAgentStatusExtensionSource } from './src/main/pi/agent-status-extension-source';",
|
||||
"export { normalizeHookPayload } from './src/shared/agent-hook-listener';",
|
||||
"export { createHookListenerState } from './src/shared/agent-hook-listener/listener-state';"
|
||||
].join('\n'),
|
||||
resolveDir: cwd
|
||||
},
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
outfile: bundle,
|
||||
packages: 'external'
|
||||
})
|
||||
const {
|
||||
getPiAgentStatusExtensionSource,
|
||||
normalizeHookPayload,
|
||||
createHookListenerState
|
||||
} = require(bundle)
|
||||
const { ExtensionRunner } = await import(
|
||||
pathToFileURL(resolve(piRoot, 'dist/core/extensions/runner.js')).href
|
||||
)
|
||||
const handlers = new Map()
|
||||
const state = createHookListenerState()
|
||||
const snapshots = []
|
||||
const errors = []
|
||||
const module = { exports: {} }
|
||||
const source = ts.transpileModule(getPiAgentStatusExtensionSource('pi'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
|
||||
}).outputText
|
||||
runInNewContext(source, {
|
||||
module,
|
||||
exports: module.exports,
|
||||
require,
|
||||
process: {
|
||||
pid: 4242,
|
||||
title: 'pi',
|
||||
argv: ['node', 'pi'],
|
||||
env: {
|
||||
ORCA_PANE_KEY: 'tab-1:11111111-1111-4111-8111-111111111111',
|
||||
ORCA_AGENT_HOOK_PORT: '4321',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'test',
|
||||
ORCA_AGENT_HOOK_ENV: 'production'
|
||||
}
|
||||
},
|
||||
fetch: async (_url, init) => {
|
||||
const result = normalizeHookPayload(state, 'pi', JSON.parse(init.body), 'production')
|
||||
snapshots.push(result?.payload)
|
||||
return { ok: true }
|
||||
},
|
||||
console,
|
||||
Promise,
|
||||
Buffer,
|
||||
URL,
|
||||
AbortController,
|
||||
setTimeout,
|
||||
clearTimeout
|
||||
})
|
||||
module.exports.default({ on: (name, handler) => handlers.set(name, [handler]) })
|
||||
const runner = new ExtensionRunner([{ path: 'orca-status', handlers }], {}, cwd, {}, {})
|
||||
runner.onError((error) => errors.push(error))
|
||||
let idle = false
|
||||
runner.isIdleFn = () => idle
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 80; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
const last = () => snapshots.at(-1)?.state
|
||||
let checks = 0
|
||||
|
||||
// Only UI promises are controlled; the real Pi runner must produce the lifecycle events.
|
||||
for (const kind of ['select', 'confirm', 'input', 'editor', 'custom']) {
|
||||
for (const ending of ['answer', 'cancel', 'error']) {
|
||||
for (const wasIdle of [false, true]) {
|
||||
idle = wasIdle
|
||||
let finish, fail
|
||||
const pending = new Promise((yes, no) => {
|
||||
finish = yes
|
||||
fail = no
|
||||
})
|
||||
runner.setUIContext({ [kind]: () => pending }, 'interactive')
|
||||
const promise = runner.getUIContext()[kind]('Sensitive title', [], {})
|
||||
const observed = promise.catch(() => undefined)
|
||||
await flush()
|
||||
assert.equal(last(), 'waiting', `${kind}/${ending}/idle=${idle}: start`)
|
||||
await runner.emit({ type: 'tool_execution_end', toolName: 'bash' })
|
||||
await flush()
|
||||
assert.equal(last(), 'waiting', 'Unrelated work must not clear the modal')
|
||||
if (ending === 'error') {
|
||||
fail(new Error('UI fixture failure'))
|
||||
} else {
|
||||
finish(ending === 'cancel' ? undefined : 'answer')
|
||||
}
|
||||
await observed
|
||||
await flush()
|
||||
assert.equal(last(), idle ? 'done' : 'working', `${kind}/${ending}/idle=${idle}: end`)
|
||||
checks++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finishA, finishB
|
||||
runner.setUIContext(
|
||||
{
|
||||
custom: () =>
|
||||
new Promise((done) => {
|
||||
finishA = done
|
||||
}),
|
||||
input: () =>
|
||||
new Promise((done) => {
|
||||
finishB = done
|
||||
})
|
||||
},
|
||||
'interactive'
|
||||
)
|
||||
const a = runner.getUIContext().custom(() => {})
|
||||
const b = runner.getUIContext().input('Input')
|
||||
await flush()
|
||||
assert.equal(last(), 'waiting')
|
||||
finishA()
|
||||
await a
|
||||
await flush()
|
||||
assert.equal(last(), 'waiting', 'The remaining prompt still needs input')
|
||||
finishB()
|
||||
await b
|
||||
await flush()
|
||||
assert.equal(last(), 'done')
|
||||
checks++
|
||||
assert.deepEqual(errors, [])
|
||||
const { version } = JSON.parse(await readFile(resolve(piRoot, 'package.json'), 'utf8'))
|
||||
console.log(`PASS: Pi ${version}, ${checks} scenarios, ${snapshots.length} status snapshots`)
|
||||
} finally {
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Real Pi dialog verification
|
||||
|
||||
Use Pi 0.84.4 or newer. Older Pi does not emit `ui_prompt_start` / `ui_prompt_end`.
|
||||
The checked-in extension only opens dialogs; it does not call a model or send synthetic
|
||||
Orca hook events.
|
||||
|
||||
1. Launch an isolated Orca development instance with CDP using the Electron skill.
|
||||
2. Open one terminal in a git worktree or folder workspace. Start Pi with Orca's
|
||||
generated status extension and this additional extension:
|
||||
|
||||
```sh
|
||||
pi --offline --no-session -e /absolute/path/to/orca/tests/tools/pi-ui-prompt-extension.mjs
|
||||
```
|
||||
|
||||
If launching Pi directly through `node` or disabling extension discovery, explicitly
|
||||
load Orca's generated `orca-agent-status.ts` with another `-e` argument.
|
||||
|
||||
3. Leave Pi at its input editor, then run from the Orca repository:
|
||||
|
||||
```sh
|
||||
node tests/tools/pi-ui-prompt-cdp-smoke.mjs http://127.0.0.1:9333 /path/to/proof
|
||||
```
|
||||
|
||||
The smoke check requires one terminal and one Pi status entry in the isolated instance.
|
||||
It opens all five real Pi dialogs, answers the selector, and cancels each dialog.
|
||||
It asserts backend `waiting` plus the terminal tab's visible **Needs attention** icon,
|
||||
then backend `done` plus the visible completion icon. Screenshots are saved for both
|
||||
states. Custom-dialog cancellation sends a plain Escape through the real PTY;
|
||||
the standard dialogs use browser keyboard events.
|
||||
|
||||
For manual verification, run `/orca-modal select`, `/orca-modal confirm`,
|
||||
`/orca-modal input`, `/orca-modal editor`, or `/orca-modal custom` inside Pi.
|
||||
|
||||
The separate runtime test covers active-agent close (`working`), idle close (`done`),
|
||||
overlap, unrelated tool events, and rejected dialog promises using Pi's actual runner:
|
||||
|
||||
```sh
|
||||
node tests/tools/pi-ui-prompt-runtime-smoke.mjs /path/to/installed/pi-coding-agent
|
||||
```
|
||||
|
||||
These local checks do not prove live SSH/network-failure behavior, Windows/WSL,
|
||||
mobile rendering, or startup selectors created before Pi's extension runner exists.
|
||||
Reference in New Issue
Block a user