diff --git a/docs/site/content/docs/agents/native-chat.mdx b/docs/site/content/docs/agents/native-chat.mdx index 1a812697e0ad..94dce5f2d13d 100644 --- a/docs/site/content/docs/agents/native-chat.mdx +++ b/docs/site/content/docs/agents/native-chat.mdx @@ -25,6 +25,12 @@ 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 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 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..693817bca6bb --- /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 || 'Empty value', + ...(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')