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