From f72079bd21f03aaa1568df98a03bd74fa114607e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:17:38 -0700 Subject: [PATCH] fix(mobile): send typed question answers as structured answers (#23458) * fix(mobile): send typed question answers as structured answers The phone packed a typed answer into agentSession.respondToQuestion's `optionId`, a field capped at 1024 characters, so a long typed answer was refused and never reached the agent. The phone now builds per-question answers for single and grouped questions and sends them as `answers` when the host advertises agent-session.question-answers.v1, falling back to the packed `optionId` for older hosts. An answer too long to pack is sent as `answers` while the host's support is still unknown, and on a host known to predate the field the phone asks the user to update the computer instead of sending an answer the host must refuse. The host features the phone negotiates for structured sessions (prompt cancel, question answers) travel as one required object from the shared capability probe instead of one optional boolean each. * test(mobile): cover a long typed answer on a grouped question and the default question id * fix(mobile): keep the chat controller's host support argument optional * fix(mobile): refuse a grouped answer too long for an older host on the step that overflows Packed grouped answers only grow, so an overflow on an earlier step could never reach an older host. Refusing it only at the final submit left the long text folded into the draft with no way to shorten it short of cancelling the question. * fix(mobile): refuse a grouped step that leaves no room for the rest on an older host * refactor(mobile): check the packed answer length once, at send --- ...e-dictation-refused-start-routing.test.tsx | 2 +- .../mobile-session-route-parity.test.ts | 16 +- .../mobile-structured-agent-prompts.ts | 42 ++- ...uctured-agent-session-host-support.test.ts | 21 ++ ...e-structured-agent-session-host-support.ts | 19 ++ ...mobile-structured-grouped-question.test.ts | 19 +- .../mobile-structured-grouped-question.ts | 9 +- .../use-mobile-native-chat-controller.ts | 7 +- .../use-mobile-native-chat-session-lane.ts | 7 +- ...se-mobile-session-feedback-capabilities.ts | 12 +- ...se-mobile-session-native-chat-dictation.ts | 4 +- .../use-mobile-session-tab-reconciliation.ts | 16 +- ...tured-agent-session-prompt-cancel.test.tsx | 36 ++- .../use-mobile-structured-agent-session.ts | 9 +- ...obile-structured-prompt-responses.test.tsx | 306 +++++++++++++++++- .../use-mobile-structured-prompt-responses.ts | 72 ++++- src/shared/agent-session-question-answer.ts | 3 + .../structured-agent-session-params.ts | 7 +- 18 files changed, 524 insertions(+), 83 deletions(-) create mode 100644 mobile/src/session/mobile-structured-agent-session-host-support.test.ts create mode 100644 mobile/src/session/mobile-structured-agent-session-host-support.ts diff --git a/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx b/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx index b073eda047d..41dc8b5d11f 100644 --- a/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx +++ b/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx @@ -92,7 +92,7 @@ function mount(client: FakeRpcClient): Mounted { worktreeId: 'w1', client, connState: 'connected', - agentSessionPromptCancelSupported: true, + agentSessionHostSupport: { promptCancel: true, questionAnswers: false }, setInput: () => {}, liveInputTerminalHandles: new Set(), activeHandle: 't1', diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index e2f8b478cc0..7523976132b 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -93,7 +93,8 @@ const HOST_COMPONENT_NAMES = new Set([ // Moved, count unchanged, when the Markdown actions' Back `useEffect` became `useBackClaim`, the // seam that also claims the key on the page while a draft is dirty. const HEAD_MAIN_HOOK_SHA256 = 'f161e14a9c53d80c3dc75f51dd8ecb339b59b7239c9c3e067791b8612f51ede2' -const HEAD_HOOK_BINDING_SHA256 = '7a9e256e2058635850253a74b9faa05bcafbbcaa1e5b0b3ef7f677dde85753a2' +// Moved when the prompt-cancel flag became one structured-session host support object. +const HEAD_HOOK_BINDING_SHA256 = 'db9f32cc60fc68adbcbb2acf9f9384ad0d78bbc6feef5d581449fadb647405fc' const HEAD_CALLBACK_IDENTITY_SHA256 = '373dca17a060e63d8cb4e32416ca2889b8404cee78f7b47e632940a9980baf23' // Pins that no callback body in the route changed unnoticed. Body text, not behaviour: the sends @@ -125,7 +126,8 @@ const HEAD_CALLBACK_BODY_SHA256 = '2ccbfb5ee57e7dfeb07dafaee6fa592b95862b3bc898a // the diff-comments effect, which now catches the loader's rejection. Count unchanged. // Moved again by the keyboard seam above, which is the +1 effect. // -1 effect for the Markdown actions' Back registration, which is `useBackClaim`'s own now. -const HEAD_EFFECT_SHA256 = '69096e20a44fa03a2c364e00a617eeabc437a79bb850b9135408d35fbd4d4a71' +// Moved by the capability probe setting that host support object. +const HEAD_EFFECT_SHA256 = '9b045a547ed269acf95db16cc87e33a9035a20c6888fd30e0363e58bb6b7d883' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' // Same pin for the 12 bodies that sit in nested functions rather than callbacks, moved by the same // rewrite of those send and read expressions. Count unchanged. Refreshed again in step 6 for @@ -181,7 +183,9 @@ const HEAD_STYLE_REFERENCE_SHA256 = const HEAD_IDENTITY_FIELD_SHA256 = '91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6' const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512' -const HEAD_CAPABILITY_SHA256 = '67c3154b71b542bb63a4365d3ea75aef19ef133c02f509318619618221786fab' +// Moved when structured-session features became one helper call; the capability strings it reads +// are pinned by that helper's own test. +const HEAD_CAPABILITY_SHA256 = 'ec1159d6e726383bf7121e9c642657fee5b4ebec049cb44ba132a303bb9a61e2' type Definition = { declaration: ts.FunctionDeclaration; sourceFile: ts.SourceFile } type HookFacts = { @@ -550,7 +554,11 @@ function readCompatibilityFacts(definitions: ReadonlyMap): { : '' const callText = canonical(node, sourceFile) if ( - ['startRuntimeCapabilityProbe', 'supportsMobileQuickCommands'].includes(callName) || + [ + 'startRuntimeCapabilityProbe', + 'supportsMobileQuickCommands', + 'structuredAgentSessionHostSupport' + ].includes(callName) || (callName === 'includes' && callText.includes('capabilities.includes')) ) { capabilities.push(callText) diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts index d11a2fc0317..649fc06b774 100644 --- a/mobile/src/session/mobile-structured-agent-prompts.ts +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -1,4 +1,8 @@ import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import { + agentSessionPromptQuestions, + type AgentSessionQuestionAnswer +} from '../../../src/shared/agent-session-question-answer' import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' import { @@ -21,6 +25,12 @@ export type StructuredPromptResponseTarget = { optionId: string } +export type StructuredQuestionResponseTarget = { + itemId: string + expectedRevision: number + answer: AgentSessionQuestionAnswer +} + type PromptTokenPayload = | { kind: 'approval' @@ -32,6 +42,7 @@ type PromptTokenPayload = kind: 'question-option' itemId: string revision: number + questionId: string optionId: string } | { @@ -55,10 +66,6 @@ export function pendingStructuredQuestion( return item.body.kind === 'question' && item.body.resolution.state === 'pending' } -function encodeQuestionAnswer(questionId: string, answer: string): string { - return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` -} - function encodePromptToken(payload: PromptTokenPayload): string { return `${STRUCTURED_PROMPT_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}` } @@ -86,11 +93,16 @@ function decodePromptToken(value: string): PromptTokenPayload | null { optionId: decoded.optionId } } - if (decoded.kind === 'question-option' && typeof decoded.optionId === 'string') { + 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 } } @@ -169,6 +181,10 @@ export function projectStructuredQuestion( { itemId: prompt.itemId, expectedRevision: prompt.revision } ) } + const [question] = agentSessionPromptQuestions(prompt.body) + if (!question) { + return null + } const optionDescriptions = prompt.body.options.map((option) => option.description) return { question: prompt.body.question, @@ -182,6 +198,7 @@ export function projectStructuredQuestion( kind: 'question-option', itemId: prompt.itemId, revision: prompt.revision, + questionId: question.id, optionId: option.id }) ), @@ -228,13 +245,13 @@ export function structuredApprovalResponseTarget( export function structuredQuestionResponseTarget( response: string, currentPrompt: StructuredQuestionItem | null -): StructuredPromptResponseTarget | null { +): StructuredQuestionResponseTarget | null { const token = decodePromptToken(response) if (token?.kind === 'question-option') { return { itemId: token.itemId, expectedRevision: token.revision, - optionId: token.optionId + answer: { questionId: token.questionId, optionIds: [token.optionId] } } } if (token) { @@ -247,29 +264,30 @@ export function structuredQuestionResponseTarget( ? { itemId: freeText.payload.itemId, expectedRevision: freeText.payload.revision, - optionId: encodeQuestionAnswer(freeText.payload.questionId, answer) + answer: { questionId: freeText.payload.questionId, optionIds: [], other: answer } } : null } if (!currentPrompt) { return null } + const [question] = agentSessionPromptQuestions(currentPrompt.body) const trimmed = response.trim() const option = currentPrompt.body.options.find( (candidate) => candidate.id === response || candidate.label === trimmed ) - if (option) { + if (question && option) { return { itemId: currentPrompt.itemId, expectedRevision: currentPrompt.revision, - optionId: option.id + answer: { questionId: question.id, optionIds: [option.id] } } } - return currentPrompt.body.freeTextQuestionId && trimmed + return question && currentPrompt.body.freeTextQuestionId && trimmed ? { itemId: currentPrompt.itemId, expectedRevision: currentPrompt.revision, - optionId: encodeQuestionAnswer(currentPrompt.body.freeTextQuestionId, trimmed) + answer: { questionId: question.id, optionIds: [], other: trimmed } } : null } diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts new file mode 100644 index 00000000000..6857855c9b4 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' +import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' + +describe('structuredAgentSessionHostSupport', () => { + it('reads each structured-session feature from the host capability list', () => { + expect(structuredAgentSessionHostSupport([])).toEqual({ + promptCancel: false, + questionAnswers: false + }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY]) + ).toEqual({ promptCancel: false, questionAnswers: true }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY]) + ).toEqual({ promptCancel: true, questionAnswers: false }) + }) +}) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts new file mode 100644 index 00000000000..939f7d559f4 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -0,0 +1,19 @@ +import { + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' + +/** Structured-session features the connected host advertised; null until the status probe answers. */ +export type StructuredAgentSessionHostSupport = { + promptCancel: boolean + questionAnswers: boolean +} + +export function structuredAgentSessionHostSupport( + capabilities: readonly string[] +): StructuredAgentSessionHostSupport { + return { + promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY), + questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY) + } +} diff --git a/mobile/src/session/mobile-structured-grouped-question.test.ts b/mobile/src/session/mobile-structured-grouped-question.test.ts index 45a365864d9..37a4dc125c2 100644 --- a/mobile/src/session/mobile-structured-grouped-question.test.ts +++ b/mobile/src/session/mobile-structured-grouped-question.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalQuestion } from '../../../src/shared/agent-session-journal-types' -import { decodeAgentSessionQuestionAnswers } from '../../../src/shared/agent-session-question-answer' import { formatQuestionAnswer, formatQuestionFreeTextAnswer, @@ -85,7 +84,7 @@ describe('mobile structured grouped questions', () => { expect(second).toMatchObject({ question: 'Which regions? (2 of 2)', multiSelect: true }) }) - it('submits the whole group as one encoded answer on the last step', () => { + it('submits the whole group as one set of answers on the last step', () => { const questions = [question(), SECOND] const draft: GroupedQuestionDraft = { promptKey: PROMPT_KEY, @@ -102,9 +101,7 @@ describe('mobile structured grouped questions', () => { }) expect(result?.kind).toBe('submit') - expect( - decodeAgentSessionQuestionAnswers(result?.kind === 'submit' ? result.optionId : '') - ).toEqual([ + expect(result?.kind === 'submit' ? result.answers : null).toEqual([ { questionId: 'q1', optionIds: ['q1:choice-1'] }, { questionId: 'q2', optionIds: ['q2:choice-1', 'q2:choice-2'] } ]) @@ -121,9 +118,9 @@ describe('mobile structured grouped questions', () => { promptKey: PROMPT_KEY }) - expect( - decodeAgentSessionQuestionAnswers(result?.kind === 'submit' ? result.optionId : '') - ).toEqual([{ questionId: 'q1', optionIds: [], other: 'DuckDB' }]) + expect(result?.kind === 'submit' ? result.answers : null).toEqual([ + { questionId: 'q1', optionIds: [], other: 'DuckDB' } + ]) }) it('keeps selected options and other text for grouped multi-select answers', () => { @@ -137,9 +134,9 @@ describe('mobile structured grouped questions', () => { promptKey: PROMPT_KEY }) - expect( - decodeAgentSessionQuestionAnswers(result?.kind === 'submit' ? result.optionId : '') - ).toEqual([{ questionId: 'q2', optionIds: ['q2:choice-1'], other: 'ap-south' }]) + expect(result?.kind === 'submit' ? result.answers : null).toEqual([ + { questionId: 'q2', optionIds: ['q2:choice-1'], other: 'ap-south' } + ]) }) it('gives each step a distinct card key so a selection cannot carry into the next question', () => { diff --git a/mobile/src/session/mobile-structured-grouped-question.ts b/mobile/src/session/mobile-structured-grouped-question.ts index 031cdf3f10f..d111b4de95a 100644 --- a/mobile/src/session/mobile-structured-grouped-question.ts +++ b/mobile/src/session/mobile-structured-grouped-question.ts @@ -1,6 +1,5 @@ import type { AgentJournalQuestion } from '../../../src/shared/agent-session-journal-types' import { - encodeAgentSessionQuestionAnswers, isValidAgentSessionQuestionAnswers, type AgentSessionQuestionAnswer } from '../../../src/shared/agent-session-question-answer' @@ -11,7 +10,7 @@ import type { MobileChatQuestion } from './mobile-native-chat-question' * prompt. The host then leaves the flat `question.options` EMPTY and puts the real content in * `questions`, so a client that reads only the flat shape renders an unanswerable card and the turn * stalls. The phone has room for one question at a time, so the group is answered as steps and - * submitted once — the host accepts the whole group as one encoded option id. + * submitted once as one set of answers. */ export type GroupedQuestionDraft = { /** Identifies the exact prompt revision these answers belong to; a revised prompt discards them. */ @@ -21,7 +20,7 @@ export type GroupedQuestionDraft = { export type GroupedQuestionAdvance = | { kind: 'advance'; draft: GroupedQuestionDraft } - | { kind: 'submit'; optionId: string } + | { kind: 'submit'; answers: AgentSessionQuestionAnswer[] } const GROUPED_TOKEN_PREFIX = 'structured-grouped-question:' @@ -195,7 +194,7 @@ function answerFromResponse( /** * Fold one answer into the draft. Returns `advance` while questions remain and `submit` with the - * encoded group once the last one lands; null when the response does not answer this prompt step. + * whole group once the last one lands; null when the response does not answer this prompt step. */ export function advanceGroupedQuestion(args: { response: string @@ -218,6 +217,6 @@ export function advanceGroupedQuestion(args: { } // Never send a group the host would refuse — the user would see a silent failure with no way back. return isValidAgentSessionQuestionAnswers(args.questions, answers) - ? { kind: 'submit', optionId: encodeAgentSessionQuestionAnswers(answers) } + ? { kind: 'submit', answers } : null } diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index a3a49e53552..dfefcf47627 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -2,6 +2,7 @@ import { useLayoutEffect, useRef, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import type { MobileNativeChatTab } from './mobile-native-chat-eligibility' +import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' @@ -36,7 +37,7 @@ export function useMobileNativeChatController(args: { /** Live socket state; the lease collapses on disconnect but one render later. */ connState: ConnectionState /** Host capability fact from the shared runtime status probe. */ - agentSessionPromptCancelSupported?: boolean | null + agentSessionHostSupport?: StructuredAgentSessionHostSupport | null onSendError: (message: string) => void /** Retires a held failure banner. Any accepted chat write clears it — a delivered * answer or permission reply must not sit under a stale "not sent". */ @@ -53,7 +54,7 @@ export function useMobileNativeChatController(args: { nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, connState, - agentSessionPromptCancelSupported = null, + agentSessionHostSupport = null, onSendError, onSendResolved } = args @@ -93,7 +94,7 @@ export function useMobileNativeChatController(args: { callerIdentity: deviceTokenRef.current ?? '', enabled: showNativeChat, connState, - promptCancelSupported: agentSessionPromptCancelSupported, + hostSupport: agentSessionHostSupport, onSendError }) const { diff --git a/mobile/src/session/use-mobile-native-chat-session-lane.ts b/mobile/src/session/use-mobile-native-chat-session-lane.ts index bfeb1b06945..147cf017ac0 100644 --- a/mobile/src/session/use-mobile-native-chat-session-lane.ts +++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts @@ -1,5 +1,6 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' +import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileNativeChatSession } from './use-mobile-native-chat-session' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' @@ -15,7 +16,7 @@ export function useMobileNativeChatSessionLane({ sessionId, sourceIdentity, callerIdentity, - promptCancelSupported, + hostSupport, enabled, connState, onSendError @@ -30,7 +31,7 @@ export function useMobileNativeChatSessionLane({ sessionId: string | null sourceIdentity: Parameters[0]['sourceIdentity'] callerIdentity: string - promptCancelSupported?: boolean | null + hostSupport: StructuredAgentSessionHostSupport | null enabled: boolean connState: ConnectionState onSendError: (message: string) => void @@ -50,7 +51,7 @@ export function useMobileNativeChatSessionLane({ sessionId: structured ? sessionId : null, sourceIdentity, callerIdentity, - promptCancelSupported, + hostSupport, enabled, // Holds are connection-scoped; dropping this on transport loss lets the hook // reacquire the provider without clearing the cached transcript. diff --git a/mobile/src/session/use-mobile-session-feedback-capabilities.ts b/mobile/src/session/use-mobile-session-feedback-capabilities.ts index f0231189309..ea8d6438bf4 100644 --- a/mobile/src/session/use-mobile-session-feedback-capabilities.ts +++ b/mobile/src/session/use-mobile-session-feedback-capabilities.ts @@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from 'react' import { Animated } from 'react-native' import { reconcileMobileSessionCreateWarningState } from './mobile-session-create-warning-state' import type { MobileSessionTerminalRuntimeModel } from './use-mobile-session-terminal-runtime' +import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTerminalRuntimeModel) { const { @@ -32,11 +33,10 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina null ) const [quickCommandsSupported, setQuickCommandsSupported] = useState(null) - // Prompt cancellation is negotiated with the same host capability probe as + // Structured-session features are negotiated with the same host capability probe as // the other session surfaces; consumers never maintain a second status cache. - const [agentSessionPromptCancelSupported, setAgentSessionPromptCancelSupported] = useState< - boolean | null - >(null) + const [agentSessionHostSupport, setAgentSessionHostSupport] = + useState(null) // Why: stable callbacks (handleFileTap) read the live value via this ref, since // the capability probe resolves after the callbacks are created. const browserScreencastSupportedRef = useRef(browserScreencastSupported) @@ -120,8 +120,8 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina setAgentSessionHistorySupported, quickCommandsSupported, setQuickCommandsSupported, - agentSessionPromptCancelSupported, - setAgentSessionPromptCancelSupported, + agentSessionHostSupport, + setAgentSessionHostSupport, browserScreencastSupportedRef, reconciledCreateWarningState, createWarning, diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index 8ea9de2b9f0..4dc1743e221 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -27,7 +27,7 @@ export function useMobileSessionNativeChatDictation( worktreeId, client, connState, - agentSessionPromptCancelSupported, + agentSessionHostSupport, setInput, liveInputTerminalHandles, activeHandle, @@ -73,7 +73,7 @@ export function useMobileSessionNativeChatDictation( nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, connState, - agentSessionPromptCancelSupported, + agentSessionHostSupport, onSendError: nativeChatSendError.show, onSendResolved: nativeChatSendError.clear }) diff --git a/mobile/src/session/use-mobile-session-tab-reconciliation.ts b/mobile/src/session/use-mobile-session-tab-reconciliation.ts index da7a48e035c..9f9af59833d 100644 --- a/mobile/src/session/use-mobile-session-tab-reconciliation.ts +++ b/mobile/src/session/use-mobile-session-tab-reconciliation.ts @@ -2,10 +2,8 @@ import { useEffect, useRef, useCallback, useMemo, useState } from 'react' import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' import { supportsMobileQuickCommands } from '../terminal/quick-commands' import { MOBILE_AI_VAULT_CAPABILITY } from '../agent-history/agent-history-capability' -import { - AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, - TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY -} from '../../../src/shared/protocol-version' +import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { runAcceptedMobileSessionTabsEffects } from './mobile-session-tabs-accepted-effects' import type { SessionTabsStreamSource } from './mobile-session-tabs-stream-health' import { useMobileSessionTabsFetchReporting } from './use-mobile-session-tabs-fetch-reporting' @@ -34,7 +32,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc switchSessionTabRef, setBrowserScreencastSupported, setAgentSessionHistorySupported, - setAgentSessionPromptCancelSupported, + setAgentSessionHostSupport, setQuickCommandsSupported, nativeChatStream, fetchTerminals, @@ -152,7 +150,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc if (!client || connState !== 'connected') { setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) - setAgentSessionPromptCancelSupported(null) + setAgentSessionHostSupport(null) setQuickCommandsSupported(null) setShowQuickCommands(false) hostQueryReplyInputSupportedRef.current = false @@ -162,7 +160,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc // host; clear the prior capability before exposing host-specific actions. setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) - setAgentSessionPromptCancelSupported(null) + setAgentSessionHostSupport(null) setQuickCommandsSupported(null) setShowQuickCommands(false) hostQueryReplyInputSupportedRef.current = false @@ -171,9 +169,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc return startRuntimeCapabilityProbe(client, (capabilities) => { setBrowserScreencastSupported(capabilities.includes('browser.screencast.v1')) setAgentSessionHistorySupported(capabilities.includes(MOBILE_AI_VAULT_CAPABILITY)) - setAgentSessionPromptCancelSupported( - capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY) - ) + setAgentSessionHostSupport(structuredAgentSessionHostSupport(capabilities)) setQuickCommandsSupported(supportsMobileQuickCommands(capabilities)) // Why: hosts without this capability strip inputKind from terminal.send, // so a forwarded xterm reply would become floor-stealing shell input. diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 2ce48ff908d..2e18bce750c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -5,7 +5,14 @@ import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-j import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import type { RpcClient } from '../transport/rpc-client' -const mocks = vi.hoisted(() => ({ sendRequest: vi.fn() })) +const mocks = vi.hoisted(() => ({ + sendRequest: vi.fn(), + promptResponses: vi.fn(() => ({ + groupedDraft: null, + respondPermission: vi.fn(), + respondQuestion: vi.fn() + })) +})) vi.mock('./use-mobile-structured-agent-state', () => ({ useMobileStructuredAgentState: () => ({ state, @@ -25,11 +32,7 @@ vi.mock('./use-mobile-structured-agent-options', () => ({ }) })) vi.mock('./use-mobile-structured-prompt-responses', () => ({ - useMobileStructuredPromptResponses: () => ({ - groupedDraft: null, - respondPermission: vi.fn(), - respondQuestion: vi.fn() - }) + useMobileStructuredPromptResponses: mocks.promptResponses })) vi.mock('./use-mobile-structured-send-operation-reconciliation', () => ({ useMobileStructuredSendOperationReconciliation: vi.fn() @@ -104,7 +107,13 @@ const client: RpcClient = { close: () => {} } -function Harness({ promptCancelSupported }: { promptCancelSupported: boolean }): null { +function Harness({ + promptCancelSupported, + questionAnswersSupported = false +}: { + promptCancelSupported: boolean + questionAnswersSupported?: boolean +}): null { hook = useMobileStructuredAgentSession({ client, sessionId: 'session-1', @@ -112,7 +121,7 @@ function Harness({ promptCancelSupported }: { promptCancelSupported: boolean }): enabled: true, connected: true, agent: 'codex', - promptCancelSupported, + hostSupport: { promptCancel: promptCancelSupported, questionAnswers: questionAnswersSupported }, onSendError: vi.fn() }) return null @@ -151,6 +160,17 @@ describe('mobile structured prompt cancellation', () => { renderer = null }) + it('hands question answering the host answers capability', () => { + act(() => { + renderer = create( + createElement(Harness, { promptCancelSupported: false, questionAnswersSupported: true }) + ) + }) + expect(mocks.promptResponses).toHaveBeenLastCalledWith( + expect.objectContaining({ questionAnswersSupported: true }) + ) + }) + it('sends the clicked prompt identity on capable hosts', async () => { act(() => { renderer = create(createElement(Harness, { promptCancelSupported: true })) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 2a24d38a38e..f04d78cc9a7 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -32,6 +32,7 @@ import type { MobileNativeChatSession } from './use-mobile-native-chat-session' import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status' import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state' import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' +import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' @@ -77,8 +78,8 @@ export function useMobileStructuredAgentSession(args: { enabled: boolean /** Live transport only; gates the connection-scoped hold, nothing else. */ connected: boolean - /** Capability fact from the shared runtime status probe; null follows legacy cancellation. */ - promptCancelSupported?: boolean | null + /** Capability facts from the shared runtime status probe; null follows the legacy wire. */ + hostSupport: StructuredAgentSessionHostSupport | null agent: string | null onSendError: (message: string) => void }): StructuredMobileSession { @@ -91,8 +92,9 @@ export function useMobileStructuredAgentSession(args: { sourceIdentity = '', enabled, onSendError, - promptCancelSupported = null + hostSupport } = args + const promptCancelSupported = hostSupport?.promptCancel ?? null const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId]) const operationIdsRef = useRef(new Map()) const commandPendingRef = useRef(false) @@ -240,6 +242,7 @@ export function useMobileStructuredAgentSession(args: { stateRef, sessionKey, mutate, + questionAnswersSupported: hostSupport?.questionAnswers ?? null, onSendError }) diff --git a/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx b/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx index 05a2b7fc380..67e15635aa5 100644 --- a/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx +++ b/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx @@ -7,7 +7,12 @@ import { EMPTY_STRUCTURED_AGENT_SESSION, type StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' -import { projectStructuredQuestion } from './mobile-structured-agent-prompts' +import { encodeAgentSessionQuestionAnswers } from '../../../src/shared/agent-session-question-answer' +import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question' +import { + projectStructuredQuestion, + type StructuredQuestionItem +} from './mobile-structured-agent-prompts' import type { StructuredAgentSessionMutate, StructuredAgentSessionMutationResult @@ -72,6 +77,8 @@ function Probe(props: { sessionKey: string state: StructuredAgentSessionState mutate: StructuredAgentSessionMutate + questionAnswersSupported?: boolean | null + onSendError?: (message: string) => void }) { const stateRef = useRef(props.state) stateRef.current = props.state @@ -79,7 +86,8 @@ function Probe(props: { stateRef, sessionKey: props.sessionKey, mutate: props.mutate, - onSendError: vi.fn() + questionAnswersSupported: props.questionAnswersSupported ?? null, + onSendError: props.onSendError ?? vi.fn() }) return null } @@ -172,4 +180,298 @@ describe('useMobileStructuredPromptResponses', () => { expect(hook().groupedDraft?.answers).toHaveLength(1) } ) + + describe('answer wire', () => { + const LONG_ANSWER = 'Proceed with the replacement, but wait for the capture. '.repeat(30).trim() + + function singlePrompt(): StructuredQuestionItem { + return { + itemId: 'item-s', + revision: 3, + sequence: 1, + observedAt: 1, + body: { + kind: 'question', + question: 'Anything else?', + options: [ + { id: 'yes', label: 'Yes' }, + { id: 'no', label: 'No' } + ], + freeTextQuestionId: 'notes', + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } + } + + // Records what was sent; the verdict is irrelevant to the wire shape under test. + function recordingMutate() { + const sent: { method: string; fields: Record }[] = [] + const mutate: StructuredAgentSessionMutate = async (method, _fingerprint, fields) => { + sent.push({ method, fields }) + return { status: 'rejected' } + } + return { mutate, sent } + } + + function mount( + prompt: AgentJournalRenderItem, + mutate: StructuredAgentSessionMutate, + questionAnswersSupported: boolean | null, + onSendError?: (message: string) => void + ): void { + act(() => { + renderer = create( + createElement(Probe, { + sessionKey: 'session-a', + state: sessionState(prompt), + mutate, + questionAnswersSupported, + onSendError + }) + ) + }) + } + + function sentFields(sent: ReturnType['sent']): Record { + expect(sent).toHaveLength(1) + expect(sent[0]!.method).toBe('agentSession.respondToQuestion') + return sent[0]!.fields + } + + it('sends a long typed answer as structured answers to a capable host', async () => { + const prompt = singlePrompt() + const { mutate, sent } = recordingMutate() + mount(prompt, mutate, true) + const card = projectStructuredQuestion(prompt)! + + await act(async () => { + await hook().respondQuestion(formatQuestionFreeTextAnswer(card, LONG_ANSWER)) + }) + + expect(sentFields(sent)).toEqual({ + itemId: 'item-s', + expectedRevision: 3, + answers: [{ questionId: 'notes', optionIds: [], other: LONG_ANSWER }] + }) + }) + + it('packs a typed answer into the option id for a host that predates answers', async () => { + const prompt = singlePrompt() + const { mutate, sent } = recordingMutate() + mount(prompt, mutate, null) + const card = projectStructuredQuestion(prompt)! + + await act(async () => { + await hook().respondQuestion(formatQuestionFreeTextAnswer(card, ' DuckDB ')) + }) + + expect(sentFields(sent)).toEqual({ + itemId: 'item-s', + expectedRevision: 3, + optionId: 'notes:DuckDB' + }) + }) + + it('tries structured answers for an answer too long to pack while support is unknown', async () => { + const prompt = singlePrompt() + const { mutate, sent } = recordingMutate() + mount(prompt, mutate, null) + + await act(async () => { + await hook().respondQuestion( + formatQuestionFreeTextAnswer(projectStructuredQuestion(prompt)!, LONG_ANSWER) + ) + }) + + expect(sentFields(sent)).toEqual({ + itemId: 'item-s', + expectedRevision: 3, + answers: [{ questionId: 'notes', optionIds: [], other: LONG_ANSWER }] + }) + }) + + it('tells the user to update an older host instead of sending an answer it must refuse', async () => { + const prompt = singlePrompt() + const { mutate, sent } = recordingMutate() + const onSendError = vi.fn() + mount(prompt, mutate, false, onSendError) + + let accepted = true + await act(async () => { + accepted = await hook().respondQuestion( + formatQuestionFreeTextAnswer(projectStructuredQuestion(prompt)!, LONG_ANSWER) + ) + }) + + expect(accepted).toBe(false) + expect(sent).toEqual([]) + expect(onSendError).toHaveBeenCalledWith( + 'Update Orca on your computer to send answers this long' + ) + }) + + it.each([ + [true, { answers: [{ questionId: 'notes', optionIds: ['no'] }] }], + [false, { optionId: 'no' }] + ])( + 'answers an option tap for the question it was shown on (answers: %s)', + async (supported, wire) => { + const prompt = singlePrompt() + const { mutate, sent } = recordingMutate() + mount(prompt, mutate, supported) + + await act(async () => { + await hook().respondQuestion(projectStructuredQuestion(prompt)!.optionTokens[1]!) + }) + + expect(sentFields(sent)).toEqual({ itemId: 'item-s', expectedRevision: 3, ...wire }) + } + ) + + it.each([true, false])('submits a grouped question once (answers: %s)', async (supported) => { + const prompt = groupedPrompt('item-g', 1) + const { mutate, sent } = recordingMutate() + mount(prompt, mutate, supported) + + await act(async () => { + await hook().respondQuestion(projectedResponse(prompt, null)) + }) + await act(async () => { + await hook().respondQuestion(projectedResponse(prompt, hook().groupedDraft)) + }) + + const answers = [ + { questionId: 'q1', optionIds: ['q1:choice-1'] }, + { questionId: 'q2', optionIds: ['q2:choice-1'] } + ] + expect(sentFields(sent)).toEqual({ + itemId: 'item-g', + expectedRevision: 1, + ...(supported ? { answers } : { optionId: encodeAgentSessionQuestionAnswers(answers) }) + }) + }) + + // Claude always sends a question list, so this is the path a long typed Claude answer takes. + it.each([ + [true, true], + [null, true], + [false, false] + ])('submits a grouped long typed answer (answers: %s)', async (supported, expectSent) => { + const base = groupedPrompt('item-g', 1) + if (base.body.kind !== 'question' || !base.body.questions) { + throw new Error('expected a grouped question') + } + const [first, second] = base.body.questions + const prompt: AgentJournalRenderItem = { + ...base, + body: { ...base.body, questions: [first!, { ...second!, freeTextQuestionId: 'q2' }] } + } + const { mutate, sent } = recordingMutate() + const onSendError = vi.fn() + mount(prompt, mutate, supported, onSendError) + + await act(async () => { + await hook().respondQuestion(projectedResponse(prompt, null)) + }) + const step = projectStructuredQuestion(prompt, hook().groupedDraft)! + await act(async () => { + await hook().respondQuestion(formatQuestionFreeTextAnswer(step, LONG_ANSWER)) + }) + + if (!expectSent) { + expect(sent).toEqual([]) + expect(onSendError).toHaveBeenCalledWith( + 'Update Orca on your computer to send answers this long' + ) + return + } + expect(sentFields(sent)).toEqual({ + itemId: 'item-g', + expectedRevision: 1, + answers: [ + { questionId: 'q1', optionIds: ['q1:choice-1'] }, + { questionId: 'q2', optionIds: [], other: LONG_ANSWER } + ] + }) + }) + + it('keeps a grouped draft an older host cannot take so it sends after an update', async () => { + const base = groupedPrompt('item-g', 1) + if (base.body.kind !== 'question' || !base.body.questions) { + throw new Error('expected a grouped question') + } + const [first, second] = base.body.questions + const prompt: AgentJournalRenderItem = { + ...base, + body: { ...base.body, questions: [{ ...first!, freeTextQuestionId: 'q1' }, second!] } + } + const { mutate, sent } = recordingMutate() + const onSendError = vi.fn() + mount(prompt, mutate, false, onSendError) + + let advanced = false + await act(async () => { + advanced = await hook().respondQuestion( + formatQuestionFreeTextAnswer(projectStructuredQuestion(prompt, null)!, LONG_ANSWER) + ) + }) + expect(advanced).toBe(true) + await act(async () => { + await hook().respondQuestion(projectedResponse(prompt, hook().groupedDraft)) + }) + + expect(sent).toEqual([]) + expect(onSendError).toHaveBeenCalledWith( + 'Update Orca on your computer to send answers this long' + ) + expect(hook().groupedDraft).not.toBeNull() + + act(() => { + renderer?.update( + createElement(Probe, { + sessionKey: 'session-a', + state: sessionState(prompt), + mutate, + questionAnswersSupported: true, + onSendError + }) + ) + }) + await act(async () => { + await hook().respondQuestion(projectedResponse(prompt, hook().groupedDraft)) + }) + + expect(sentFields(sent)).toEqual({ + itemId: 'item-g', + expectedRevision: 1, + answers: [ + { questionId: 'q1', optionIds: [], other: LONG_ANSWER }, + { questionId: 'q2', optionIds: ['q2:choice-1'] } + ] + }) + }) + + it('names the single question an option tap answers when the prompt has no typed field', async () => { + const base = singlePrompt() + const { freeTextQuestionId: _omitted, ...body } = base.body + const prompt: StructuredQuestionItem = { ...base, body } + const { mutate, sent } = recordingMutate() + mount(prompt, mutate, true) + + await act(async () => { + await hook().respondQuestion(projectStructuredQuestion(prompt)!.optionTokens[0]!) + }) + + expect(sentFields(sent)).toEqual({ + itemId: 'item-s', + expectedRevision: 3, + answers: [{ questionId: 'q1', optionIds: ['yes'] }] + }) + }) + }) }) diff --git a/mobile/src/session/use-mobile-structured-prompt-responses.ts b/mobile/src/session/use-mobile-structured-prompt-responses.ts index 8340b7edee8..093029b2be5 100644 --- a/mobile/src/session/use-mobile-structured-prompt-responses.ts +++ b/mobile/src/session/use-mobile-structured-prompt-responses.ts @@ -1,5 +1,11 @@ import { useCallback, useState } from 'react' import type { AgentSessionPromptResult } from '../../../src/shared/agent-session-wire' +import type { AgentJournalQuestionItem } from '../../../src/shared/agent-session-journal-types' +import { + AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH, + legacyAgentSessionSelectedOptionId, + type AgentSessionQuestionAnswer +} from '../../../src/shared/agent-session-question-answer' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import { pendingStructuredApproval, @@ -7,7 +13,10 @@ import { structuredApprovalResponseTarget, structuredQuestionResponseTarget } from './mobile-structured-agent-prompts' -import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' +import type { + StructuredAgentSessionMutate, + StructuredAgentSessionMutationResult +} from './mobile-structured-agent-session-rpc' import { advanceGroupedQuestion, groupedQuestionPromptKey, @@ -23,13 +32,15 @@ export function useMobileStructuredPromptResponses(args: { stateRef: { readonly current: StructuredAgentSessionState } sessionKey: string mutate: StructuredAgentSessionMutate + /** Host takes structured `answers`; otherwise the answer is packed into `optionId`. */ + questionAnswersSupported: boolean | null onSendError: (message: string) => void }): { groupedDraft: GroupedQuestionDraft | null respondPermission: (optionId: string) => Promise respondQuestion: (answer: string) => Promise } { - const { mutate, onSendError, sessionKey, stateRef } = args + const { mutate, onSendError, questionAnswersSupported, sessionKey, stateRef } = args // Partially answered grouped question, held only until its last step is submitted. The session it // was collected in is stored with it and checked on read, so switching sessions drops the draft // without an effect that would render the stale one for a frame first. @@ -62,6 +73,38 @@ export function useMobileStructuredPromptResponses(args: { [mutate, onSendError, stateRef] ) + const sendAnswers = useCallback( + ( + target: { itemId: string; expectedRevision: number }, + body: Pick, + answers: AgentSessionQuestionAnswer[] + ): Promise | null> => { + const send = (fields: Record) => + mutate( + 'agentSession.respondToQuestion', + 'agentSession.respondTo:question', + { ...target, ...fields } + ) + if (questionAnswersSupported === true) { + return send({ answers }) + } + const optionId = legacyAgentSessionSelectedOptionId(body, answers) + if (optionId === null) { + return Promise.resolve(null) + } + if (optionId.length > AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH) { + // Too long to pack for any host: while support is unknown, `answers` is the only form that can land. + if (questionAnswersSupported === null) { + return send({ answers }) + } + onSendError('Update Orca on your computer to send answers this long') + return Promise.resolve(null) + } + return send({ optionId }) + }, + [mutate, onSendError, questionAnswersSupported] + ) + const respondQuestion = useCallback( async (answer: string): Promise => { const prompt = stateRef.current.items.find(pendingStructuredQuestion) ?? null @@ -80,11 +123,14 @@ export function useMobileStructuredPromptResponses(args: { setCollected({ sessionKey, draft: grouped.draft }) return true } - const result = await mutate( - 'agentSession.respondToQuestion', - 'agentSession.respondTo:question', - { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: grouped.optionId } + const result = await sendAnswers( + { itemId: prompt.itemId, expectedRevision: prompt.revision }, + prompt.body, + grouped.answers ) + if (!result) { + return false + } if (result.status !== 'rejected') { // The group left the phone; a retry must start from the first question, not a stale tail. setCollected((current) => @@ -103,18 +149,22 @@ export function useMobileStructuredPromptResponses(args: { if (!target) { return false } - const result = await mutate( - 'agentSession.respondToQuestion', - 'agentSession.respondTo:question', - target + const result = await sendAnswers( + { itemId: target.itemId, expectedRevision: target.expectedRevision }, + // Grouped questions returned above, so this is a single question. + {}, + [target.answer] ) + if (!result) { + return false + } if (result.status === 'unknown') { onSendError('Answer unconfirmed — check chat before retrying') return false } return result.status === 'accepted' }, - [groupedDraft, mutate, onSendError, sessionKey, stateRef] + [groupedDraft, onSendError, sendAnswers, sessionKey, stateRef] ) return { groupedDraft, respondPermission, respondQuestion } diff --git a/src/shared/agent-session-question-answer.ts b/src/shared/agent-session-question-answer.ts index cc815acf542..08374adc3d5 100644 --- a/src/shared/agent-session-question-answer.ts +++ b/src/shared/agent-session-question-answer.ts @@ -5,6 +5,9 @@ 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. */ +export const AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH = 1024 + /** Largest typed answer to one question, in UTF-8 bytes. */ export const AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES = 64 * 1024 diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index bd9ce3db82a..9d240ac578c 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -2,7 +2,10 @@ import { z } from 'zod' import { isAgentSessionSurfaceTabId } from '../agent-session-surface-tab-id' import { isAgentSessionId } from '../agent-session-record' import { normalizeExecutionHostId } from '../execution-host' -import { AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES } from '../agent-session-question-answer' +import { + AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES, + AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH +} from '../agent-session-question-answer' import { AGENT_SESSION_ID_MAX_LENGTH, AGENT_SESSION_HISTORY_DIRECTIONS, @@ -13,7 +16,7 @@ import { export const MAX_ID_LENGTH = AGENT_SESSION_ID_MAX_LENGTH // Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. -export const MAX_RESPONSE_OPTION_ID_LENGTH = 1024 +export const MAX_RESPONSE_OPTION_ID_LENGTH = AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH export const MAX_PROMPT_BYTES = 256 * 1024