From cbee6dff82a24ce50daa7fb6b70106f7f72a5108 Mon Sep 17 00:00:00 2001 From: Narry <3047793+NarryG@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:17:54 -0400 Subject: [PATCH 1/4] feat(omp): answer ACP forms through existing question cards --- docs/site/content/docs/agents/native-chat.mdx | 4 + .../src/session/MobileNativeChatQuestion.tsx | 51 ++- .../MobileNativeChatResolutionReceipt.tsx | 8 +- .../session/mobile-native-chat-question.ts | 3 + .../mobile-structured-agent-prompts.ts | 108 ++--- .../mobile-structured-grouped-question.ts | 5 +- ...bile-structured-question-consumer.test.tsx | 349 ++++++++++++++++ .../use-mobile-structured-prompt-responses.ts | 39 +- src/main/acp/acp-dialects/acp-dialect.ts | 6 + .../acp/acp-dialects/antigravity-dialect.ts | 4 +- src/main/acp/acp-dialects/grok-requests.ts | 7 +- src/main/acp/acp-form-elicitation.test.ts | 374 ++++++++++++++++++ src/main/acp/acp-form-elicitation.ts | 236 +++++++++++ src/main/acp/acp-launch-specs.ts | 3 + src/main/acp/acp-session-runtime.ts | 10 +- src/main/acp/acp-structured-acquire.ts | 4 +- .../acp-structured-adapter.test-support.ts | 2 + src/main/acp/acp-structured-prompts.ts | 6 +- src/main/acp/acp-timeline-requests.ts | 15 +- .../journal-prompt-body-bounds.ts | 4 + ...tured-agent-session-grouped-prompt.test.ts | 137 ++++++- .../structured-agent-session-turns-prompt.ts | 6 + .../NativeChatQuestionCard.test.tsx | 93 +++++ .../native-chat/NativeChatQuestionCard.tsx | 85 ++-- .../NativeChatQuestionOptionRow.tsx | 51 +++ .../NativeChatResolutionReceipt.tsx | 21 +- .../NativeChatStructuredQuestionCard.test.tsx | 96 ++++- .../NativeChatStructuredQuestionCard.tsx | 12 +- .../NativeChatStructuredSession.tsx | 1 + .../src/i18n/en-runtime-required.json | 3 +- src/renderer/src/i18n/locales/en.json | 4 +- src/shared/agent-session-journal-schemas.ts | 4 +- src/shared/agent-session-journal-types.ts | 4 + src/shared/agent-session-question-answer.ts | 13 +- src/shared/native-chat-ask-types.ts | 2 +- src/shared/native-chat-resolution-receipt.ts | 12 +- ...uctured-agent-session-agent-params.test.ts | 76 +++- .../structured-agent-session-params.ts | 58 +-- ...ructured-agent-session-question-answers.ts | 60 +++ 39 files changed, 1746 insertions(+), 230 deletions(-) create mode 100644 mobile/src/session/mobile-structured-question-consumer.test.tsx create mode 100644 src/main/acp/acp-form-elicitation.test.ts create mode 100644 src/main/acp/acp-form-elicitation.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatQuestionOptionRow.tsx create mode 100644 src/shared/rpc-contract/structured-agent-session-question-answers.ts diff --git a/docs/site/content/docs/agents/native-chat.mdx b/docs/site/content/docs/agents/native-chat.mdx index 1a812697e0ad..d2e1c803e013 100644 --- a/docs/site/content/docs/agents/native-chat.mdx +++ b/docs/site/content/docs/agents/native-chat.mdx @@ -25,6 +25,10 @@ Toggle Chat UI ↔ terminal from the agent pane once enabled. When Claude shows an **AskUserQuestion** (or similar structured permission/question card), Chat UI should render the question card in the transcript so you can answer from the composer path instead of treating it as a collapsed tool call. Confirm with **Submit** on the card. That includes agents running on a paired [Remote Orca Server](/docs/remote-servers) / headless host — not only local panes. +OMP ACP forms use the same desktop and mobile cards for choices, text, and confirmations. Optional answers can be left unset; choosing an empty value or **No selections** is distinct from leaving a choice unset. **Decline** replies to the form; **Cancel** stops it. Full access does not answer these human decisions automatically. + +Stopping the turn withdraws its open cards and rejects late answers. OMP versions whose ACP transport does not send a form-cancellation notification cannot notify Orca of their own dialog timeout; dismiss the remaining card or stop the turn. + ## Availability Chat UI ships on desktop for supported local and remote (paired server) agent sessions. The [mobile companion](/docs/mobile) reuses chat-style transcript patterns for the same paired sessions. diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx index f303df749951..b957e5edb75b 100644 --- a/mobile/src/session/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -37,12 +37,20 @@ export function MobileNativeChatQuestion({ const hasOptions = question.options.length > 0 const answerText = question.freeTextInput?.allowEmpty ? freeText : freeText.trim() + const hasExclusiveSelection = selectedOptionIndexes.some( + (index) => question.optionExclusive?.[index] + ) const toggle = (optionIndex: number): void => { + if (question.optionExclusive?.[optionIndex]) { + setFreeText('') + } setSelectedOptionIndexes((prev) => prev.includes(optionIndex) ? prev.filter((index) => index !== optionIndex) - : [...prev, optionIndex] + : question.optionExclusive?.[optionIndex] + ? [optionIndex] + : [...prev.filter((index) => !question.optionExclusive?.[index]), optionIndex] ) } @@ -72,7 +80,7 @@ export function MobileNativeChatQuestion({ return } const answer = - question.freeTextToken && answerText.length > 0 + question.freeTextToken && answerText.length > 0 && !hasExclusiveSelection ? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, answerText) : formatQuestionAnswerByIndexes(question, selectedOptionIndexes) if (await sendAnswer(answer)) { @@ -86,7 +94,11 @@ export function MobileNativeChatQuestion({ } const answer = question.multiSelect && question.freeTextToken && selectedOptionIndexes.length > 0 - ? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, answerText) + ? formatQuestionAnswerWithOtherByIndexes( + question, + selectedOptionIndexes.filter((index) => !question.optionExclusive?.[index]), + answerText + ) : formatQuestionFreeTextAnswer(question, answerText) if (await sendAnswer(answer)) { setFreeText('') @@ -173,20 +185,41 @@ export function MobileNativeChatQuestion({ ) : null} + {question.declineToken ? ( + [ + styles.submit, + styles.decline, + pressed && !sending && styles.pressed + ]} + disabled={sending} + onPress={() => sendAnswer(question.declineToken!)} + > + Decline request + + ) : null} + {allowOther ? ( { + setFreeText(text) + setSelectedOptionIndexes((prev) => + prev.filter((index) => !question.optionExclusive?.[index]) + ) + }} placeholder={ question.freeTextInput?.placeholder ?? (hasOptions ? 'Or type a reply…' : 'Type your reply…') } placeholderTextColor={colors.textMuted} selectionColor={colors.accentBlue} - onSubmitEditing={submitFreeText} - returnKeyType="send" + onSubmitEditing={question.freeTextInput?.multiline ? undefined : submitFreeText} + returnKeyType={question.freeTextInput?.multiline ? 'default' : 'send'} // A declared input (Pi's `input`) is one line unless it asks for more, as on desktop. multiline={question.freeTextInput ? question.freeTextInput.multiline === true : true} /> @@ -297,6 +330,12 @@ const styles = StyleSheet.create({ submitTextDisabled: { color: colors.textMuted }, + decline: { + backgroundColor: colors.bgRaised + }, + declineText: { + color: colors.textMuted + }, freeTextRow: { flexDirection: 'row', alignItems: 'flex-end', diff --git a/mobile/src/session/MobileNativeChatResolutionReceipt.tsx b/mobile/src/session/MobileNativeChatResolutionReceipt.tsx index b888525df72b..5e258a14a141 100644 --- a/mobile/src/session/MobileNativeChatResolutionReceipt.tsx +++ b/mobile/src/session/MobileNativeChatResolutionReceipt.tsx @@ -76,9 +76,11 @@ export function MobileNativeChatResolutionReceipt({ > {heading ? {heading} : null} - {answer.answer === '' - ? NATIVE_CHAT_RECEIPT_COPY.emptyAnswer - : (answer.answer ?? NATIVE_CHAT_RECEIPT_COPY.unavailable)} + {answer.copy === 'declined' + ? NATIVE_CHAT_RECEIPT_COPY.declined + : answer.answer === '' + ? NATIVE_CHAT_RECEIPT_COPY.emptyAnswer + : (answer.answer ?? NATIVE_CHAT_RECEIPT_COPY.unavailable)} ) diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts index d32b335138ff..de8a75d1caf5 100644 --- a/mobile/src/session/mobile-native-chat-question.ts +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -19,6 +19,9 @@ export type MobileChatQuestion = { optionTokens: (string | null)[] /** Per-option secondary text from structured prompts, parallel to `options`. */ optionDescriptions?: (string | undefined)[] + optionExclusive?: boolean[] + /** Opaque whole-form refusal response, distinct from cancellation. */ + declineToken?: string /** Opaque prefix used when free-text answers must target a specific prompt. */ freeTextToken?: string freeTextInput?: AgentJournalFreeTextInput diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts index cd1b35d0102f..45a549a1eb23 100644 --- a/mobile/src/session/mobile-structured-agent-prompts.ts +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -1,3 +1,4 @@ +import { z } from 'zod' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import { agentSessionPromptQuestions, @@ -31,26 +32,26 @@ export type StructuredQuestionResponseTarget = { answer: AgentSessionQuestionAnswer } -type PromptTokenPayload = - | { - kind: 'approval' - itemId: string - revision: number - optionId: string - } - | { - kind: 'question-option' - itemId: string - revision: number - questionId: string - optionId: string - } - | { - kind: 'question-free-text' - itemId: string - revision: number - questionId: string - } +const promptIdentityFields = { itemId: z.string(), revision: z.number().finite() } +const promptTokenSchema = z.discriminatedUnion('kind', [ + z.object({ + ...promptIdentityFields, + kind: z.enum(['approval', 'question-decline']), + optionId: z.string() + }), + z.object({ + ...promptIdentityFields, + kind: z.literal('question-option'), + questionId: z.string(), + optionId: z.string() + }), + z.object({ + ...promptIdentityFields, + kind: z.literal('question-free-text'), + questionId: z.string() + }) +]) +type PromptTokenPayload = z.infer const STRUCTURED_PROMPT_TOKEN_PREFIX = 'structured-agent-prompt:' @@ -70,54 +71,18 @@ function encodePromptToken(payload: PromptTokenPayload): string { return `${STRUCTURED_PROMPT_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}` } -function decodePromptToken(value: string): PromptTokenPayload | null { +export function decodeStructuredPromptToken(value: string): PromptTokenPayload | null { if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) { return null } try { - const decoded = JSON.parse( - decodeURIComponent(value.slice(STRUCTURED_PROMPT_TOKEN_PREFIX.length)) - ) as Record - if ( - typeof decoded.itemId !== 'string' || - typeof decoded.revision !== 'number' || - !Number.isFinite(decoded.revision) - ) { - return null - } - if (decoded.kind === 'approval' && typeof decoded.optionId === 'string') { - return { - kind: decoded.kind, - itemId: decoded.itemId, - revision: decoded.revision, - optionId: decoded.optionId - } - } - if ( - decoded.kind === 'question-option' && - typeof decoded.questionId === 'string' && - typeof decoded.optionId === 'string' - ) { - return { - kind: decoded.kind, - itemId: decoded.itemId, - revision: decoded.revision, - questionId: decoded.questionId, - optionId: decoded.optionId - } - } - if (decoded.kind === 'question-free-text' && typeof decoded.questionId === 'string') { - return { - kind: decoded.kind, - itemId: decoded.itemId, - revision: decoded.revision, - questionId: decoded.questionId - } - } + const parsed = promptTokenSchema.safeParse( + JSON.parse(decodeURIComponent(value.slice(STRUCTURED_PROMPT_TOKEN_PREFIX.length))) + ) + return parsed.success ? parsed.data : null } catch { return null } - return null } function decodeQuestionFreeTextAnswer(value: string): { @@ -131,7 +96,7 @@ function decodeQuestionFreeTextAnswer(value: string): { if (separator === -1) { return null } - const payload = decodePromptToken(value.slice(0, separator)) + const payload = decodeStructuredPromptToken(value.slice(0, separator)) if (payload?.kind !== 'question-free-text') { return null } @@ -172,13 +137,22 @@ export function projectStructuredQuestion( if (prompt?.body.kind !== 'question') { return null } + const declineToken = prompt.body.declineOptionId + ? encodePromptToken({ + kind: 'question-decline', + itemId: prompt.itemId, + revision: prompt.revision, + optionId: prompt.body.declineOptionId + }) + : undefined if (prompt.body.questions) { - return projectGroupedQuestion( + const projected = projectGroupedQuestion( prompt.body.questions, groupedDraft, groupedQuestionPromptKey(prompt.itemId, prompt.revision), { itemId: prompt.itemId, expectedRevision: prompt.revision } ) + return projected && declineToken ? { ...projected, declineToken } : projected } const [question] = agentSessionPromptQuestions(prompt.body) if (!question) { @@ -190,6 +164,10 @@ export function projectStructuredQuestion( prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision }, options: prompt.body.options.map((option) => option.label), ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), + ...(declineToken ? { declineToken } : {}), + ...(prompt.body.options.some((option) => option.exclusive) + ? { optionExclusive: prompt.body.options.map((option) => option.exclusive === true) } + : {}), multiSelect: false, allowOther: Boolean(prompt.body.freeTextQuestionId), ...(prompt.body.freeTextInput ? { freeTextInput: prompt.body.freeTextInput } : {}), @@ -219,7 +197,7 @@ export function structuredApprovalResponseTarget( response: string, currentPrompt: StructuredApprovalItem | null ): StructuredPromptResponseTarget | null { - const token = decodePromptToken(response) + const token = decodeStructuredPromptToken(response) if (token?.kind === 'approval') { return { itemId: token.itemId, @@ -246,7 +224,7 @@ export function structuredQuestionResponseTarget( response: string, currentPrompt: StructuredQuestionItem | null ): StructuredQuestionResponseTarget | null { - const token = decodePromptToken(response) + const token = decodeStructuredPromptToken(response) if (token?.kind === 'question-option') { return { itemId: token.itemId, diff --git a/mobile/src/session/mobile-structured-grouped-question.ts b/mobile/src/session/mobile-structured-grouped-question.ts index 7fa5c41b95d7..51819e599563 100644 --- a/mobile/src/session/mobile-structured-grouped-question.ts +++ b/mobile/src/session/mobile-structured-grouped-question.ts @@ -120,6 +120,9 @@ export function projectGroupedQuestion( ...(promptIdentity ? { prompt: promptIdentity } : {}), options: question.options.map((option) => option.label), ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), + ...(question.options.some((option) => option.exclusive) + ? { optionExclusive: question.options.map((option) => option.exclusive === true) } + : {}), multiSelect: question.multiSelect, allowOther: Boolean(question.freeTextQuestionId), ...(question.freeTextInput ? { freeTextInput: question.freeTextInput } : {}), @@ -210,7 +213,7 @@ export function advanceGroupedQuestion(args: { return null } const answer = answerFromResponse(args.response, question, args.promptKey) - if (!answer) { + if (!answer || !isValidAgentSessionQuestionAnswers([question], [answer])) { return null } const answers = [...collected, answer] diff --git a/mobile/src/session/mobile-structured-question-consumer.test.tsx b/mobile/src/session/mobile-structured-question-consumer.test.tsx new file mode 100644 index 000000000000..24026cdcfd4b --- /dev/null +++ b/mobile/src/session/mobile-structured-question-consumer.test.tsx @@ -0,0 +1,349 @@ +import { createElement, useRef } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TextInput } from 'react-native' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + type StructuredAgentSessionState +} from '../../../src/shared/structured-agent-session-reducer' +import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' +import { mobileChatQuestionKey } from './mobile-native-chat-question' +import { + projectStructuredQuestion, + type StructuredQuestionItem +} from './mobile-structured-agent-prompts' +import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' +import { + advanceGroupedQuestion, + type GroupedQuestionDraft +} from './mobile-structured-grouped-question' +import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' + +vi.mock('react-native', () => ({ + Keyboard: { dismiss: vi.fn() }, + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + Check: 'Check', + ChevronDown: 'ChevronDown', + CircleHelp: 'CircleHelp', + X: 'X' +})) + +let renderer: ReactTestRenderer +let responses: { + groupedDraft: GroupedQuestionDraft | null + respondQuestion: (answer: string) => Promise +} +const cancel = vi.fn(async () => true) + +function prompt(): StructuredQuestionItem { + return { + itemId: 'form', + revision: 4, + sequence: 1, + observedAt: 1, + body: { + kind: 'question', + question: 'Choose targets', + options: [], + declineOptionId: 'opaque:decline', + questions: [ + { + id: 'targets', + question: 'Targets?', + multiSelect: true, + freeTextQuestionId: 'targets', + options: [ + { id: 'web', label: 'Web' }, + { id: 'mobile', label: 'Mobile' }, + { id: 'none', label: 'No selections', exclusive: true } + ] + } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + } +} + +function Probe(props: { + prompt: StructuredQuestionItem + mutate: StructuredAgentSessionMutate + supported?: boolean | null + actionablePromptIds?: StructuredAgentSessionState['actionablePromptIds'] +}) { + const stateRef = useRef(EMPTY_STRUCTURED_AGENT_SESSION) + stateRef.current = { + ...EMPTY_STRUCTURED_AGENT_SESSION, + status: 'ready', + items: [props.prompt], + ...(props.actionablePromptIds ? { actionablePromptIds: props.actionablePromptIds } : {}) + } + responses = useMobileStructuredPromptResponses({ + stateRef, + sessionKey: 'session', + mutate: props.mutate, + questionAnswersSupported: props.supported ?? null, + onSendError: vi.fn() + }) + const question = projectStructuredQuestion(props.prompt, responses.groupedDraft) + return question + ? createElement(MobileNativeChatQuestion, { + key: mobileChatQuestionKey(question), + question, + onAnswer: responses.respondQuestion, + onCancel: cancel + }) + : null +} + +function mount(item = prompt(), supported: boolean | null = true) { + const sent: Record[] = [] + const mutate: StructuredAgentSessionMutate = async (_method, _fingerprint, fields) => { + sent.push(fields) + return { status: 'rejected' } + } + act(() => { + renderer = create(createElement(Probe, { prompt: item, mutate, supported })) + }) + return { sent, mutate } +} + +async function pick(index: number) { + await act(async () => + renderer.root.findAllByProps({ accessibilityRole: 'checkbox' })[index]!.props.onPress() + ) +} +async function press(label: string) { + await act(async () => renderer.root.findByProps({ accessibilityLabel: label }).props.onPress()) +} + +afterEach(() => { + act(() => renderer?.unmount()) + vi.clearAllMocks() +}) + +describe('structured mobile question consumer', () => { + it.each([ + { picks: [0, 2], ids: ['none'] }, + { picks: [2, 0, 1], ids: ['web', 'mobile'] }, + { picks: [0, 1, 2], ids: ['none'] } + ])('submits exclusive choices separately: $picks', async ({ picks, ids }) => { + const { sent } = mount() + for (const index of picks) { + await pick(index) + } + await press('Submit selected options') + expect(sent).toEqual([ + { + itemId: 'form', + expectedRevision: 4, + answers: [{ questionId: 'targets', optionIds: ids }] + } + ]) + }) + + it.each([true, false])( + 'keeps Other separate from exclusive choices (typed first=%s)', + async (typedFirst) => { + const { sent } = mount() + if (!typedFirst) { + await pick(2) + } + await act(async () => renderer.root.findByType(TextInput).props.onChangeText('Custom')) + if (typedFirst) { + await pick(2) + } + await press(typedFirst ? 'Submit selected options' : 'Send reply') + expect(sent[0]?.answers).toEqual([ + { + questionId: 'targets', + optionIds: typedFirst ? ['none'] : [], + ...(typedFirst ? {} : { other: 'Custom' }) + } + ]) + } + ) + + it('rejects an invalid early step before it can poison the remaining draft', async () => { + const item = prompt() + item.body.questions!.push({ + id: 'next', + question: 'Next?', + multiSelect: false, + options: [{ id: 'ok', label: 'OK' }] + }) + const { sent } = mount(item) + const card = projectStructuredQuestion(item)! + const invalid = `${card.optionTokens[0]}, ${card.optionTokens[2]}` + await act(async () => expect(await responses.respondQuestion(invalid)).toBe(false)) + expect(responses.groupedDraft).toBeNull() + expect(sent).toEqual([]) + await pick(0) + await press('Submit selected options') + expect(responses.groupedDraft?.answers).toEqual([{ questionId: 'targets', optionIds: ['web'] }]) + expect(renderer.root.findByType(MobileNativeChatQuestion).props.question.question).toContain( + 'Next?' + ) + }) + + it.each([true, false, null])( + 'sends Decline as the existing option transport (answers support=%s)', + async (supported) => { + const item = prompt() + item.body.questions!.push({ + id: 'next', + question: 'Next?', + multiSelect: false, + options: [{ id: 'ok', label: 'OK' }] + }) + const { sent } = mount(item, supported) + await pick(0) + await press('Submit selected options') + await press('Decline request') + expect(sent).toEqual([{ itemId: 'form', expectedRevision: 4, optionId: 'opaque:decline' }]) + expect(cancel).not.toHaveBeenCalled() + // A rejected refusal keeps the user's accumulated answers available. + expect(responses.groupedDraft?.answers).toHaveLength(1) + await press('Cancel') + expect(cancel).toHaveBeenCalledWith({ itemId: 'form', expectedRevision: 4 }) + expect(sent).toHaveLength(1) + } + ) + + it.each([ + { kind: 'question-option', questionId: '', optionId: 'opaque:decline' }, + { kind: 'question-decline', optionId: 7 } + ])('rejects old or malformed decline tokens without a mutation: %j', async (payload) => { + const { sent } = mount() + const token = `structured-agent-prompt:${encodeURIComponent( + JSON.stringify({ itemId: 'form', revision: 4, ...payload }) + )}` + await act(async () => expect(await responses.respondQuestion(token)).toBe(false)) + expect(sent).toEqual([]) + }) + + it('does not send a stale decline after a revision changes', async () => { + const item = prompt() + const token = projectStructuredQuestion(item)!.declineToken! + const { sent, mutate } = mount(item) + act(() => renderer.update(createElement(Probe, { prompt: { ...item, revision: 5 }, mutate }))) + await act(async () => expect(await responses.respondQuestion(token)).toBe(false)) + expect(sent).toEqual([]) + }) + + it('does not send a decline once the host retires the actionable prompt', async () => { + const item = prompt() + const token = projectStructuredQuestion(item)!.declineToken! + const { sent, mutate } = mount(item) + act(() => + renderer.update(createElement(Probe, { prompt: item, mutate, actionablePromptIds: [] })) + ) + await act(async () => expect(await responses.respondQuestion(token)).toBe(false)) + expect(sent).toEqual([]) + }) + + it('discards collected answers after an unconfirmed decline leaves the phone', async () => { + const item = prompt() + item.body.questions!.push({ + id: 'next', + question: 'Next?', + multiSelect: false, + options: [{ id: 'ok', label: 'OK' }] + }) + mount(item) + await pick(0) + await press('Submit selected options') + expect(responses.groupedDraft?.answers).toHaveLength(1) + const mutate: StructuredAgentSessionMutate = async () => ({ status: 'unknown' }) + act(() => renderer.update(createElement(Probe, { prompt: item, mutate }))) + await press('Decline request') + expect(responses.groupedDraft).toBeNull() + expect(renderer.root.findByType(MobileNativeChatQuestion).props.question.question).toContain( + 'Targets?' + ) + }) + + it('keeps older prompts without decline or exclusivity unchanged', async () => { + const item = prompt() + delete item.body.declineOptionId + delete item.body.questions![0]!.options[2]!.exclusive + const { sent } = mount(item) + expect(renderer.root.findAllByProps({ accessibilityLabel: 'Decline request' })).toHaveLength(0) + await pick(0) + await pick(2) + await press('Submit selected options') + expect(sent[0]).toMatchObject({ + answers: [{ questionId: 'targets', optionIds: ['web', 'none'] }] + }) + }) + + it.each([true, false])( + 'allows empty default text through the single-question wire (answers support=%s)', + async (supported) => { + const item = prompt() + delete item.body.questions + item.body.freeTextQuestionId = 'input' + item.body.freeTextInput = { + allowEmpty: true, + multiline: true, + initialValue: '', + placeholder: 'Write here' + } + const { sent } = mount(item, supported) + expect(renderer.root.findByType(TextInput).props).toMatchObject({ + value: '', + placeholder: 'Write here', + multiline: true, + returnKeyType: 'default' + }) + expect(renderer.root.findByType(TextInput).props.onSubmitEditing).toBeUndefined() + await press('Send reply') + expect(sent).toEqual([ + { + itemId: 'form', + expectedRevision: 4, + ...(supported + ? { answers: [{ questionId: 'input', optionIds: [], other: '' }] } + : { optionId: 'input:' }) + } + ]) + } + ) + + it.each([false, true])( + 'preserves grouped default and empty text with choices=%s', + async (choices) => { + const item = prompt() + if (!choices) { + item.body.questions![0]!.options = [] + } + item.body.questions![0]!.multiSelect = false + item.body.questions![0]!.freeTextInput = { + allowEmpty: true, + multiline: true, + initialValue: ' draft\n' + } + const { sent } = mount(item) + expect(renderer.root.findByType(TextInput).props.value).toBe(' draft\n') + await press('Send reply') + expect(sent[0]).toMatchObject({ + answers: [{ questionId: 'targets', optionIds: [], other: ' draft\n' }] + }) + const card = projectStructuredQuestion(item)! + expect( + advanceGroupedQuestion({ + response: `${card.freeTextToken}:`, + questions: item.body.questions!, + draft: null, + promptKey: 'form:4' + }) + ).toEqual({ kind: 'submit', answers: [{ questionId: 'targets', optionIds: [], other: '' }] }) + } + ) +}) diff --git a/mobile/src/session/use-mobile-structured-prompt-responses.ts b/mobile/src/session/use-mobile-structured-prompt-responses.ts index eb2a0b38caa1..5f03b5dff31e 100644 --- a/mobile/src/session/use-mobile-structured-prompt-responses.ts +++ b/mobile/src/session/use-mobile-structured-prompt-responses.ts @@ -12,6 +12,7 @@ import { actionableStructuredQuestion } from './mobile-structured-actionable-prompt' import { + decodeStructuredPromptToken, structuredApprovalResponseTarget, structuredQuestionResponseTarget } from './mobile-structured-agent-prompts' @@ -78,7 +79,7 @@ export function useMobileStructuredPromptResponses(args: { const sendAnswers = useCallback( ( target: { itemId: string; expectedRevision: number }, - body: Pick, + body: Pick, answers: AgentSessionQuestionAnswer[] ): Promise | null> => { const send = (fields: Record) => @@ -110,6 +111,38 @@ export function useMobileStructuredPromptResponses(args: { const respondQuestion = useCallback( async (answer: string): Promise => { const prompt = actionableStructuredQuestion(stateRef.current) + const decline = decodeStructuredPromptToken(answer) + if (decline?.kind === 'question-decline') { + if ( + !prompt?.body.declineOptionId || + decline.itemId !== prompt.itemId || + decline.revision !== prompt.revision || + decline.optionId !== prompt.body.declineOptionId + ) { + return false + } + const result = await mutate( + 'agentSession.respondToQuestion', + 'agentSession.respondTo:question', + { + itemId: decline.itemId, + expectedRevision: decline.revision, + optionId: prompt.body.declineOptionId + } + ) + if (result.status !== 'rejected') { + setCollected((current) => + current?.sessionKey === sessionKey && + current.draft.promptKey === groupedQuestionPromptKey(prompt.itemId, prompt.revision) + ? null + : current + ) + } + if (result.status === 'unknown') { + onSendError('Answer unconfirmed — check chat before retrying') + } + return result.status === 'accepted' + } if (prompt?.body.questions) { const promptKey = groupedQuestionPromptKey(prompt.itemId, prompt.revision) const grouped = advanceGroupedQuestion({ @@ -154,7 +187,7 @@ export function useMobileStructuredPromptResponses(args: { const result = await sendAnswers( { itemId: target.itemId, expectedRevision: target.expectedRevision }, // Grouped questions returned above, so this is a single question. - {}, + prompt?.body ?? {}, [target.answer] ) if (!result) { @@ -166,7 +199,7 @@ export function useMobileStructuredPromptResponses(args: { } return result.status === 'accepted' }, - [groupedDraft, onSendError, sendAnswers, sessionKey, stateRef] + [groupedDraft, mutate, onSendError, sendAnswers, sessionKey, stateRef] ) return { groupedDraft, respondPermission, respondQuestion } diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 4132b168124e..85fd1f94a6d5 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -10,6 +10,12 @@ import type { AcpAgentError } from '../acp-errors' import type { AgentSessionOptionChoice } from '../../../shared/agent-session-wire' import type { ModelInfo, ToolCallUpdate } from '../generated/acp-protocol.generated' +export const pendingAcpResolution = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const export type AcpRequestPresentation = { body: ProviderTimelineRequestBody reply(response: AgentSessionPromptResponse | null): unknown diff --git a/src/main/acp/acp-dialects/antigravity-dialect.ts b/src/main/acp/acp-dialects/antigravity-dialect.ts index f1de78dc939b..9517516b2397 100644 --- a/src/main/acp/acp-dialects/antigravity-dialect.ts +++ b/src/main/acp/acp-dialects/antigravity-dialect.ts @@ -2,9 +2,9 @@ import { z } from 'zod' import { legacyAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' import { AcpRpcError } from '../acp-errors' import { readAcpPermissionRequest } from '../acp-permission-requests' -import { acpPermissionPresentation, pendingAcpResolution } from '../acp-timeline-requests' +import { acpPermissionPresentation } from '../acp-timeline-requests' import type { ToolCallUpdate } from '../generated/acp-protocol.generated' -import type { AcpDialect, AcpRequestPresentation } from './acp-dialect' +import { pendingAcpResolution, type AcpDialect, type AcpRequestPresentation } from './acp-dialect' // Antigravity asks the person a question as a permission request on an `interaction_` tool call, // one choice per option, sent even in its yolo mode: a question, never an approval to auto-allow. diff --git a/src/main/acp/acp-dialects/grok-requests.ts b/src/main/acp/acp-dialects/grok-requests.ts index b86788120616..67cd5cea0e88 100644 --- a/src/main/acp/acp-dialects/grok-requests.ts +++ b/src/main/acp/acp-dialects/grok-requests.ts @@ -4,8 +4,11 @@ import { legacyAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' import { AcpRpcError } from '../acp-errors' -import { pendingAcpResolution } from '../acp-timeline-requests' -import type { AcpRequestPresentation, AcpRequestSettlement } from './acp-dialect' +import { + pendingAcpResolution, + type AcpRequestPresentation, + type AcpRequestSettlement +} from './acp-dialect' const questionRequestSchema = z.object({ sessionId: z.string(), diff --git a/src/main/acp/acp-form-elicitation.test.ts b/src/main/acp/acp-form-elicitation.test.ts new file mode 100644 index 000000000000..cbabe0a120e0 --- /dev/null +++ b/src/main/acp/acp-form-elicitation.test.ts @@ -0,0 +1,374 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { acpFormPresentation } from './acp-form-elicitation' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { + openAcpAdapterRig, + PROVIDER_SESSION, + sendHello +} from './acp-structured-adapter.test-support' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' + +afterEach(closeProviderTimelineRigs) +const form = (properties: Record, required?: string[]) => ({ + sessionId: PROVIDER_SESSION, + mode: 'form', + message: 'Review the plan', + requestedSchema: { type: 'object', properties, ...(required ? { required } : {}) } +}) + +describe('ACP form questions', () => { + it.each(['grok', 'opencode'])('does not advertise unverified forms to %s', async (agent) => { + const rig = await openAcpAdapterRig({ spec: acpLaunchSpecFor(agent)! }) + await rig.acquire() + expect(rig.sent('initialize')[0]?.params).not.toHaveProperty('clientCapabilities.elicitation') + }) + + it('sends a human plan decision and rejects foreign-session or unsupported requests without rows', async () => { + const rig = await openAcpAdapterRig({ + spec: acpLaunchSpecFor('omp')!, + launch: { fullAccess: true } + }) + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + const agent = rig.child().agent + const params = form( + { value: { type: 'string', enum: ['Approve and execute', 'Refine plan'] } }, + ['value'] + ) + expect( + await agent.request(60, 'elicitation/create', { ...params, sessionId: 'other-session' }) + ).toMatchObject({ error: { code: -32602 } }) + expect( + await agent.request(61, 'elicitation/create', form({ value: { type: 'object' } })) + ).toMatchObject({ error: { code: -32602 } }) + expect((await rig.rig.rows()).filter((row) => row.body.kind === 'question')).toHaveLength(0) + const requested = agent.request(62, 'elicitation/create', params) + await rig.settle() + const question = (await rig.rig.rows()).find((row) => row.body.kind === 'question')! + await rig.adapter.answerPrompt({ + sessionId: SESSION, + fence: 1, + itemId: question.itemId, + kind: 'question', + response: { + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: ['o2'] }] + }, + commit: async () => {} + }) + expect(await requested).toMatchObject({ + result: { action: 'accept', content: { value: 'Refine plan' } } + }) + }) + + it('converts four fields of booleans, choices, numbers and multi choices to typed content', () => { + const presentation = acpFormPresentation( + form( + { + confirm: { type: 'boolean' }, + choice: { type: 'string', oneOf: [{ const: 'a', title: 'A' }] }, + count: { type: 'integer', minimum: 1 }, + many: { type: 'array', items: { anyOf: [{ const: 'a' }, { const: 'b' }] } } + }, + ['confirm', 'choice', 'count', 'many'] + ) + ) + expect( + presentation.reply({ + kind: 'answers', + answers: [ + { questionId: 'q1', optionIds: ['o2'] }, + { questionId: 'q2', optionIds: ['o1'] }, + { questionId: 'q3', optionIds: [], other: '3' }, + { questionId: 'q4', optionIds: ['o1', 'o2'] } + ] + }) + ).toEqual({ + action: 'accept', + content: { + confirm: false, + choice: 'a', + count: 3, + many: ['a', 'b'] + } + }) + expect(presentation.reply(null)).toEqual({ action: 'cancel' }) + }) + + it('delivers a human decline without requiring answers or stopping the turn', async () => { + const rig = await openAcpAdapterRig({ spec: acpLaunchSpecFor('omp')! }) + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + const requested = rig + .child() + .agent.request(63, 'elicitation/create', form({ confirm: { type: 'boolean' } }, ['confirm'])) + await rig.settle() + const question = (await rig.rig.rows()).find((row) => row.body.kind === 'question')! + await rig.adapter.answerPrompt({ + sessionId: SESSION, + fence: 1, + itemId: question.itemId, + kind: 'question', + response: { kind: 'option', optionId: 'acp-form-decline' }, + commit: async () => {} + }) + expect(await requested).toMatchObject({ result: { action: 'decline' } }) + expect(rig.sent('session/cancel')).toHaveLength(0) + }) + + it('keeps a text-only form question and instructions in the rendered question', () => { + const message = 'Which branch should receive the change?' + const title = 'Other (type your own)' + const description = 'Use an existing branch name.' + const { body } = acpFormPresentation({ + ...form({ q0__other: { type: 'string', title, description } }), + message + }) + if (body.kind !== 'question' || !body.questions?.[0]) { + throw new Error('Missing form question') + } + const displayed = body.questions[0].question + expect(displayed).toContain(message) + expect(displayed).toContain(title) + expect(displayed).toContain(description) + }) + + it('folds native OMP ask companions into three answerable questions', () => { + const presentation = acpFormPresentation( + form({ + q0: { type: 'string', title: 'First choice', oneOf: [{ const: 'a' }] }, + q0__other: { type: 'string', title: 'Une autre réponse' }, + q1: { type: 'array', title: 'Multiple choices', items: { anyOf: [{ const: 'x' }] } }, + q1__other: { type: 'string', title: 'Other (type your own)' }, + q2: { type: 'string', title: 'Last choice', oneOf: [{ const: 'b' }] }, + q2__other: { type: 'string', title: 'Other (type your own)' } + }) + ) + expect( + presentation.reply({ + kind: 'answers', + answers: [ + { questionId: 'q1', optionIds: [], other: 'custom' }, + { questionId: 'q2', optionIds: ['o1'], other: 'also' }, + { questionId: 'q3', optionIds: ['o1'] } + ] + }) + ).toEqual({ + action: 'accept', + content: { q0__other: 'custom', q1: ['x'], q1__other: 'also', q2: 'b' } + }) + expect( + presentation.reply({ + kind: 'answers', + answers: [ + { questionId: 'q1', optionIds: [], other: '' }, + { questionId: 'q2', optionIds: ['empty'] }, + { questionId: 'q3', optionIds: ['o1'] } + ] + }) + ).toEqual({ action: 'accept', content: { q0__other: '', q1: [], q2: 'b' } }) + }) + + it.each([ + { + other: { type: 'string', enum: ['custom'] }, + required: undefined, + answer: { questionId: 'q2', optionIds: ['o1'] } + }, + { + other: { type: 'string' }, + required: ['q0__other'], + answer: { questionId: 'q2', optionIds: [], other: 'custom' } + } + ])('does not fold a companion with its own choices or required presence: %j', (testCase) => { + const presentation = acpFormPresentation( + form( + { q0: { type: 'string', enum: ['preset'] }, q0__other: testCase.other }, + testCase.required + ) + ) + expect( + presentation.reply({ + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: ['o1'] }, testCase.answer] + }) + ).toEqual({ action: 'accept', content: { q0: 'preset', q0__other: 'custom' } }) + }) + + it('accepts an unknown string format without inventing format validation', () => { + const presentation = acpFormPresentation( + form({ value: { type: 'string', format: 'provider-specific-format' } }, ['value']) + ) + expect( + presentation.reply({ + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: [], other: 'value' }] + }) + ).toEqual({ action: 'accept', content: { value: 'value' } }) + }) + + it.each([ + { type: 'string', enum: ['', 'nonempty'] }, + { type: 'string', oneOf: [{ const: '', title: 'Empty value' }] } + ])('preserves an explicitly chosen empty optional value: %j', (property) => { + const presentation = acpFormPresentation(form({ value: property })) + expect( + presentation.reply({ + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: ['o1'] }] + }) + ).toEqual({ action: 'accept', content: { value: '' } }) + expect( + presentation.reply({ + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: ['omit'] }] + }) + ).toEqual({ action: 'accept', content: {} }) + }) + + it('distinguishes empty required and optional arrays from an omitted property', () => { + const answer = (required: boolean, optionId: string) => { + const presentation = acpFormPresentation( + form( + { targets: { type: 'array', items: { anyOf: [{ const: 'web' }] } } }, + required ? ['targets'] : [] + ) + ) + const question = presentation.body.kind === 'question' && presentation.body.questions?.[0] + const option = question && question.options.find((entry) => entry.id === optionId) + if (!question || !option) { + throw new Error(`Missing array answer: ${optionId}`) + } + return presentation.reply({ + kind: 'answers', + answers: [{ questionId: question.id, optionIds: [option.id] }] + }) + } + expect(answer(true, 'empty')).toEqual({ action: 'accept', content: { targets: [] } }) + expect(answer(false, 'empty')).toEqual({ action: 'accept', content: { targets: [] } }) + expect(answer(false, 'omit')).toEqual({ action: 'accept', content: {} }) + }) + + it('refuses forms that cannot fit the journal and response contract', () => { + const properties = Object.fromEntries( + Array.from({ length: 5 }, (_, index) => [`field${index}`, { type: 'boolean' }]) + ) + expect(() => acpFormPresentation(form(properties))).toThrowError( + expect.objectContaining({ code: -32602 }) + ) + }) + + it('refuses choices that would truncate a synthetic empty-array answer', () => { + expect(() => + acpFormPresentation( + form( + { + targets: { + type: 'array', + items: { + anyOf: Array.from({ length: 64 }, (_, index) => ({ const: `target${index}` })) + } + } + }, + ['targets'] + ) + ) + ).toThrowError(expect.objectContaining({ code: -32602 })) + }) + + it('refuses a required field that is not an own schema property', () => { + expect(() => + acpFormPresentation(form({ value: { type: 'string' } }, ['toString'])) + ).toThrowError(expect.objectContaining({ code: -32602 })) + }) + + it.each([ + ['omit', 'o1'], + ['empty', 'o1'], + ['empty', 'omit'] + ])('rejects mixed synthetic array choices from older clients: %j', (...optionIds) => { + const presentation = acpFormPresentation( + form({ targets: { type: 'array', items: { anyOf: [{ const: 'web' }] } } }) + ) + expect(() => + presentation.reply({ + kind: 'answers', + answers: [{ questionId: 'q1', optionIds }] + }) + ).toThrowError(expect.objectContaining({ code: -32602 })) + }) + + it.each([ + { value: { type: 'object' } }, + { value: { type: 'string', pattern: '.*' } }, + { value: { type: 'array', items: { type: 'string' } } } + ])('refuses unsupported schemas without accepting them', (properties) => { + expect(() => acpFormPresentation(form(properties))).toThrowError( + expect.objectContaining({ code: -32602 }) + ) + }) + + it('rejects invalid numeric answers rather than coercing empty input to zero', () => { + const presentation = acpFormPresentation( + form({ value: { type: 'integer', minimum: 1 } }, ['value']) + ) + for (const other of ['', 'NaN', '0', '1.5']) { + expect(() => + presentation.reply({ + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: [], other }] + }) + ).toThrowError(expect.objectContaining({ code: -32602 })) + } + }) + + it.each([false, true])( + 'keeps OMP plan review human under fullAccess=%s and cancels on Stop', + async (fullAccess) => { + const rig = await openAcpAdapterRig({ + spec: acpLaunchSpecFor('omp')!, + launch: { fullAccess } + }) + await rig.acquire() + expect(rig.sent('initialize')[0]?.params).toMatchObject({ + clientCapabilities: { elicitation: { form: {} } } + }) + await sendHello(rig, 'first') + await rig.frame('session/prompt') + const requested = rig.child().agent.request( + 51, + 'elicitation/create', + form( + { + value: { type: 'string', enum: ['Approve and execute', 'Refine plan'] } + }, + ['value'] + ) + ) + await rig.settle() + const question = (await rig.rig.rows()).find((row) => row.body.kind === 'question') + expect(question?.body).toMatchObject({ resolution: { state: 'pending' } }) + await rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }) + expect(await requested).toMatchObject({ result: { action: 'cancel' } }) + const late = await rig + .child() + .agent.request(52, 'elicitation/create', form({ value: { type: 'boolean' } })) + expect(late).toMatchObject({ result: { action: 'cancel' } }) + await expect( + rig.adapter.answerPrompt({ + sessionId: SESSION, + fence: 1, + itemId: question!.itemId, + kind: 'question', + response: { kind: 'option', optionId: 'o1' }, + commit: async () => {} + }) + ).rejects.toThrow(/no longer waiting/) + } + ) +}) diff --git a/src/main/acp/acp-form-elicitation.ts b/src/main/acp/acp-form-elicitation.ts new file mode 100644 index 000000000000..16b5bb050fd4 --- /dev/null +++ b/src/main/acp/acp-form-elicitation.ts @@ -0,0 +1,236 @@ +import { z } from 'zod' +import type { AgentJournalQuestion } from '../../shared/agent-session-journal-types' +import { + isValidAgentSessionQuestionAnswers, + legacyAgentSessionQuestionAnswers +} from '../../shared/agent-session-question-answer' +import { pendingAcpResolution, type AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { AcpRpcError } from './acp-errors' +import { + MAX_JOURNAL_GROUPED_PROMPT_QUESTIONS, + MAX_JOURNAL_PROMPT_OPTIONS +} from '../native-chat/agent-session-journal/journal-prompt-body-bounds' + +const choiceSchema = z + .object({ + const: z.string(), + title: z.string().optional(), + description: z.string().optional() + }) + .strict() +const propertySchema = z + .object({ + type: z.enum(['string', 'boolean', 'number', 'integer', 'array']), + title: z.string().optional(), + description: z.string().optional(), + format: z.string().nullish(), + default: z.union([z.string(), z.boolean(), z.number()]).optional(), + enum: z.array(z.string()).nonempty().optional(), + oneOf: z.array(choiceSchema).nonempty().optional(), + items: z + .object({ anyOf: z.array(choiceSchema).nonempty() }) + .strict() + .optional(), + minimum: z.number().optional(), + maximum: z.number().optional() + }) + .strict() +const formSchema = z.object({ + sessionId: z.string(), + mode: z.literal('form'), + message: z.string(), + requestedSchema: z + .object({ + type: z.literal('object'), + properties: z.record(z.string(), propertySchema), + required: z.array(z.string()).optional() + }) + .strict() +}) + +export const ACP_FORM_ELICITATION_METHOD = 'elicitation/create' + +/** Only schemas representable by the existing question rows are accepted. */ +export function acpFormPresentation(params: unknown): AcpRequestPresentation { + const parsed = formSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Unsupported ACP elicitation form') + } + const { message, requestedSchema } = parsed.data + const entries = Object.entries(requestedSchema.properties) + const required = new Set(requestedSchema.required ?? []) + if ( + !entries.length || + entries.length > MAX_JOURNAL_GROUPED_PROMPT_QUESTIONS * 2 || + [...required].some((key) => !Object.hasOwn(requestedSchema.properties, key)) + ) { + throw new AcpRpcError(-32602, 'Unsupported ACP elicitation form') + } + const companionKeys = new Set() + for (const [key, property] of entries) { + const otherKey = `${key}__other` + const other = requestedSchema.properties[otherKey] + if ( + /^q\d+$/.test(key) && + (property.type === 'array' || property.oneOf || property.enum) && + other?.type === 'string' && + !other.enum && + !other.oneOf && + !other.items && + other.minimum === undefined && + other.maximum === undefined && + !required.has(key) && + !required.has(otherKey) + ) { + companionKeys.add(otherKey) + } + } + const questionEntries = entries.filter(([key]) => !companionKeys.has(key)) + if (questionEntries.length > MAX_JOURNAL_GROUPED_PROMPT_QUESTIONS) { + throw new AcpRpcError(-32602, 'Unsupported ACP elicitation form') + } + const fields = questionEntries.map(([key, property], index) => { + const otherKey = `${key}__other` + const companion = companionKeys.has(otherKey) ? requestedSchema.properties[otherKey] : undefined + const textProperty = companion ?? property + const choices: { const: string; title?: string; description?: string }[] | undefined = + property.type === 'boolean' + ? [ + { const: 'true', title: 'Yes' }, + { const: 'false', title: 'No' } + ] + : property.type === 'array' + ? property.items?.anyOf + : (property.oneOf ?? property.enum?.map((value) => ({ const: value, title: value }))) + if ( + (property.type === 'array' && !choices) || + (property.type !== 'array' && property.items) || + (property.type !== 'string' && (property.enum || property.oneOf)) || + (property.enum && property.oneOf) || + (!['number', 'integer'].includes(property.type) && + (property.minimum !== undefined || property.maximum !== undefined)) || + (choices && new Set(choices.map((choice) => choice.const)).size !== choices.length) + ) { + throw new AcpRpcError(-32602, 'Unsupported ACP elicitation property') + } + const id = `q${index + 1}` + const title = property.title ?? (questionEntries.length === 1 ? message : key) + let text = index === 0 && message && message !== title ? `${message}\n${title}` : title + if (property.description && property.description !== message && property.description !== title) { + text += `\n${property.description}` + } + const question: AgentJournalQuestion = { + id, + question: text, + multiSelect: property.type === 'array', + options: choices + ? [ + ...choices.map((choice, choiceIndex) => ({ + id: `o${choiceIndex + 1}`, + label: choice.title ?? choice.const, + ...(choice.description ? { description: choice.description } : {}) + })), + ...(property.type === 'array' + ? [{ id: 'empty', label: 'No selections', exclusive: true }] + : []), + ...(!required.has(key) && !companion + ? [{ id: 'omit', label: 'Leave unset', exclusive: true }] + : []) + ] + : [], + ...(!choices || companion + ? { + freeTextQuestionId: id, + freeTextInput: { + allowEmpty: companion + ? property.type !== 'array' && questionEntries.length > 1 + : textProperty.type === 'string' || !required.has(key), + multiline: !companion && property.type === 'string', + ...(textProperty.default !== undefined + ? { initialValue: String(textProperty.default) } + : {}) + } + } + : {}) + } + if (question.options.length > MAX_JOURNAL_PROMPT_OPTIONS) { + throw new AcpRpcError(-32602, 'Unsupported ACP elicitation choices') + } + return { key, property, choices, question, otherKey: companion ? otherKey : undefined } + }) + const questions = fields.map((field) => field.question) + const body = { + kind: 'question' as const, + question: message, + options: questions[0]!.options, + questions, + declineOptionId: 'acp-form-decline', + resolution: pendingAcpResolution + } + return { + body, + reply(response) { + if (!response) { + return { action: 'cancel' } + } + if (response.kind === 'option' && response.optionId === body.declineOptionId) { + return { action: 'decline' } + } + const answers = + response.kind === 'answers' + ? response.answers + : legacyAgentSessionQuestionAnswers(body, response.optionId) + if (!answers || !isValidAgentSessionQuestionAnswers(questions, answers)) { + throw new AcpRpcError(-32602, 'Invalid ACP elicitation answer') + } + const content: Record = {} + for (const field of fields) { + const answer = answers.find((entry) => entry.questionId === field.question.id)! + if (field.otherKey && answer.other !== undefined) { + content[field.otherKey] = answer.other + } + if (answer.optionIds.includes('omit')) { + continue + } + if (answer.optionIds.includes('empty')) { + content[field.key] = [] + continue + } + const values = answer.optionIds.map((id) => { + const index = field.question.options.findIndex((option) => option.id === id) + return field.choices![index]!.const + }) + if (field.otherKey && values.length === 0) { + continue + } + if (field.property.type === 'array') { + content[field.key] = values + } else if (field.property.type === 'boolean') { + content[field.key] = values[0] === 'true' + } else if (field.property.type === 'string') { + const value = values[0] ?? answer.other ?? '' + if (required.has(field.key) || values.length > 0 || value !== '') { + content[field.key] = value + } + } else { + const text = answer.other ?? '' + if (!required.has(field.key) && text.trim() === '') { + continue + } + const value = Number(text) + if ( + text.trim() === '' || + !Number.isFinite(value) || + (field.property.type === 'integer' && !Number.isInteger(value)) || + (field.property.minimum !== undefined && value < field.property.minimum) || + (field.property.maximum !== undefined && value > field.property.maximum) + ) { + throw new AcpRpcError(-32602, 'Invalid ACP elicitation number') + } + content[field.key] = value + } + } + return { action: 'accept', content } + } + } +} diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index fb39656239ff..8e90da803b75 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -144,6 +144,8 @@ export type AcpLaunchSpec = { } /** Images go to the agent when it also advertises them; off sends text prompts only. */ imagePrompts?: true + /** Opts in only agents whose elicitation forms Orca supports. */ + formElicitation?: true /** The agent compacts its conversation when sent `/compact` as a prompt; off hides `/compact`. */ compaction?: true /** Throws when the chat's own arguments would send a copied conversation's CLI elsewhere than @@ -269,6 +271,7 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { args: ({ pluginDir }) => ['acp', ...(pluginDir ? ['--plugin-dir', pluginDir] : [])], env: {}, dialect: OMP_ACP_DIALECT, + formElicitation: true, loginCommand: agentSessionSignInFor('omp')?.loginCommand ?? [], // The directory OMP's terminal chats read too; its default is OMP's own. account: directoryAccountBinding('PI_CODING_AGENT_DIR', (homePath) => diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 266b9995a97b..3112f533bafb 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -45,6 +45,8 @@ export type AcpSessionRuntimeOptions = { clientInfo?: InitializeRequest['clientInfo'] peer?: AcpPeerOptions onPermission?: AcpPermissionHandler + /** Only opt in when onRequest presents and answers supported form schemas. */ + handlesFormElicitation?: true /** Agent requests other than permissions. The handler owns its request: once `context.signal` * aborts, send the agent's own cancelled reply, finish an answer already in progress, or throw * (-32800). The runtime never answers for it; a request left unanswered ends at `close()`. */ @@ -105,7 +107,13 @@ export class AcpSessionRuntime { 'initialize', { protocolVersion: ACP_PROTOCOL_VERSION, - clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + ...(this.options.handlesFormElicitation && this.options.onRequest + ? { elicitation: { form: {} } } + : {}) + }, ...(this.options.clientInfo === undefined ? {} : { clientInfo: this.options.clientInfo }) } satisfies InitializeRequest, InitializeResponseSchema diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 98e78ac548a3..a69402074a77 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -117,6 +117,7 @@ export async function acquireAcpStructuredSession(input: { }, { clientInfo: { name: 'orca', version: '1' }, + handlesFormElicitation: spec.formElicitation, ...(acquire.onOutput ? { onOutput: acquire.onOutput } : {}), ...(spec.signIn ? { signIn: spec.signIn } : {}), onPermission: (request, context) => { @@ -304,12 +305,11 @@ export async function acquireAcpStructuredSession(input: { ended: false, exitObservedAt: null } - const reading = liveLane const unbind = events?.bindReadingControl?.({ pauseReading: () => connection.pauseReading(), resumeReading: () => { connection.resumeReading() - reading.retry() + liveLane.retry() } }) if (unbind) { diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 1682e9edfeb8..0f845d0f648f 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -229,6 +229,8 @@ export async function openAcpAdapterRig( return child }, readProcessStartTime: async () => 1_700_000_000_000, + // Scripted children supply their own identity, not the native Windows process table. + isWindowsProcessStartTimeAvailable: () => true, dataDirectory: join(tmpdir(), 'orca-acp-adapter-rig'), onEvent: (event) => lifecycle.push(event), onDispatchSettledLate: (settlement) => settled.push(settlement), diff --git a/src/main/acp/acp-structured-prompts.ts b/src/main/acp/acp-structured-prompts.ts index 7f9516e9827a..4136957858ea 100644 --- a/src/main/acp/acp-structured-prompts.ts +++ b/src/main/acp/acp-structured-prompts.ts @@ -58,7 +58,7 @@ export class AcpStructuredPrompts { } return Promise.resolve(translated.settled.reply) } - if (translated.presentation && !this.admits()) { + if (translated.presentation && (!this.admits() || context.signal.aborted)) { // No prompt of Orca's runs, or a Stop or steer is cutting it short: the agent hears its own // cancelled reply and no card opens. return Promise.resolve(translated.presentation.reply(null)) @@ -97,6 +97,10 @@ export class AcpStructuredPrompts { }, { once: true } ) + // Applying the row can synchronously trigger cancellation before the listener is installed. + if (context.signal.aborted && this.open.get(key) === entry) { + this.withdraw(entry) + } }) } diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index 842eafd2af98..5d8f9f868e22 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -4,17 +4,15 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { + pendingAcpResolution, + type AcpDialect, + type AcpRequestPresentation +} from './acp-dialects/acp-dialect' import type { AcpToolTimeline } from './acp-tool-timeline' import { AcpRpcError } from './acp-errors' import { readAcpPermissionRequest } from './acp-permission-requests' - -export const pendingAcpResolution = { - state: 'pending', - selectedOptionId: null, - resolvedBy: null, - resolvedAt: null -} as const +import { acpFormPresentation, ACP_FORM_ELICITATION_METHOD } from './acp-form-elicitation' /** `description`: the agent's own words about what answering means, shown above the choices. */ export function acpPermissionPresentation( @@ -110,6 +108,7 @@ export function translateAcpRequest( } } const presentation = + (method === ACP_FORM_ELICITATION_METHOD ? acpFormPresentation(params) : undefined) ?? options.dialect.request?.(method, params) ?? (method === 'session/request_permission' ? acpPermissionPresentation(params) : undefined) if (!presentation) { diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index 6e226a4c6cff..06d4409e7b60 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -96,6 +96,9 @@ export function boundJournalPromptBody( .map(boundPromptQuestion) } : {}), + ...(body.declineOptionId + ? { declineOptionId: boundPromptIdentifier(body.declineOptionId) } + : {}), ...(body.freeTextQuestionId ? { freeTextQuestionId: boundPromptIdentifier(body.freeTextQuestionId) } : {}), @@ -134,6 +137,7 @@ function boundPromptOptions( return options.slice(0, MAX_JOURNAL_PROMPT_OPTIONS).map((option) => ({ id: boundPromptIdentifier(option.id), label: boundInlineText(option.label, JOURNAL_PROMPT_OPTION_LIMITS).text, + ...(option.exclusive === undefined ? {} : { exclusive: option.exclusive }), ...(option.description === undefined ? {} : { description: boundInlineText(option.description, JOURNAL_PROMPT_OPTION_LIMITS).text }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index f308b7903983..f6372d5069a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -1,4 +1,7 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalQuestion +} from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,7 +9,15 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' +import { + decodeAgentSessionQuestionAnswers, + encodeAgentSessionQuestionAnswers +} from '../../../shared/agent-session-question-answer' +import { + MAX_RESPONSE_OPTION_ID_LENGTH, + RespondToQuestionParams +} from '../../../shared/rpc-contract/structured-agent-session-params' +import { acpFormPresentation } from '../../acp/acp-form-elicitation' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { @@ -23,7 +34,10 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + closeTestJournalHostDatabase, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' @@ -70,7 +84,10 @@ function adapter(): StructuredAgentSessionAdapter { } } -async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }> { +async function seedGroupedQuestion( + declineOptionId?: string, + questions?: AgentJournalQuestion[] +): Promise<{ itemId: string; revision: number }> { const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 100 } const events = acquire.mock.calls.at(-1)?.[0].events if (!events) { @@ -82,14 +99,16 @@ async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number kind: 'question', question: '2 grouped questions from Claude', options: [], - questions: [ + ...(declineOptionId ? { declineOptionId } : {}), + questions: questions ?? [ { id: 'q1', question: 'Targets', multiSelect: true, options: [ { id: 'target-web', label: 'Web' }, - { id: 'target-mobile', label: 'Mobile' } + { id: 'target-mobile', label: 'Mobile' }, + { id: 'target-none', label: 'None', exclusive: true } ] }, { @@ -149,10 +168,65 @@ beforeEach(async () => { afterEach(async () => { await host.flushAllStreamedEvents() + closeTestJournalHostDatabase(root) await rm(root, { recursive: true, force: true }) }) describe('grouped question admission', () => { + it.each([undefined, 'acp-form-decline'])( + 'admits whole-form decline only when advertised: %s', + async (declineOptionId) => { + await host.attach(CALLER, attachParams()) + const prompt = await seedGroupedQuestion(declineOptionId) + const fields = { + itemId: prompt.itemId, + expectedRevision: prompt.revision, + optionId: 'acp-form-decline' + } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:question', fields), + kind: 'question', + ...fields + }) + expect(result.ok).toBe(Boolean(declineOptionId)) + if (declineOptionId) { + expect(result).toMatchObject({ + value: { resolution: { state: 'resolved', selectedOptionId: declineOptionId } } + }) + } else { + expect(answerPrompt).not.toHaveBeenCalled() + } + } + ) + + it.each([['target-none'], ['target-web', 'target-none']])( + 'honors persisted exclusive choices for %j', + async (...optionIds) => { + await host.attach(CALLER, attachParams()) + const prompt = await seedGroupedQuestion() + const fields = { + itemId: prompt.itemId, + expectedRevision: prompt.revision, + answers: [ + { questionId: 'q1', optionIds }, + { questionId: 'q2', optionIds: [], other: 'SSH host' } + ] + } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:question', fields), + kind: 'question', + ...fields + }) + expect(result.ok).toBe(optionIds.length === 1) + if (optionIds.length > 1) { + expect(result).toMatchObject({ + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(answerPrompt).not.toHaveBeenCalled() + } + } + ) + it('reads the packed answer an older client sends into structured answers', async () => { const attached = await host.attach(CALLER, attachParams()) expect(attached.ok).toBe(true) @@ -177,6 +251,57 @@ describe('grouped question admission', () => { ) }) + it('admits a packed maximum-capacity ACP form answer through the RPC schema and host', async () => { + const choices = Array.from({ length: 63 }, (_, index) => ({ const: `target-${index}` })) + const keys = ['first', 'second', 'third', 'fourth'] + const presentation = acpFormPresentation({ + sessionId: THREAD, + mode: 'form', + message: 'Select targets', + requestedSchema: { + type: 'object', + properties: Object.fromEntries( + keys.map((key) => [key, { type: 'array', items: { anyOf: choices } }]) + ), + required: keys + } + }) + if (presentation.body.kind !== 'question' || !presentation.body.questions) { + throw new Error('ACP form did not present grouped questions') + } + const questions = presentation.body.questions + expect(questions).toHaveLength(4) + for (const question of questions) { + expect(question.options).toHaveLength(64) + } + const answers = questions.map((question) => ({ + questionId: question.id, + optionIds: question.options.filter((option) => !option.exclusive).map((option) => option.id) + })) + const optionId = encodeAgentSessionQuestionAnswers(answers) + expect(optionId.length).toBeGreaterThan(MAX_RESPONSE_OPTION_ID_LENGTH) + expect(decodeAgentSessionQuestionAnswers(optionId)).toEqual(answers) + expect(presentation.reply({ kind: 'option', optionId })).toEqual({ + action: 'accept', + content: Object.fromEntries(keys.map((key) => [key, choices.map((choice) => choice.const)])) + }) + expect((await host.attach(CALLER, attachParams())).ok).toBe(true) + const prompt = await seedGroupedQuestion(presentation.body.declineOptionId, questions) + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId } + const params = RespondToQuestionParams.parse({ + envelope: envelope('agentSession.respondTo:question', fields), + ...fields + }) + const result = await host.respondToPrompt(CALLER, { kind: 'question', ...params }) + expect(result).toMatchObject({ + ok: true, + value: { resolution: { state: 'resolved', selectedOptionId: optionId, answers } } + }) + expect(answerPrompt).toHaveBeenCalledWith( + expect.objectContaining({ response: { kind: 'answers', answers } }) + ) + }) + it('takes structured answers past the old option-id bound and keeps the packed form for older readers', async () => { const attached = await host.attach(CALLER, attachParams()) expect(attached.ok).toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 5b8f090b22ea..093b98c51af5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -56,6 +56,12 @@ function readPromptChoice( ? { response: { kind: 'option', optionId }, selectedOptionId: optionId } : null } + if (prompt.declineOptionId && input.optionId === prompt.declineOptionId && !input.answers) { + return { + response: { kind: 'option', optionId: prompt.declineOptionId }, + selectedOptionId: prompt.declineOptionId + } + } const answers = input.answers ?? (input.optionId === undefined diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx index af574996e8f7..61f20868b11f 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx @@ -163,6 +163,99 @@ describe('NativeChatQuestionCard', () => { expect(onAnswer).toHaveBeenCalledWith([{ indices: [0, 2], other: '' }]) }) + it.each([ + { picks: ['Apple', 'Banana', 'Skip'], indices: [3] }, + { picks: ['Skip', 'Apple', 'Banana'], indices: [0, 1] }, + { picks: ['Apple', 'Banana', 'No selections'], indices: [2] }, + { picks: ['No selections', 'Banana'], indices: [1] }, + { picks: ['Skip', 'No selections'], indices: [2] }, + { picks: ['No selections', 'Skip'], indices: [3] } + ])( + 'keeps exclusive multi-select choices separate from other choices: $picks', + ({ picks, indices }) => { + const onAnswer = vi.fn() + const prompt = { + questions: [ + { + question: 'Which fruits?', + multiSelect: true, + options: [ + { label: 'Apple' }, + { label: 'Banana' }, + { label: 'No selections', exclusive: true }, + { label: 'Skip', exclusive: true } + ] + } + ] + } + render(prompt, onAnswer, false) + + for (const label of picks) { + clickOption(label) + } + for (const [index, option] of prompt.questions[0]!.options.entries()) { + expect(optionPressed(option.label)).toBe(indices.includes(index) ? 'true' : 'false') + } + clickAction('Submit') + + expect(onAnswer).toHaveBeenCalledWith([{ indices, other: '' }]) + } + ) + + it('keeps ordinary multi-select options additive with a typed answer', () => { + const onAnswer = vi.fn() + render( + { + questions: [ + { + question: 'Which fruits?', + multiSelect: true, + options: [{ label: 'Apple' }, { label: 'Banana' }] + } + ] + }, + onAnswer + ) + + clickOption('Apple') + typeAnswer('Dragon fruit') + clickOption('Banana') + clickAction('Submit') + + expect(onAnswer).toHaveBeenCalledWith([{ indices: [0, 1], other: 'Dragon fruit' }]) + }) + + it.each([true, false])( + 'keeps typed answers separate from exclusive choices (typed first=%s)', + (typedFirst) => { + const onAnswer = vi.fn() + const prompt = { + questions: [ + { + question: 'Which fruits?', + multiSelect: true, + options: [{ label: 'Apple' }, { label: 'Skip', exclusive: true }] + } + ] + } + render(prompt, onAnswer) + + if (typedFirst) { + typeAnswer('Dragon fruit') + clickOption('Skip') + } else { + clickOption('Skip') + typeAnswer('Dragon fruit') + } + expect(optionPressed('Skip')).toBe(typedFirst ? 'true' : 'false') + clickAction('Submit') + + expect(onAnswer).toHaveBeenCalledWith([ + typedFirst ? { indices: [1], other: '' } : { indices: [], other: 'Dragon fruit' } + ]) + } + ) + it('keeps duplicate labels distinct by their numbered row', () => { const onAnswer = vi.fn() render( diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index 2b36b246883f..67260ba8c895 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -6,6 +6,7 @@ import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { AskAnswerSelection, AskPrompt } from './native-chat-interactive-prompt' import { NativeChatPromptCollapseToggle } from './NativeChatPromptCollapse' +import { NativeChatQuestionOptionRow } from './NativeChatQuestionOptionRow' import { useNativeChatPromptCardFocus } from './use-native-chat-prompt-card-focus' import { useNativeChatQuestionAutoAdvance } from './use-native-chat-question-auto-advance' import { useNativeChatQuestionKeys } from './use-native-chat-question-keys' @@ -23,6 +24,8 @@ export type NativeChatQuestionCardProps = { freeTextInputs?: readonly (AgentJournalFreeTextInput | undefined)[] /** Dismiss the prompt (sends Escape to the agent). */ onCancel: () => void + /** Optional whole-form refusal; unlike Cancel it does not stop the turn. */ + onDecline?: () => void /** Fold the card to a strip and give the input back, writing nothing; Escape does too. */ onCollapse?: () => void /** Take focus when the card takes the input region; number keys pick while it holds. */ @@ -53,6 +56,7 @@ export function NativeChatQuestionCard({ allowOther = true, freeTextInputs, onCancel, + onDecline, onCollapse, shouldFocus = false, answerInputRef @@ -77,15 +81,15 @@ export function NativeChatQuestionCard({ const answerPlaceholder = freeTextInput?.placeholder ?? translate('components.native-chat.question.otherPlaceholder', 'Type your answer') - const acceptsEmpty = (qi: number): boolean => - freeTextInputs?.[qi]?.allowEmpty === true && prompt.questions[qi]?.options.length === 0 + const acceptsEmpty = (qi: number): boolean => freeTextInputs?.[qi]?.allowEmpty === true const typedText = (qi: number, oth = otherText): string => freeTextInputs?.[qi]?.allowEmpty ? (oth[qi] ?? '') : (oth[qi] ?? '').trim() // Picking an option replaces a chosen typed answer on single-select; the text stays in // the field, unsent, until the user types or clicks there again. const typedAnswerChosen = (qi: number, sel = selections, oth = otherText): boolean => - acceptsEmpty(qi) || ((sel[qi] ?? []).includes(TYPED_ANSWER) && typedText(qi, oth).length > 0) + (acceptsEmpty(qi) && !sel[qi]?.some((choice) => choice !== TYPED_ANSWER)) || + ((sel[qi] ?? []).includes(TYPED_ANSWER) && typedText(qi, oth).length > 0) const chooseTypedAnswer = (qi: number): void => { setSelections((prev) => { @@ -93,7 +97,10 @@ export function NativeChatQuestionCard({ if (cur.includes(TYPED_ANSWER)) { return prev } - const chosen = prompt.questions[qi]?.multiSelect ? [...cur, TYPED_ANSWER] : [TYPED_ANSWER] + const question = prompt.questions[qi] + const chosen = question?.multiSelect + ? [...cur.filter((choice) => !question.options[choice]?.exclusive), TYPED_ANSWER] + : [TYPED_ANSWER] return prev.map((s, i) => (i === qi ? chosen : s)) }) } @@ -159,8 +166,10 @@ export function NativeChatQuestionCard({ const picked = !cur.includes(optionIndex) const chosen = !picked ? cur.filter((pickedIndex) => pickedIndex !== optionIndex) - : q.multiSelect - ? [...cur, optionIndex].sort((a, b) => a - b) + : q.multiSelect && !q.options[optionIndex]?.exclusive + ? [...cur.filter((choice) => !q.options[choice]?.exclusive), optionIndex].sort( + (a, b) => a - b + ) : [optionIndex] const next = selections.map((s, i) => (i === index ? chosen : s)) setSelections(next) @@ -294,7 +303,7 @@ export function NativeChatQuestionCard({ the card's right edge instead of crowding the choices. */}
{q.options.map((opt, i) => ( - )} + {onDecline ? ( + + ) : null} - ) -} diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionOptionRow.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionOptionRow.tsx new file mode 100644 index 000000000000..3b8c1408ec24 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQuestionOptionRow.tsx @@ -0,0 +1,51 @@ +import { Check } from 'lucide-react' +import { cn } from '@/lib/utils' + +export function NativeChatQuestionOptionRow({ + badge, + initialFocus, + label, + description, + selected, + disabled, + onSelect +}: { + badge: string + initialFocus: boolean + label: string + description?: string + selected: boolean + disabled: boolean + onSelect: () => void +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx index 93bafb5498c3..e264800ce0e8 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.tsx @@ -62,16 +62,21 @@ export function NativeChatResolutionReceipt({ > {heading ?

{heading}

: null}

- {answer.answer === '' + {answer.copy === 'declined' ? translate( - 'components.native-chat.receipt.emptyAnswer', - NATIVE_CHAT_RECEIPT_COPY.emptyAnswer + 'components.native-chat.receipt.declined', + NATIVE_CHAT_RECEIPT_COPY.declined ) - : (answer.answer ?? - translate( - 'components.native-chat.receipt.unavailable', - NATIVE_CHAT_RECEIPT_COPY.unavailable - ))} + : answer.answer === '' + ? translate( + 'components.native-chat.receipt.emptyAnswer', + NATIVE_CHAT_RECEIPT_COPY.emptyAnswer + ) + : (answer.answer ?? + translate( + 'components.native-chat.receipt.unavailable', + NATIVE_CHAT_RECEIPT_COPY.unavailable + ))}

) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.test.tsx index 9404c25c30a5..f06d01f6a2ef 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.test.tsx @@ -17,15 +17,16 @@ afterEach(() => { container.remove() }) -function render(question: AgentJournalQuestion) { +function render(question: AgentJournalQuestion | AgentJournalQuestion[], declineOptionId?: string) { const onAnswer = vi.fn() const onCancel = vi.fn() act(() => root.render( ) ) @@ -41,6 +42,16 @@ function submit(): void { act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))) } +function clickOption(label: string): void { + const button = [...container.querySelectorAll('button[aria-pressed]')].find( + (node) => node.textContent?.replace(/^\d+\s*/, '').trim() === label + ) + if (!button) { + throw new Error(`Missing option: ${label}`) + } + act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))) +} + const input: AgentJournalQuestion = { id: 'text', question: 'Enter text', @@ -51,6 +62,39 @@ const input: AgentJournalQuestion = { } describe('structured extension question cards', () => { + it('keeps exclusive structured choices separate when encoding their option ids', () => { + const { onAnswer } = render({ + id: 'targets', + question: 'Choose targets', + multiSelect: true, + options: [ + { id: 'web', label: 'Web' }, + { id: 'mobile', label: 'Mobile' }, + { id: 'none', label: 'No selections', exclusive: true } + ] + }) + clickOption('Web') + clickOption('Mobile') + clickOption('No selections') + submit() + expect(onAnswer).toHaveBeenCalledWith({ + kind: 'answers', + answers: [{ questionId: 'targets', optionIds: ['none'] }] + }) + }) + + it('offers decline only for forms and delivers it without requiring field answers or cancelling', () => { + render(input) + expect(container.textContent).not.toContain('Decline request') + const { onAnswer, onCancel } = render(input, 'acp-form-decline') + const decline = [...container.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'Decline request' + )! + act(() => decline.click()) + expect(onAnswer).toHaveBeenCalledWith({ kind: 'option', optionId: 'acp-form-decline' }) + expect(onCancel).not.toHaveBeenCalled() + }) + it('submits an empty input as an answer', () => { const { onAnswer } = render(input) expect(container.querySelector('input')?.placeholder).toBe('Provider placeholder') @@ -102,6 +146,54 @@ describe('structured extension question cards', () => { }) }) + it.each([false, true])( + 'submits optional blank choices without cancelling or attaching blank text to a preset: %s', + (pickLast) => { + const { onAnswer, onCancel } = render([ + { + id: 'first', + question: 'First?', + options: [{ id: 'alpha', label: 'Alpha' }], + multiSelect: false, + freeTextQuestionId: 'first', + freeTextInput: { allowEmpty: true } + }, + { + id: 'second', + question: 'Second?', + options: [{ id: 'beta', label: 'Beta' }], + multiSelect: false, + freeTextQuestionId: 'second', + freeTextInput: { allowEmpty: true } + } + ]) + const advance = () => { + const button = [...container.querySelectorAll('button')].find((node) => + ['Next', 'Skip', 'Submit'].includes(node.textContent?.trim() ?? '') + ) + if (!button) { + throw new Error('Missing question action') + } + act(() => button.click()) + } + advance() + if (pickLast) { + clickOption('Beta') + } + advance() + expect(onAnswer).toHaveBeenCalledWith({ + kind: 'answers', + answers: [ + { questionId: 'first', optionIds: [], other: '' }, + pickLast + ? { questionId: 'second', optionIds: ['beta'] } + : { questionId: 'second', optionIds: [], other: '' } + ] + }) + expect(onCancel).not.toHaveBeenCalled() + } + ) + it('keeps legacy empty input disabled', () => { const { onAnswer } = render({ ...input, freeTextInput: undefined }) expect( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.tsx index 20643c738068..265fdf71a632 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.tsx @@ -7,6 +7,7 @@ export function NativeChatStructuredQuestionCard({ questions, onAnswer, onCancel, + declineOptionId, isSubmitting, shouldFocus, answerInputRef @@ -14,6 +15,7 @@ export function NativeChatStructuredQuestionCard({ questions: readonly AgentJournalQuestion[] onAnswer: (response: AgentSessionPromptResponse) => void onCancel: () => void + declineOptionId?: string isSubmitting?: boolean shouldFocus?: boolean answerInputRef?: RefObject @@ -27,7 +29,8 @@ export function NativeChatStructuredQuestionCard({ multiSelect: question.multiSelect, options: question.options.map((option) => ({ label: option.label, - ...(option.description ? { description: option.description } : {}) + ...(option.description ? { description: option.description } : {}), + ...(option.exclusive ? { exclusive: true } : {}) })) })) }} @@ -43,7 +46,9 @@ export function NativeChatStructuredQuestionCard({ const id = question.options[optionIndex]?.id return id ? [id] : [] }), - ...(other !== undefined && (other.length > 0 || question.freeTextInput?.allowEmpty) + ...(other !== undefined && + (other.length > 0 || + (question.freeTextInput?.allowEmpty && answer?.indices?.length === 0)) ? { other } : {}) } @@ -53,6 +58,9 @@ export function NativeChatStructuredQuestionCard({ } }} onCancel={onCancel} + onDecline={ + declineOptionId ? () => onAnswer({ kind: 'option', optionId: declineOptionId }) : undefined + } isSubmitting={isSubmitting} shouldFocus={shouldFocus} answerInputRef={answerInputRef} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index bcd81ef62faf..2a0b26a5a5d7 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -384,6 +384,7 @@ export function NativeChatStructuredSession( void promptResponse.respond(prompt, response)} isSubmitting={promptResponse.holds(prompt)} onCancel={cancelPrompt} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a9739ece4999..3c4a776818ad 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2945,9 +2945,9 @@ "couldNotStart": "{{agent}} couldn't start.", "couldNotStop": "Couldn't stop {{agent}}.", "couldNotStopTheAgent": "Couldn't stop the agent.", + "commandUnavailable": "This command isn't available in {{agent}} chats, so it was not sent.", "editSavedArguments": "Edit them in Settings > Agents > Arguments.", "emptyMessage": "This message is empty, so it was not sent.", - "commandUnavailable": "This command isn't available in {{agent}} chats, so it was not sent.", "historyInOtherAccount": "This chat's history is in another Claude account. Switch back to that account to continue it.", "historyTooLarge": "This conversation's history is too large to restore here.", "hostFault": "Orca ran into a problem, so this didn't go through.", @@ -3008,6 +3008,7 @@ "receipt": { "cancelled": "Cancelled", "cancelledBy": "Cancelled on {{device}}", + "declined": "Declined request", "emptyAnswer": "Empty answer", "resolved": "Resolved", "resolver": "Answered on {{device}}", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8bf09ce31dc5..e991c09781a3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18501,6 +18501,7 @@ "receipt": { "unavailable": "Selected answer unavailable", "emptyAnswer": "Empty answer", + "declined": "Declined request", "cancelled": "Cancelled", "resolved": "Resolved", "resolver": "Answered on {{device}}", @@ -18848,7 +18849,8 @@ "send": "Submit", "next": "Next", "skip": "Skip", - "sending": "Sending…" + "sending": "Sending…", + "decline": "Decline request" }, "ask": { "awaiting": "Awaiting user input:", diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 5cd2f87caf2f..f99a7216038e 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -142,7 +142,8 @@ const Block = openDiscriminatedUnion( const PromptOption = z.object({ id: z.string(), label: z.string(), - description: z.string().optional() + description: z.string().optional(), + exclusive: z.boolean().optional() }) const FreeTextInput = z.object({ @@ -254,6 +255,7 @@ const KnownItemBody = z.discriminatedUnion('kind', [ questions: z.array(Question).optional(), freeTextQuestionId: z.string().optional(), freeTextInput: FreeTextInput.optional(), + declineOptionId: z.string().optional(), resolution: Resolution }), z.object({ diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 1f556e922528..d8b7c8b95f49 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -179,6 +179,8 @@ export type AgentJournalPromptOption = { id: string label: string description?: string + /** Selecting this option excludes every other answer to the question. */ + exclusive?: boolean } export type AgentJournalFreeTextInput = { @@ -246,6 +248,8 @@ export type AgentJournalQuestionItem = { /** Present when the provider accepts an answer outside the offered options. */ freeTextQuestionId?: string freeTextInput?: AgentJournalFreeTextInput + /** Optional whole-form refusal action, separate from answers and turn cancellation. */ + declineOptionId?: string resolution: AgentJournalResolution } diff --git a/src/shared/agent-session-question-answer.ts b/src/shared/agent-session-question-answer.ts index 43868c7937b5..409ed1f7bb48 100644 --- a/src/shared/agent-session-question-answer.ts +++ b/src/shared/agent-session-question-answer.ts @@ -1,11 +1,11 @@ import type { AgentJournalQuestion, AgentJournalQuestionItem } from './agent-session-journal-types' -const GROUP_ANSWER_PREFIX = 'question-group:' +export const GROUP_ANSWER_PREFIX = 'question-group:' /** A single-question item has no question list; the client and host must agree on its id. */ const SINGLE_QUESTION_ID = 'q1' -/** Longest option id a host takes, which also bounds an answer packed into one. */ +/** Longest scalar option id a host takes; grouped answers have separate structured bounds. */ export const AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH = 1024 /** Largest typed answer to one question, in UTF-8 bytes. */ @@ -191,6 +191,13 @@ export function isValidAgentSessionQuestionAnswers( return false } const answerCount = answer.optionIds.length + (hasOther ? 1 : 0) - return answerCount > 0 && (question.multiSelect || answerCount === 1) + return ( + answerCount > 0 && + (answerCount === 1 || + (question.multiSelect && + !question.options.some( + (option) => option.exclusive && answer.optionIds.includes(option.id) + ))) + ) }) } diff --git a/src/shared/native-chat-ask-types.ts b/src/shared/native-chat-ask-types.ts index 9c5942ab7de0..244d7a6e6379 100644 --- a/src/shared/native-chat-ask-types.ts +++ b/src/shared/native-chat-ask-types.ts @@ -1,7 +1,7 @@ // Canonical AskUserQuestion prompt types consumed by the shared parser and both // native-chat platform UIs. -export type AskOption = { label: string; description?: string } +export type AskOption = { label: string; description?: string; exclusive?: boolean } export type AskQuestion = { question: string header?: string diff --git a/src/shared/native-chat-resolution-receipt.ts b/src/shared/native-chat-resolution-receipt.ts index 740f941f7257..7270960a74f2 100644 --- a/src/shared/native-chat-resolution-receipt.ts +++ b/src/shared/native-chat-resolution-receipt.ts @@ -11,6 +11,7 @@ import type { NativeChatAskRowSubject } from './native-chat-ask-row' /** Desktop's fallbacks for its translations; the phone's words. */ export const NATIVE_CHAT_RECEIPT_COPY = { emptyAnswer: 'Empty answer', + declined: 'Declined request', unavailable: 'Selected answer unavailable', resolved: 'Resolved', cancelled: 'Cancelled', @@ -19,8 +20,12 @@ export const NATIVE_CHAT_RECEIPT_COPY = { } as const export type NativeChatResolvedPrompt = AgentJournalApprovalItem | AgentJournalQuestionItem -/** `answer` is '' for an explicitly empty text answer and null when the answer cannot be read. */ -export type NativeChatReceiptAnswer = { question: string | null; answer: string | null } +/** Fixed copy never occupies user text; '' is an empty answer, null has no text. */ +export type NativeChatReceiptAnswer = { + question: string | null + answer: string | null + copy?: 'declined' +} export function nativeChatReceiptAnswers( body: NativeChatResolvedPrompt @@ -30,6 +35,9 @@ export function nativeChatReceiptAnswers( } const selected = body.resolution.selectedOptionId if (body.kind === 'question') { + if (body.declineOptionId && selected === body.declineOptionId) { + return [{ question: null, answer: null, copy: 'declined' }] + } // Rows written before hosts recorded structured answers carry only the packed form. const answers = body.resolution.answers ?? diff --git a/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts b/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts index bdcbdedaaa38..0d70689a0d2e 100644 --- a/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts +++ b/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts @@ -5,9 +5,14 @@ import { CreateIntentParams, CreateSupportParams, ModelCatalogParams, - RespondToQuestionParams + RespondToQuestionParams, + MAX_PROMPT_BYTES, + MAX_RESPONSE_OPTION_ID_LENGTH } from './structured-agent-session-params' -import { AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES } from '../agent-session-question-answer' +import { + AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES, + encodeAgentSessionQuestionAnswers +} from '../agent-session-question-answer' const ENVELOPE = { sessionId: 'agent-session-0123456789abcdef0123456789abcdef', @@ -17,10 +22,9 @@ const ENVELOPE = { } describe('structured agent params', () => { - it('keeps normal question answers valid and bounds the combined answer body after schema extraction', () => { + it('accepts bounded question answers and rejects oversized combined bodies', () => { const base = { envelope: ENVELOPE, itemId: 'question-1', expectedRevision: 1 } const ordinary = { ...base, answers: [{ questionId: 'q1', optionIds: ['yes'], other: 'why' }] } - expect(() => RespondToQuestionParams.safeParse(ordinary)).not.toThrow() expect(RespondToQuestionParams.safeParse(ordinary).success).toBe(true) const oversized = { ...base, @@ -32,6 +36,70 @@ describe('structured agent params', () => { } expect(RespondToQuestionParams.safeParse(oversized).success).toBe(false) }) + + it('rejects oversized scalar ids and invalid packed answers past the scalar bound', () => { + const base = { envelope: ENVELOPE, itemId: 'question-1', expectedRevision: 1 } + const answer = { questionId: 'q1', optionIds: ['yes'] } + const oversizedAnswers = Array.from({ length: 4 }, (_, index) => ({ + questionId: `q${index + 1}`, + optionIds: [], + other: 'x'.repeat(AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES) + })) + expect(JSON.stringify(oversizedAnswers).length).toBeGreaterThan(MAX_PROMPT_BYTES) + for (const optionId of [ + 'x'.repeat(MAX_RESPONSE_OPTION_ID_LENGTH + 1), + ' yes', + 'yes ', + '\tyes\n', + `question-group:${'not-json'.repeat(MAX_RESPONSE_OPTION_ID_LENGTH)}`, + `question-group:%${' '.repeat(MAX_RESPONSE_OPTION_ID_LENGTH)}`, + `${encodeAgentSessionQuestionAnswers([])}${' '.repeat(MAX_RESPONSE_OPTION_ID_LENGTH)}`, + `${encodeAgentSessionQuestionAnswers(Array.from({ length: 5 }, () => answer))}${' '.repeat(MAX_RESPONSE_OPTION_ID_LENGTH)}`, + `${encodeAgentSessionQuestionAnswers([{ ...answer, optionIds: Array(65).fill('yes') }])}${' '.repeat(MAX_RESPONSE_OPTION_ID_LENGTH)}`, + encodeAgentSessionQuestionAnswers([{ ...answer, questionId: 'x'.repeat(1025) }]), + encodeAgentSessionQuestionAnswers([{ ...answer, optionIds: ['x'.repeat(1025)] }]), + encodeAgentSessionQuestionAnswers([ + { ...answer, other: 'é'.repeat(AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES) } + ]), + encodeAgentSessionQuestionAnswers(oversizedAnswers), + `question-group:${' '.repeat(MAX_PROMPT_BYTES * 3)}[]` + ]) { + expect(RespondToQuestionParams.safeParse({ ...base, optionId }).success).toBe(false) + } + expect( + RespondToQuestionParams.safeParse({ + ...base, + optionId: 'x'.repeat(MAX_RESPONSE_OPTION_ID_LENGTH) + }).success + ).toBe(true) + }) + + it('preserves ordinary question option ids verbatim without accepting padding', () => { + const base = { envelope: ENVELOPE, itemId: 'question-1', expectedRevision: 1 } + for (const optionId of ['yes', 'question-group:custom']) { + expect(RespondToQuestionParams.parse({ ...base, optionId }).optionId).toBe(optionId) + } + expect(RespondToQuestionParams.safeParse({ ...base, optionId: ' yes ' }).success).toBe(false) + }) + + it('accepts current and fully percent-encoded packed answers within the structured byte budget', () => { + const answers = [ + { + questionId: 'q1', + optionIds: [], + other: '%'.repeat(AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES) + } + ] + const base = { envelope: ENVELOPE, itemId: 'question-1', expectedRevision: 1 } + for (const optionId of [ + encodeAgentSessionQuestionAnswers(answers), + `question-group:${encodeURIComponent(JSON.stringify(answers))}` + ]) { + expect(optionId.length).toBeGreaterThan(MAX_RESPONSE_OPTION_ID_LENGTH) + expect(RespondToQuestionParams.parse({ ...base, optionId }).optionId).toBe(optionId) + } + }) + it('accepts only the same bounded user message a send accepts, with bounded options', () => { const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'opening text' }] } const params = { diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index da29e7ab640c..36665fc8e4e1 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -1,8 +1,4 @@ -import { - SendBody, - MAX_OPTION_LABEL, - MAX_PROMPT_BYTES -} from './structured-agent-session-message-params' +import { SendBody, MAX_OPTION_LABEL } from './structured-agent-session-message-params' export { SendBlock, MAX_BLOCKS, @@ -26,23 +22,16 @@ import { isAgentSessionSurfaceTabId } from '../agent-session-surface-tab-id' import { isAgentSessionOptions } from '../agent-session-record' import { isStructuredAgentId } from '../agent-session-provider-handle-encoding' import { normalizeExecutionHostId } from '../execution-host' -import { - AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES, - AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH -} from '../agent-session-question-answer' +import { AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH } from '../agent-session-question-answer' +import { QuestionAnswers, QuestionOptionId } from './structured-agent-session-question-answers' import { AGENT_SESSION_HISTORY_DIRECTIONS, AGENT_SESSION_HISTORY_MAX_LIMIT } from '../agent-session-wire' import { AGENT_SESSION_THREAD_GOAL_OBJECTIVE_MAX_LENGTH } from '../agent-session-thread-goal-wire' -// Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. export const MAX_RESPONSE_OPTION_ID_LENGTH = AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH -/** Matches the journal's bounds on one grouped prompt. */ -const MAX_QUESTION_ANSWER_QUESTIONS = 4 -const MAX_QUESTION_ANSWER_OPTIONS = 64 - /** One relaunch cannot offer more chats than a profile plausibly holds. */ export const MAX_RESTART_RESUME_SESSIONS = 512 @@ -224,49 +213,20 @@ export const RespondParams = z }) .strict() -const QuestionAnswer = z - .object({ - // Codex question ids are model-written and untrimmed; the host matches them exactly. - questionId: z - .string() - .min(1, 'Invalid question id') - .max(MAX_RESPONSE_OPTION_ID_LENGTH, 'Invalid question id'), - optionIds: z - .array(Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH)) - .max(MAX_QUESTION_ANSWER_OPTIONS), - // Hashed verbatim by both peers, so no trim or transform here. - other: z - .string() - .max(AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES) - .refine( - (value) => Buffer.byteLength(value, 'utf8') <= AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES, - 'Answer is too large' - ) - .optional() - }) - .strict() - export const RespondToQuestionParams = z .object({ envelope: MutationEnvelope, itemId: Identifier('Invalid item id'), expectedRevision: z.number().int().positive(), /** An answer packed into one id, from clients that predate `answers`. */ - optionId: Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH).optional(), - answers: z.array(QuestionAnswer).min(1).max(MAX_QUESTION_ANSWER_QUESTIONS).optional() + optionId: QuestionOptionId.optional(), + answers: QuestionAnswers.optional() }) .strict() - .superRefine((value, ctx) => { - if ((value.optionId === undefined) === (value.answers === undefined)) { - ctx.addIssue({ code: 'custom', message: 'Send exactly one of an option id or answers' }) - } - if ( - value.answers !== undefined && - Buffer.byteLength(JSON.stringify(value.answers), 'utf8') > MAX_PROMPT_BYTES - ) { - ctx.addIssue({ code: 'custom', message: 'Answer is too large' }) - } - }) + .refine( + (value) => (value.optionId === undefined) !== (value.answers === undefined), + 'Send exactly one of an option id or answers' + ) export const SetOptionParams = z .object({ diff --git a/src/shared/rpc-contract/structured-agent-session-question-answers.ts b/src/shared/rpc-contract/structured-agent-session-question-answers.ts new file mode 100644 index 000000000000..ca8eb2840b76 --- /dev/null +++ b/src/shared/rpc-contract/structured-agent-session-question-answers.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' +import { Identifier } from './structured-agent-session-identifiers' +import { MAX_PROMPT_BYTES } from './structured-agent-session-message-params' +import { + AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES, + AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH as MAX_RESPONSE_OPTION_ID_LENGTH, + decodeAgentSessionQuestionAnswers, + GROUP_ANSWER_PREFIX +} from '../agent-session-question-answer' + +/** Matches the journal's bounds on one grouped prompt. */ +const MAX_QUESTION_ANSWER_QUESTIONS = 4 +const MAX_QUESTION_ANSWER_OPTIONS = 64 + +const QuestionAnswer = z + .object({ + // Codex question ids are model-written and untrimmed; the host matches them exactly. + questionId: z + .string() + .min(1, 'Invalid question id') + .max(MAX_RESPONSE_OPTION_ID_LENGTH, 'Invalid question id'), + optionIds: z + .array(Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH)) + .max(MAX_QUESTION_ANSWER_OPTIONS), + // Hashed verbatim by both peers, so no trim or transform here. + other: z + .string() + .max(AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES) + .refine( + (value) => Buffer.byteLength(value, 'utf8') <= AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES, + 'Answer is too large' + ) + .optional() + }) + .strict() + +export const QuestionAnswers = z + .array(QuestionAnswer) + .min(1) + .max(MAX_QUESTION_ANSWER_QUESTIONS) + .refine( + (answers) => Buffer.byteLength(JSON.stringify(answers), 'utf8') <= MAX_PROMPT_BYTES, + 'Answer is too large' + ) + +const ScalarQuestionOptionId = Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH) +export const QuestionOptionId = z.string().refine((value) => { + if ( + value.length <= MAX_RESPONSE_OPTION_ID_LENGTH && + ScalarQuestionOptionId.safeParse(value).success + ) { + return true + } + // Older clients percent-encode every UTF-8 byte; cap before decoding their JSON. + if (value.length > GROUP_ANSWER_PREFIX.length + MAX_PROMPT_BYTES * 3) { + return false + } + const answers = decodeAgentSessionQuestionAnswers(value) + return answers !== null && QuestionAnswers.safeParse(answers).success +}, 'Invalid option id') From 3e3b3ed2ad7675f1254f17da7aeff91d8e83d5cd Mon Sep 17 00:00:00 2001 From: Narry <3047793+NarryG@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:13:14 -0400 Subject: [PATCH 2/4] docs(native-chat): clarify form decline and skip actions --- docs/site/content/docs/agents/native-chat.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/site/content/docs/agents/native-chat.mdx b/docs/site/content/docs/agents/native-chat.mdx index d2e1c803e013..57fdb5544c15 100644 --- a/docs/site/content/docs/agents/native-chat.mdx +++ b/docs/site/content/docs/agents/native-chat.mdx @@ -25,7 +25,7 @@ Toggle Chat UI ↔ terminal from the agent pane once enabled. When Claude shows an **AskUserQuestion** (or similar structured permission/question card), Chat UI should render the question card in the transcript so you can answer from the composer path instead of treating it as a collapsed tool call. Confirm with **Submit** on the card. That includes agents running on a paired [Remote Orca Server](/docs/remote-servers) / headless host — not only local panes. -OMP ACP forms use the same desktop and mobile cards for choices, text, and confirmations. Optional answers can be left unset; choosing an empty value or **No selections** is distinct from leaving a choice unset. **Decline** replies to the form; **Cancel** stops it. Full access does not answer these human decisions automatically. +OMP ACP forms use the same desktop and mobile cards for choices, text, and confirmations. Optional answers can be left unset; choosing an empty value or **No selections** is distinct from leaving a choice unset. **Decline request** replies to the form; **Cancel** dismisses the prompt and stops the turn. On desktop, **Skip** advances past an unanswered question without discarding other answers. On the final question it attempts submission if any answers exist, but all form questions must have an answer before submission; choose **Leave unset** for an optional choice. If no answers exist, clicking **Skip** dismisses the prompt and stops the turn. Full access does not answer these human decisions automatically. Stopping the turn withdraws its open cards and rejects late answers. OMP versions whose ACP transport does not send a form-cancellation notification cannot notify Orca of their own dialog timeout; dismiss the remaining card or stop the turn. From 508f560ce6af021d447002454e233d72615db813 Mon Sep 17 00:00:00 2001 From: Narry <3047793+NarryG@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:44:15 -0400 Subject: [PATCH 3/4] fix(omp): label explicit empty form choices --- docs/site/content/docs/agents/native-chat.mdx | 2 ++ src/main/acp/acp-form-elicitation.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/site/content/docs/agents/native-chat.mdx b/docs/site/content/docs/agents/native-chat.mdx index 57fdb5544c15..94dce5f2d13d 100644 --- a/docs/site/content/docs/agents/native-chat.mdx +++ b/docs/site/content/docs/agents/native-chat.mdx @@ -27,6 +27,8 @@ When Claude shows an **AskUserQuestion** (or similar structured permission/quest OMP ACP forms use the same desktop and mobile cards for choices, text, and confirmations. Optional answers can be left unset; choosing an empty value or **No selections** is distinct from leaving a choice unset. **Decline request** replies to the form; **Cancel** dismisses the prompt and stops the turn. On desktop, **Skip** advances past an unanswered question without discarding other answers. On the final question it attempts submission if any answers exist, but all form questions must have an answer before submission; choose **Leave unset** for an optional choice. If no answers exist, clicking **Skip** dismisses the prompt and stops the turn. Full access does not answer these human decisions automatically. +An offered empty-string choice without a label appears as **Empty value**; selecting it still sends an empty string, not an omitted answer. + Stopping the turn withdraws its open cards and rejects late answers. OMP versions whose ACP transport does not send a form-cancellation notification cannot notify Orca of their own dialog timeout; dismiss the remaining card or stop the turn. ## Availability diff --git a/src/main/acp/acp-form-elicitation.ts b/src/main/acp/acp-form-elicitation.ts index 16b5bb050fd4..d7aa52cc6338 100644 --- a/src/main/acp/acp-form-elicitation.ts +++ b/src/main/acp/acp-form-elicitation.ts @@ -127,7 +127,7 @@ export function acpFormPresentation(params: unknown): AcpRequestPresentation { ? [ ...choices.map((choice, choiceIndex) => ({ id: `o${choiceIndex + 1}`, - label: choice.title ?? choice.const, + label: (choice.title ?? choice.const) || 'Empty value', ...(choice.description ? { description: choice.description } : {}) })), ...(property.type === 'array' From ad2a53d4a0df9c804498c0503106e29ecae39457 Mon Sep 17 00:00:00 2001 From: Narry <3047793+NarryG@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:52:06 -0400 Subject: [PATCH 4/4] fix(omp): retain choice value when its title is empty --- src/main/acp/acp-form-elicitation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/acp/acp-form-elicitation.ts b/src/main/acp/acp-form-elicitation.ts index d7aa52cc6338..693817bca6bb 100644 --- a/src/main/acp/acp-form-elicitation.ts +++ b/src/main/acp/acp-form-elicitation.ts @@ -127,7 +127,7 @@ export function acpFormPresentation(params: unknown): AcpRequestPresentation { ? [ ...choices.map((choice, choiceIndex) => ({ id: `o${choiceIndex + 1}`, - label: (choice.title ?? choice.const) || 'Empty value', + label: choice.title || choice.const || 'Empty value', ...(choice.description ? { description: choice.description } : {}) })), ...(property.type === 'array'