diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index 6adfdf23fbc..8b046e0db79 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -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; metadata: Record; 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,', ' }', diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index f5d7ef7eb01..a769bfc74d2 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -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', diff --git a/src/main/pi/agent-status-ui-prompt-source.ts b/src/main/pi/agent-status-ui-prompt-source.ts new file mode 100644 index 00000000000..2f1ed92c9ae --- /dev/null +++ b/src/main/pi/agent-status-ui-prompt-source.ts @@ -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 })", + ' })', + '' + ] +} diff --git a/src/main/pi/agent-status-ui-prompt.test.ts b/src/main/pi/agent-status-ui-prompt.test.ts new file mode 100644 index 00000000000..4ab9341589e --- /dev/null +++ b/src/main/pi/agent-status-ui-prompt.test.ts @@ -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[] = [] + 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 { + // 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, 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((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() + }) +}) diff --git a/src/shared/agent-hook-listener/providers/pi-family-events.ts b/src/shared/agent-hook-listener/providers/pi-family-events.ts index a254ed985d0..d54f2d99163 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-events.ts +++ b/src/shared/agent-hook-listener/providers/pi-family-events.ts @@ -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 } diff --git a/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts b/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts index bb3e3251655..d20b4aedbf7 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts @@ -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, 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' || diff --git a/tests/e2e/pi-ui-prompt-status.spec.ts b/tests/e2e/pi-ui-prompt-status.spec.ts new file mode 100644 index 00000000000..e4b868bd315 --- /dev/null +++ b/tests/e2e/pi-ui-prompt-status.spec.ts @@ -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): Promise { + 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) +}) diff --git a/tests/tools/pi-ui-prompt-cdp-smoke.mjs b/tests/tools/pi-ui-prompt-cdp-smoke.mjs new file mode 100644 index 00000000000..27bf971781f --- /dev/null +++ b/tests/tools/pi-ui-prompt-cdp-smoke.mjs @@ -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() +} diff --git a/tests/tools/pi-ui-prompt-extension.mjs b/tests/tools/pi-ui-prompt-extension.mjs new file mode 100644 index 00000000000..561c361446d --- /dev/null +++ b/tests/tools/pi-ui-prompt-extension.mjs @@ -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'}`) + } + }) +} diff --git a/tests/tools/pi-ui-prompt-runtime-smoke.mjs b/tests/tools/pi-ui-prompt-runtime-smoke.mjs new file mode 100644 index 00000000000..703893c6dca --- /dev/null +++ b/tests/tools/pi-ui-prompt-runtime-smoke.mjs @@ -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 }) +} diff --git a/tests/tools/pi-ui-prompt-verification.md b/tests/tools/pi-ui-prompt-verification.md new file mode 100644 index 00000000000..1aa128cec49 --- /dev/null +++ b/tests/tools/pi-ui-prompt-verification.md @@ -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.