fix(omp): show OMP's plan review as a plan card and answer it from chat (#31635)

This commit is contained in:
Kelvin Amoaba
2026-10-22 00:10:02 +00:00
committed by GitHub
parent b17477614d
commit b0fcdb493c
13 changed files with 495 additions and 18 deletions
@@ -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
@@ -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<AgentStatusEntry> | 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) => {
@@ -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<typeof useMobileStructuredAsyncQuestion>['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(
@@ -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?: {
@@ -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",
@@ -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<typeof vi.fn>
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()
})
})
+52
View File
@@ -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 })",
' })',
''
]
}
@@ -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
+3 -1
View File
@@ -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",
@@ -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<string, unknown>) {
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<string, unknown>) => {
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')
})
})
@@ -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<unknown> = 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<string, unknown>,
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
)
}
@@ -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, unknown>): 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) {
+92
View File
@@ -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<string> = 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
}