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:
gatsby74
2026-09-07 19:35:19 -07:00
committed by GitHub
co-authored by Neil
parent aeddfa463d
commit a278d84a4e
11 changed files with 625 additions and 4 deletions
+5 -1
View File
@@ -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 })",
' })',
''
]
}
+197
View File
@@ -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' ||
+72
View File
@@ -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)
})
+61
View File
@@ -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()
}
+43
View File
@@ -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'}`)
}
})
}
+155
View File
@@ -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 })
}
+42
View File
@@ -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.