mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 03:57:35 +00:00
fix(omp): show OMP's plan review as a plan card and answer it from chat (#31635)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user