diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts index 865bfdbbf87..cd4dbda6ee8 100644 --- a/mobile/src/session/mobile-native-chat-permission.ts +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -1,5 +1,6 @@ import { parseNativeChatApprovalEnvelope } from '../../../src/shared/native-chat-approval-envelope' import type { AgentJournalApprovalSubject } from '../../../src/shared/agent-session-journal-types' +import { parseOmpPlanReviewPrompt } from '../../../src/shared/omp-plan-review-prompt' // Agent permission asks (e.g. Claude/Codex "Do you want to proceed?") surface // as plain TUI text in the agent's last assistant message — there is no @@ -24,6 +25,21 @@ export type MobileChatPermission = { options: Array<{ label: string; send: string }> } +/** OMP's plan review envelope: exact agent-emitted options, so no heuristic may replace it. */ +export function parseOmpPlanReviewFromStatus( + interactivePrompt: string | undefined | null +): MobileChatPermission | null { + const planReview = parseOmpPlanReviewPrompt(interactivePrompt) + return planReview + ? { + title: 'Review proposed plan', + description: 'Refine plan closes the review so you can send what to change.', + subject: { kind: 'plan', text: planReview.plan }, + options: planReview.options + } + : null +} + /** Parse the live `agentStatus.interactivePrompt` approval envelope * (`{ approval: { tool, summary } }`, emitted by the host on a PermissionRequest) * into an Allow/Deny card. This is the reliable, agent-emitted signal — unlike diff --git a/mobile/src/session/use-mobile-native-chat-prompts.test.ts b/mobile/src/session/use-mobile-native-chat-prompts.test.ts index 23f027e662e..968d865d6cc 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.test.ts @@ -3,6 +3,7 @@ import TestRenderer from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { buildOmpPlanReviewAnswer } from '../../../src/shared/omp-plan-review-prompt' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' const APPROVAL = JSON.stringify({ @@ -39,6 +40,29 @@ function permissionFor(status: Partial | null): unknown { } describe('useMobileNativeChatPrompts approval-envelope state gate', () => { + it('keeps the OMP plan review card authoritative over approval-sounding prose', () => { + const planReview = JSON.stringify({ + planReview: { + reviewId: 'review-1', + plan: '# Plan', + options: [{ id: 'execute', label: 'Approve and execute' }] + } + }) + expect( + permissionFor({ + state: 'blocked', + agentType: 'omp', + interactivePrompt: planReview, + lastAssistantMessage: 'Please approve this plan.\n1. Yes\n2. No' + }) + ).toMatchObject({ + subject: { kind: 'plan', text: '# Plan' }, + options: [ + { label: 'Approve and execute', send: buildOmpPlanReviewAnswer('review-1', 'execute') } + ] + }) + }) + it.each(['opencode', 'opencode2'])( 'keeps the %s approval event authoritative over numbered assistant prose', (agentType) => { diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 026d3a7f3bc..13d6561bcc2 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -3,7 +3,11 @@ import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import { parseAskFromStatus, resolveNativeChatAsk } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { resolveNativeChatTranscriptAgent } from '../../../src/shared/native-chat-agent-support' -import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' +import { + detectAgentPermission, + parseApprovalFromStatus, + parseOmpPlanReviewFromStatus +} from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' import { useMobileStructuredAsyncQuestion, @@ -19,6 +23,25 @@ export type MobileNativeChatPrompts = { structuredAnswer: ReturnType['answer'] } +/** Agent-emitted envelopes carry exact options, so they win over the text heuristic. */ +function resolveStatusPermission(status: AgentStatusEntry): MobileNativeChatPrompts['permission'] { + const planReview = parseOmpPlanReviewFromStatus(status.interactivePrompt) + if (planReview) { + return planReview + } + if (resolveNativeChatTranscriptAgent(status.agentType) === 'opencode') { + return parseApprovalFromStatus(status.interactivePrompt, status.agentType) + } + return ( + detectAgentPermission({ + state: status.state, + lastAssistantMessage: status.lastAssistantMessage, + toolName: status.toolName, + toolInput: status.toolInput + }) ?? parseApprovalFromStatus(status.interactivePrompt, status.agentType) + ) +} + /** Derives the prompt cards shown above the composer. */ export function useMobileNativeChatPrompts(args: { enabled: boolean @@ -32,21 +55,10 @@ export function useMobileNativeChatPrompts(args: { }): MobileNativeChatPrompts { const { enabled, structured = null, status, messages, transcriptLoading } = args const blocked = status?.state === 'waiting' || status?.state === 'blocked' - const openCode = resolveNativeChatTranscriptAgent(status?.agentType) === 'opencode' // Both permission paths sit inside the paused gate: an approval envelope can // outlive its answer (the host keeps it sticky), so only a waiting/blocked // agent may surface it — never a working or done one (STA-3144). - const permission = - blocked && status - ? openCode - ? parseApprovalFromStatus(status.interactivePrompt, status.agentType) - : (detectAgentPermission({ - state: status.state, - lastAssistantMessage: status.lastAssistantMessage, - toolName: status.toolName, - toolInput: status.toolInput - }) ?? parseApprovalFromStatus(status.interactivePrompt, status.agentType)) - : null + const permission = blocked && status ? resolveStatusPermission(status) : null const question = blocked && status && !permission ? parseAgentQuestion(status.lastAssistantMessage ?? '') : null const askFromStatus = useMemo( diff --git a/src/main/pi/agent-status-extension-test-harness.ts b/src/main/pi/agent-status-extension-test-harness.ts index 5e4f487f833..feb90b854f6 100644 --- a/src/main/pi/agent-status-extension-test-harness.ts +++ b/src/main/pi/agent-status-extension-test-harness.ts @@ -8,8 +8,13 @@ import { getPiAgentStatusExtensionSource } from './agent-status-extension-source export type HookContext = { hasUI?: boolean - ui?: { setEditorText?: (text: string) => void; notify?: (message: string, level: string) => void } + ui?: { + setEditorText?: (text: string) => void + notify?: (message: string, level: string) => void + onTerminalInput?: (handler: (data: string) => { consume?: boolean } | undefined) => () => void + } isIdle?: () => boolean + resolvePlanReview?: (reviewId: string, choice: string) => boolean model?: { provider?: unknown; id?: unknown } | null modelRegistry?: { getAvailable: () => { provider: string; id: string }[] } sessionManager?: { diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index af2735159fe..2b5bf3f8f4b 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -3,6 +3,7 @@ import { getPiPrefillHandlerSourceLines } from './prefill-extension-source' import { getAgentStatusInputRedactionSourceLines } from './agent-status-input-redaction-source' import type { PiAgentKind } from '../../shared/pi-agent-kind' import { getOmpSessionOwnerHandlerSourceLines } from './omp-session-status-owner-source' +import { getOmpPlanReviewHandlerSourceLines } from './omp-plan-review-source' import { getPiAgentStatusUiPromptHandlerSourceLines } from './agent-status-ui-prompt-source' import { getPiSubagentRosterEventSourceLines, @@ -190,6 +191,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' })', '', ...approvalHandlers, + ...(kind === 'prime-agent' ? [] : getOmpPlanReviewHandlerSourceLines()), ...getPiAgentStatusUiPromptHandlerSourceLines(kind), ...modelSelectHandler, " // Why: capture the assistant's final text on each completed message", diff --git a/src/main/pi/agent-status-omp-plan-review.test.ts b/src/main/pi/agent-status-omp-plan-review.test.ts new file mode 100644 index 00000000000..93fd73a3fca --- /dev/null +++ b/src/main/pi/agent-status-omp-plan-review.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' + +import { buildOmpPlanReviewAnswer } from '../../shared/omp-plan-review-prompt' +import { + createAgentStatusExtensionHarness, + type HookContext +} from './agent-status-extension-test-harness' + +// Shape captured from omp 18.12.2's plan_review_requested. +const REVIEW_ID = '86949d10-20aa-46d4-9359-4e1e71467d8f' +const PLAN_REVIEW_REQUEST = { + type: 'plan_review_requested', + reviewId: REVIEW_ID, + sessionId: 'session-1', + title: 'Add-a-greeting', + planFilePath: 'local://demo-plan.md', + planContent: '# Add a greeting\n\n1. Create hello.txt\n', + options: [ + { id: 'execute', label: 'Approve and execute', disabled: false }, + { id: 'keep', label: 'Approve and keep context (~7.1k / 8.2k)', disabled: true }, + { id: 'refine', label: 'Refine plan', disabled: false } + ] +} + +function createPlanReviewContext(): { + ctx: HookContext + resolvePlanReview: ReturnType + readInput: (data: string) => { consume?: boolean; data?: string } | undefined +} { + let listener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null + const resolvePlanReview = vi.fn(() => true) + return { + ctx: { + isIdle: () => true, + resolvePlanReview, + ui: { + onTerminalInput: (handler) => { + listener = handler + return () => { + listener = null + } + } + } + }, + resolvePlanReview, + readInput: (data) => listener?.(data) + } +} + +describe('OMP plan review forwarding', () => { + it('posts the open review and answers it from the terminal input Orca writes', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const { ctx, resolvePlanReview, readInput } = createPlanReviewContext() + + await harness.callHook('plan_review_requested', PLAN_REVIEW_REQUEST, ctx) + expect(readInput('x')).toBeUndefined() + expect(readInput(buildOmpPlanReviewAnswer('stale-review', 'execute'))).toEqual({ + consume: true + }) + expect(resolvePlanReview).not.toHaveBeenCalled() + expect(readInput(buildOmpPlanReviewAnswer(REVIEW_ID, 'execute'))).toEqual({ consume: true }) + expect(resolvePlanReview).toHaveBeenCalledWith(REVIEW_ID, 'execute') + + await harness.callHook( + 'plan_review_resolved', + { reviewId: REVIEW_ID, choice: 'execute', by: 'extension' }, + ctx + ) + expect(readInput(buildOmpPlanReviewAnswer(REVIEW_ID, 'execute'))).toBeUndefined() + + await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(2)) + expect( + harness.fetchMock.mock.calls.map(([, init]) => JSON.parse(String(init?.body)).payload) + ).toEqual([ + expect.objectContaining({ + hook_event_name: 'plan_review_requested', + review_id: REVIEW_ID, + plan: PLAN_REVIEW_REQUEST.planContent, + options: [ + { id: 'execute', label: 'Approve and execute' }, + { id: 'refine', label: 'Refine plan' } + ] + }), + expect.objectContaining({ hook_event_name: 'plan_review_resolved', is_idle: true }) + ]) + }) + + it('keeps the replacement review answerable when the replaced one reports its close', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const { ctx, resolvePlanReview, readInput } = createPlanReviewContext() + + await harness.callHook( + 'plan_review_requested', + { ...PLAN_REVIEW_REQUEST, reviewId: 'old' }, + ctx + ) + await harness.callHook('plan_review_requested', PLAN_REVIEW_REQUEST, ctx) + await harness.callHook('plan_review_resolved', { reviewId: 'old', choice: undefined }, ctx) + + readInput(buildOmpPlanReviewAnswer(REVIEW_ID, 'save')) + expect(resolvePlanReview).toHaveBeenCalledWith(REVIEW_ID, 'save') + }) + + it('closes only the open review when its card asks to refine', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const { ctx, resolvePlanReview, readInput } = createPlanReviewContext() + + await harness.callHook('plan_review_requested', PLAN_REVIEW_REQUEST, ctx) + + expect(readInput(buildOmpPlanReviewAnswer('replaced-review', 'dismiss'))).toEqual({ + consume: true + }) + expect(readInput(buildOmpPlanReviewAnswer(REVIEW_ID, 'dismiss'))).toEqual({ data: '\x1b' }) + expect(resolvePlanReview).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pi/omp-plan-review-source.ts b/src/main/pi/omp-plan-review-source.ts new file mode 100644 index 00000000000..c6f01de87b0 --- /dev/null +++ b/src/main/pi/omp-plan-review-source.ts @@ -0,0 +1,52 @@ +import { + OMP_PLAN_REVIEW_ANSWER_PREFIX, + OMP_PLAN_REVIEW_DISMISS +} from '../../shared/omp-plan-review-prompt' + +/** OMP 18.12+ reports its plan review overlay; older builds never fire these events. */ +export function getOmpPlanReviewHandlerSourceLines(): string[] { + return [ + ` const OMP_PLAN_REVIEW_ANSWER_PREFIX = ${JSON.stringify(OMP_PLAN_REVIEW_ANSWER_PREFIX)}`, + ` const OMP_PLAN_REVIEW_DISMISS = ${JSON.stringify(OMP_PLAN_REVIEW_DISMISS)}`, + ' let ompPlanReview: { reviewId: string; stopInput: () => void } | null = null', + '', + " onStatus('plan_review_requested', (event, ctx) => {", + ' updateRuntimeOmpSessionMetadata(ctx)', + ' ompPlanReview?.stopInput()', + ' ompPlanReview = null', + " if (!isOmpRuntime() || typeof ctx?.resolvePlanReview !== 'function' || typeof ctx?.ui?.onTerminalInput !== 'function') return", + ' const reviewId = event.reviewId', + // Why: the overlay owns the keyboard; input listeners see each whole OSC before it does. + ' const stopInput = ctx.ui.onTerminalInput((data) => {', + " if (typeof data !== 'string' || !data.startsWith(OMP_PLAN_REVIEW_ANSWER_PREFIX)) return undefined", + " const [answerId, choice] = data.slice(OMP_PLAN_REVIEW_ANSWER_PREFIX.length).replace(/\\x07$/, '').split(';')", + // Why: a card drawn for a replaced review must not answer or dismiss this one. + ' if (answerId !== reviewId) return { consume: true }', + // Why: OMP has no dismiss API; the overlay closes on its own cancel key. + " if (choice === OMP_PLAN_REVIEW_DISMISS) return { data: '\\x1b' }", + ' ctx.resolvePlanReview(reviewId, choice)', + ' return { consume: true }', + ' })', + ' ompPlanReview = { reviewId, stopInput }', + " post('plan_review_requested', {", + ' review_id: reviewId,', + ' plan: event.planContent,', + ' options: (event.options ?? []).filter((option) => !option.disabled).map((option) => ({ id: option.id, label: option.label })),', + ' })', + ' })', + '', + " onStatus('plan_review_resolved', (event, ctx) => {", + ' updateRuntimeOmpSessionMetadata(ctx)', + // Why: a replaced review reports its close after the replacement opened. + ' if (!ompPlanReview || ompPlanReview.reviewId !== event.reviewId) return', + ' ompPlanReview.stopInput()', + ' ompPlanReview = null', + ' let isIdle = true', + ' try {', + " if (typeof ctx?.isIdle === 'function') isIdle = ctx.isIdle() === true", + ' } catch {}', + " post('plan_review_resolved', { is_idle: isIdle })", + ' })', + '' + ] +} diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts index ea9fa1d96cf..57f9ad0e632 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts @@ -18,6 +18,7 @@ import { type AskQuestion, type InteractiveQuestionParser } from '../../../../shared/native-chat-ask' +import { parseOmpPlanReviewPrompt } from '../../../../shared/omp-plan-review-prompt' export { buildAskAnswerKeys, @@ -69,6 +70,18 @@ export function parseApprovalFromStatus( interactivePrompt: string | undefined | null, agent?: string ): ChatApproval | null { + const planReview = parseOmpPlanReviewPrompt(interactivePrompt) + if (planReview) { + return { + title: translate('components.native-chat.approval.plan.reviewTitle', 'Review proposed plan'), + description: translate( + 'components.native-chat.approval.plan.refineHint', + 'Refine plan closes the review so you can send what to change.' + ), + subject: { kind: 'plan', text: planReview.plan }, + options: planReview.options + } + } const envelope = parseNativeChatApprovalEnvelope(interactivePrompt, agent) if (!envelope) { return null diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8cc6cb4e4b5..5602da57d26 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19293,7 +19293,9 @@ "needsAccess": "Needs access to", "needsNewerOrca": "This request needs a newer version of Orca.", "plan": { - "file": "Plan file" + "file": "Plan file", + "reviewTitle": "Review proposed plan", + "refineHint": "Refine plan closes the review so you can send what to change." } }, "launchPromptNotDelivered": "Not delivered — check the terminal", diff --git a/src/shared/agent-hook-listener-omp-plan-review.test.ts b/src/shared/agent-hook-listener-omp-plan-review.test.ts new file mode 100644 index 00000000000..c0e5f362f28 --- /dev/null +++ b/src/shared/agent-hook-listener-omp-plan-review.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + createHookListenerState, + seedLegacyAgentStatusForTests, + type HookListenerState +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' +import { PANE_KEY } from './agent-hook-listener-test-harness' +import { AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH } from './agent-status-types' +import { buildOmpPlanReviewAnswer, parseOmpPlanReviewPrompt } from './omp-plan-review-prompt' + +const REVIEW_ID = '86949d10-20aa-46d4-9359-4e1e71467d8f' + +function post(state: HookListenerState, payload: Record) { + return normalizeHookPayload(state, 'omp', { paneKey: PANE_KEY, payload }, 'production')?.payload +} + +function requestReview(state: HookListenerState, plan: string) { + return post(state, { + hook_event_name: 'plan_review_requested', + review_id: REVIEW_ID, + plan, + options: [ + { id: 'execute', label: 'Approve and execute' }, + { id: 'refine', label: 'Refine plan' }, + { id: 'save', label: 'Save and quit' } + ] + }) +} + +describe('OMP plan review status', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + it('blocks on the review with a plan card whose choices reach the extension', () => { + const blocked = requestReview(state, '# Add a greeting') + + expect(blocked?.state).toBe('blocked') + expect(parseOmpPlanReviewPrompt(blocked?.interactivePrompt)).toEqual({ + reviewId: REVIEW_ID, + plan: '# Add a greeting', + options: [ + { label: 'Approve and execute', send: buildOmpPlanReviewAnswer(REVIEW_ID, 'execute') }, + { label: 'Refine plan', send: buildOmpPlanReviewAnswer(REVIEW_ID, 'dismiss') }, + { label: 'Save and quit', send: buildOmpPlanReviewAnswer(REVIEW_ID, 'save') } + ] + }) + }) + + it('clips a long plan so the card still parses', () => { + const blocked = requestReview(state, `"quoted"\n${'x'.repeat(40_000)}`) + + expect(blocked?.interactivePrompt?.length).toBeLessThanOrEqual( + AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH + ) + expect(parseOmpPlanReviewPrompt(blocked?.interactivePrompt)?.plan).toMatch( + /^"quoted"\nx+\n\n…$/ + ) + }) + + it.each([ + [true, 'done'], + [false, 'working'] + ])('ends the review wait when OMP closes it (idle %s)', (isIdle, expected) => { + requestReview(state, '# Plan') + const resolved = post(state, { hook_event_name: 'plan_review_resolved', is_idle: isIdle }) + + expect(resolved?.state).toBe(expected) + expect(resolved?.interactivePrompt).toBeUndefined() + }) + + it("keeps the card when an earlier turn's last async child ends under the review", () => { + const deliver = (payload: Record) => { + const event = normalizeHookPayload(state, 'omp', { paneKey: PANE_KEY, payload }, 'production') + if (event) { + seedLegacyAgentStatusForTests(state, event) + } + return state.lastStatusByPaneKey.get(PANE_KEY)?.payload + } + deliver({ + hook_event_name: 'plan_review_requested', + review_id: REVIEW_ID, + plan: '# Plan', + options: [{ id: 'execute', label: 'Approve and execute' }] + }) + + const held = deliver({ hook_event_name: 'agent_end' }) + expect(held?.state).toBe('blocked') + expect(parseOmpPlanReviewPrompt(held?.interactivePrompt)?.reviewId).toBe(REVIEW_ID) + expect(deliver({ hook_event_name: 'agent_end', session_boundary: true })?.state).toBe('done') + }) +}) 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 bf2acdc89ef..d7bf5a4b43d 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-events.ts +++ b/src/shared/agent-hook-listener/providers/pi-family-events.ts @@ -7,6 +7,7 @@ import { clearPaneTurnCacheState, type HookListenerState } from '../listener-sta import { resolvePrompt, resolveToolState } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import { readString } from '../tool-input-preview' +import { parseOmpPlanReviewPrompt } from '../../omp-plan-review-prompt' /** Maps a Pi-family hook event (Pi, OMP, Prime) onto a pane status: lifecycle * events become `working` / `done`, an ask tool becomes `blocked`, and OMP's @@ -67,12 +68,14 @@ export function normalizePiCompatibleEvent( // no value of it can downgrade this to working without hiding a real prompt. const isOmpApprovalRequest = agentType === 'omp' && eventName === 'tool_approval_requested' const isOmpApprovalResolution = agentType === 'omp' && eventName === 'tool_approval_resolved' + const isOmpPlanReview = agentType === 'omp' && eventName === 'plan_review_requested' + const isOmpPlanReviewEnd = agentType === 'omp' && eventName === 'plan_review_resolved' const isPiUiPrompt = agentType === 'pi' && (eventName === 'ui_prompt_start' || hookPayload.ui_prompt_active === true) const isPiUiPromptEnd = agentType === 'pi' && eventName === 'ui_prompt_end' let stateName = - isPiCompatibleAsk || isOmpApprovalRequest + isPiCompatibleAsk || isOmpApprovalRequest || isOmpPlanReview ? 'blocked' : isOmpApprovalResolution || eventName === 'before_agent_start' || @@ -89,13 +92,14 @@ export function normalizePiCompatibleEvent( if (isPiUiPrompt) { // Why: waiting uses the same orange question icon as Claude/Codex input prompts. stateName = 'waiting' - } else if (isPiUiPromptEnd) { + } else if (isPiUiPromptEnd || isOmpPlanReviewEnd) { stateName = hookPayload.is_idle === true ? 'done' : 'working' } if ( !stateName || - (stateName === 'working' && holdsOmpApproval(state, agentType, eventName, paneKey)) + (stateName === 'working' && holdsOmpApproval(state, agentType, eventName, paneKey)) || + (eventName === 'agent_end' && holdsOmpPlanReview(state, agentType, hookPayload, paneKey)) ) { return null } @@ -130,6 +134,7 @@ export function normalizePiCompatibleEvent( const OMP_APPROVAL_RELEASE_EVENTS: ReadonlySet = new Set([ 'tool_approval_resolved', + 'plan_review_resolved', 'before_agent_start', 'agent_start' ]) @@ -154,3 +159,23 @@ function holdsOmpApproval( previous?.agentType === 'omp' && previous.state === 'blocked' && previous.toolName !== 'ask' ) } + +/** + * Whether an open OMP plan review must survive this completion. A turn cannot end under the + * overlay, but the extension posts `agent_end` when the last async child of an earlier turn + * finishes, and only the review's own resolution (or a new session) ends this wait. + */ +function holdsOmpPlanReview( + state: HookListenerState, + agentType: 'pi' | 'omp' | 'prime-agent', + hookPayload: Record, + paneKey: string +): boolean { + if (agentType !== 'omp' || hookPayload.session_boundary === true) { + return false + } + const previous = state.lastStatusByPaneKey.get(paneKey)?.payload + return ( + previous?.state === 'blocked' && parseOmpPlanReviewPrompt(previous.interactivePrompt) !== 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 65c61981868..ecf3adba4e1 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 @@ -2,6 +2,7 @@ import type { ToolSnapshot } from '../listener-event' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' import { deriveToolInputPreview, hasOwnField, readString, toolUpdate } from '../tool-input-preview' import { clearActiveToolFieldsUpdate, deriveInteractivePrompt } from '../interactive-tool' +import { serializeOmpPlanReviewPrompt } from '../../omp-plan-review-prompt' /** OMP's `ask` carries the same questions/options payload as Pi's question tool. */ function serializeQuestionPrompt(toolInput: unknown): string | undefined { @@ -15,6 +16,14 @@ function serializeQuestionPrompt(toolInput: unknown): string | undefined { } } +function serializePlanReviewPrompt(hookPayload: Record): string | undefined { + const reviewId = readString(hookPayload, 'review_id') + const plan = hookPayload.plan + return reviewId && typeof plan === 'string' + ? serializeOmpPlanReviewPrompt(reviewId, plan, hookPayload.options) + : undefined +} + function isPiCompatibleAskTool( agentKind: 'pi' | 'omp' | 'prime-agent', toolName: string | undefined @@ -81,6 +90,20 @@ export function extractPiToolFields( { hasToolInputField: true } ) } + if ( + agentKind === 'omp' && + (eventName === 'plan_review_requested' || eventName === 'plan_review_resolved') + ) { + return toolUpdate( + { + toolName: undefined, + toolInput: undefined, + interactivePrompt: + eventName === 'plan_review_requested' ? serializePlanReviewPrompt(hookPayload) : undefined + }, + { hasToolInputField: true } + ) + } if (eventName === 'message_end' && hookPayload.role === 'assistant') { const text = readString(hookPayload, 'text') if (text) { diff --git a/src/shared/omp-plan-review-prompt.ts b/src/shared/omp-plan-review-prompt.ts new file mode 100644 index 00000000000..cfa7c6bc1aa --- /dev/null +++ b/src/shared/omp-plan-review-prompt.ts @@ -0,0 +1,92 @@ +import { AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH } from './agent-status-types' + +// Why: OMP's plan review overlay holds the keyboard, so Orca's extension reads this +// OSC before the overlay does and answers through ctx.resolvePlanReview. +export const OMP_PLAN_REVIEW_ANSWER_PREFIX = '\x1b]7503;orca-plan-review;' + +/** OMP's own choice ids; `refine` needs feedback, so the card dismisses the overlay instead. */ +const OMP_PLAN_REVIEW_CHOICES: ReadonlySet = new Set(['execute', 'compact', 'keep', 'save']) +export const OMP_PLAN_REVIEW_DISMISS = 'dismiss' +const TRUNCATION_NOTE = '\n\n…' + +type OmpPlanReviewOption = { id: string; label: string } + +export type OmpPlanReviewPrompt = { + reviewId: string + plan: string + options: { label: string; send: string }[] +} + +export function buildOmpPlanReviewAnswer(reviewId: string, choice: string): string { + return `${OMP_PLAN_REVIEW_ANSWER_PREFIX}${reviewId};${choice}\x07` +} + +function readPlanReviewOptions(value: unknown): OmpPlanReviewOption[] { + return (Array.isArray(value) ? value : []).flatMap((option: unknown) => + option && + typeof option === 'object' && + 'id' in option && + 'label' in option && + typeof option.id === 'string' && + typeof option.label === 'string' && + option.label.length > 0 + ? [{ id: option.id, label: option.label }] + : [] + ) +} + +/** The `interactivePrompt` envelope for an open OMP plan review, clipped to the field's cap. */ +export function serializeOmpPlanReviewPrompt( + reviewId: string, + plan: string, + rawOptions: unknown +): string { + const options = readPlanReviewOptions(rawOptions) + const serialize = (text: string): string => + JSON.stringify({ planReview: { reviewId, plan: text, options } }) + let text = plan + let json = serialize(text) + while (json.length > AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH && text.length > 0) { + const excess = json.length - AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH + text = text.slice(0, Math.max(0, text.length - excess - TRUNCATION_NOTE.length)) + json = serialize(`${text}${TRUNCATION_NOTE}`) + } + return json +} + +export function parseOmpPlanReviewPrompt( + interactivePrompt: string | undefined | null +): OmpPlanReviewPrompt | null { + if (!interactivePrompt) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(interactivePrompt) + } catch { + return null + } + const review = + parsed && typeof parsed === 'object' && 'planReview' in parsed ? parsed.planReview : null + if ( + !review || + typeof review !== 'object' || + !('reviewId' in review) || + !('plan' in review) || + typeof review.reviewId !== 'string' || + !/^[\w-]+$/.test(review.reviewId) || + typeof review.plan !== 'string' + ) { + return null + } + const reviewId = review.reviewId + const plan = review.plan + const options = 'options' in review ? review.options : undefined + const cardOptions = readPlanReviewOptions(options).flatMap(({ id, label }) => { + const choice = id === 'refine' ? OMP_PLAN_REVIEW_DISMISS : id + return choice === OMP_PLAN_REVIEW_DISMISS || OMP_PLAN_REVIEW_CHOICES.has(choice) + ? [{ label, send: buildOmpPlanReviewAnswer(reviewId, choice) }] + : [] + }) + return cardOptions.length > 0 ? { reviewId, plan, options: cardOptions } : null +}