diff --git a/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx b/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx new file mode 100644 index 00000000000..05a2b7fc380 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx @@ -0,0 +1,175 @@ +import { createElement, useRef } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionPromptResult } from '../../../src/shared/agent-session-wire' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + type StructuredAgentSessionState +} from '../../../src/shared/structured-agent-session-reducer' +import { projectStructuredQuestion } from './mobile-structured-agent-prompts' +import type { + StructuredAgentSessionMutate, + StructuredAgentSessionMutationResult +} from './mobile-structured-agent-session-rpc' +import { groupedQuestionPromptKey } from './mobile-structured-grouped-question' +import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' + +type PromptResponses = ReturnType + +let currentHook: PromptResponses | null = null +let renderer: ReactTestRenderer | null = null + +function groupedPrompt(itemId: string, revision: number): AgentJournalRenderItem { + return { + itemId, + revision, + sequence: 1, + observedAt: 1, + body: { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'First?', + multiSelect: false, + options: [ + { id: 'q1:choice-1', label: 'One' }, + { id: 'q1:choice-2', label: 'Another one' } + ] + }, + { + id: 'q2', + question: 'Second?', + multiSelect: false, + options: [ + { id: 'q2:choice-1', label: 'Two' }, + { id: 'q2:choice-2', label: 'Another two' } + ] + } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + } +} + +function sessionState(prompt: AgentJournalRenderItem): StructuredAgentSessionState { + return { ...EMPTY_STRUCTURED_AGENT_SESSION, status: 'ready', items: [prompt] } +} + +function projectedResponse(prompt: AgentJournalRenderItem, draft: PromptResponses['groupedDraft']) { + const projected = projectStructuredQuestion(prompt, draft) + const response = projected?.optionTokens[0] + if (!response) { + throw new Error('Grouped question did not project an option response') + } + return response +} + +function Probe(props: { + sessionKey: string + state: StructuredAgentSessionState + mutate: StructuredAgentSessionMutate +}) { + const stateRef = useRef(props.state) + stateRef.current = props.state + currentHook = useMobileStructuredPromptResponses({ + stateRef, + sessionKey: props.sessionKey, + mutate: props.mutate, + onSendError: vi.fn() + }) + return null +} + +function hook(): PromptResponses { + if (!currentHook) { + throw new Error('Hook probe is not mounted') + } + return currentHook +} + +afterEach(() => { + act(() => renderer?.unmount()) + currentHook = null + renderer = null +}) + +describe('useMobileStructuredPromptResponses', () => { + it.each([ + ['another session', 'session-b', groupedPrompt('item-b', 1)], + ['a newer prompt revision', 'session-a', groupedPrompt('item-a', 2)] + ])( + 'does not let a completed grouped response clear %s draft', + async (_, nextSession, nextPrompt) => { + const firstPrompt = groupedPrompt('item-a', 1) + let resolveMutation!: ( + value: StructuredAgentSessionMutationResult + ) => void + const pendingMutation = new Promise< + StructuredAgentSessionMutationResult + >((resolve) => { + resolveMutation = resolve + }) + const mutate = vi.fn(() => pendingMutation) as unknown as StructuredAgentSessionMutate + + act(() => { + renderer = create( + createElement(Probe, { + sessionKey: 'session-a', + state: sessionState(firstPrompt), + mutate + }) + ) + }) + await act(async () => { + await hook().respondQuestion(projectedResponse(firstPrompt, null)) + }) + let firstSubmission!: Promise + act(() => { + firstSubmission = hook().respondQuestion( + projectedResponse(firstPrompt, hook().groupedDraft) + ) + }) + + act(() => { + renderer?.update( + createElement(Probe, { + sessionKey: nextSession, + state: sessionState(nextPrompt), + mutate + }) + ) + }) + await act(async () => { + await hook().respondQuestion(projectedResponse(nextPrompt, null)) + }) + expect(hook().groupedDraft?.answers).toHaveLength(1) + + await act(async () => { + resolveMutation({ + status: 'accepted', + value: { + itemId: firstPrompt.itemId, + revision: firstPrompt.revision, + resolution: { + state: 'resolved', + selectedOptionId: 'q2:choice-1', + resolvedBy: 'mobile', + resolvedAt: 2 + } + }, + sameFence: true + }) + await firstSubmission + }) + + expect(hook().groupedDraft?.promptKey).toBe( + groupedQuestionPromptKey(nextPrompt.itemId, nextPrompt.revision) + ) + expect(hook().groupedDraft?.answers).toHaveLength(1) + } + ) +}) diff --git a/mobile/src/session/use-mobile-structured-prompt-responses.ts b/mobile/src/session/use-mobile-structured-prompt-responses.ts index 502a8550811..8340b7edee8 100644 --- a/mobile/src/session/use-mobile-structured-prompt-responses.ts +++ b/mobile/src/session/use-mobile-structured-prompt-responses.ts @@ -66,11 +66,12 @@ export function useMobileStructuredPromptResponses(args: { async (answer: string): Promise => { const prompt = stateRef.current.items.find(pendingStructuredQuestion) ?? null if (prompt?.body.questions) { + const promptKey = groupedQuestionPromptKey(prompt.itemId, prompt.revision) const grouped = advanceGroupedQuestion({ response: answer, questions: prompt.body.questions, draft: groupedDraft, - promptKey: groupedQuestionPromptKey(prompt.itemId, prompt.revision) + promptKey }) if (!grouped) { return false @@ -86,7 +87,11 @@ export function useMobileStructuredPromptResponses(args: { ) if (result.status !== 'rejected') { // The group left the phone; a retry must start from the first question, not a stale tail. - setCollected(null) + setCollected((current) => + current?.sessionKey === sessionKey && current.draft.promptKey === promptKey + ? null + : current + ) } if (result.status === 'unknown') { onSendError('Answer unconfirmed — check chat before retrying') diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 58dece2256c..233809d40ff 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -12,6 +12,8 @@ import { import { normalizeExecutionHostId } from '../../../../shared/execution-host' const MAX_ID_LENGTH = 512 +// Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. +const MAX_RESPONSE_OPTION_ID_LENGTH = 1024 const MAX_PROMPT_BYTES = 256 * 1024 const MAX_BLOCKS = 64 const MAX_OPTION_LABEL = 512 @@ -21,11 +23,11 @@ export const SessionId = z .max(MAX_ID_LENGTH) .refine(isAgentSessionId, 'Invalid agent session id') -const Identifier = (message: string) => +const Identifier = (message: string, maxLength = MAX_ID_LENGTH) => z .string() .min(1, message) - .max(MAX_ID_LENGTH, message) + .max(maxLength, message) .refine((value) => value === value.trim(), message) export const JournalCursor = z @@ -166,7 +168,7 @@ export const RespondParams = z itemId: Identifier('Invalid item id'), /** Compare-and-set: the revision the client had on screen. */ expectedRevision: z.number().int().positive(), - optionId: Identifier('Invalid option id') + optionId: Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH) }) .strict() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index c43c82d03ee..8defafb4433 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -732,6 +732,41 @@ describe('parameter validation', () => { }) }) + it('accepts the maximum fully encoded Claude choice group and retains a finite bound', async () => { + const maximumSelections = Array.from({ length: 4 }, (_, questionIndex) => ({ + questionId: `q${questionIndex + 1}`, + optionIds: Array.from( + { length: 4 }, + (_, optionIndex) => `q${questionIndex + 1}:choice-${optionIndex + 1}` + ) + })) + const optionId = `question-group:${encodeURIComponent(JSON.stringify(maximumSelections))}` + expect(optionId.length).toBe(610) + + const response = await call( + 'agentSession.respondToQuestion', + { + envelope: envelope(), + itemId: 'item-1', + expectedRevision: 1, + optionId + }, + STRUCTURED_CLIENT + ) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.respondToPrompt).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ optionId }) + ) + + await rejects('agentSession.respondToQuestion', { + envelope: envelope(), + itemId: 'item-1', + expectedRevision: 1, + optionId: 'x'.repeat(1025) + }) + }) + it('bounds a history page and validates its cursor', async () => { await rejects('agentSession.history', { sessionId: SESSION, diff --git a/src/shared/agent-session-question-answer.test.ts b/src/shared/agent-session-question-answer.test.ts index 529bfdfd72d..3f8820d3013 100644 --- a/src/shared/agent-session-question-answer.test.ts +++ b/src/shared/agent-session-question-answer.test.ts @@ -6,6 +6,12 @@ import { type AgentSessionQuestionAnswer } from './agent-session-question-answer' +const GROUP_ANSWER_PREFIX = 'question-group:' + +function decodeWithOriginalPercentDecoder(encoded: string): unknown { + return JSON.parse(decodeURIComponent(encoded.slice(GROUP_ANSWER_PREFIX.length))) +} + describe('agent-session grouped question answers', () => { const answers: AgentSessionQuestionAnswer[] = [ { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, @@ -18,6 +24,44 @@ describe('agent-session grouped question answers', () => { ) }) + it('keeps compact answers readable by the original percent-decoding contract', () => { + expect(decodeWithOriginalPercentDecoder(encodeAgentSessionQuestionAnswers(answers))).toEqual( + answers + ) + }) + + it('accepts the previous fully percent-encoded representation', () => { + const encoded = `${GROUP_ANSWER_PREFIX}${encodeURIComponent(JSON.stringify(answers))}` + + expect(decodeAgentSessionQuestionAnswers(encoded)).toEqual(answers) + }) + + it('round-trips percent signs and Unicode through the compact representation', () => { + const unicodeAnswers: AgentSessionQuestionAnswer[] = [ + { + questionId: '進捗%', + optionIds: ['100%:完了', '🚀'], + other: 'café 東京 50%' + } + ] + + expect( + decodeAgentSessionQuestionAnswers(encodeAgentSessionQuestionAnswers(unicodeAnswers)) + ).toEqual(unicodeAnswers) + }) + + it("fits Claude's maximum choice group within a 512-character host response bound", () => { + const maximumSelections = Array.from({ length: 4 }, (_, questionIndex) => ({ + questionId: `q${questionIndex + 1}`, + optionIds: Array.from( + { length: 4 }, + (_, optionIndex) => `q${questionIndex + 1}:choice-${optionIndex + 1}` + ) + })) + + expect(encodeAgentSessionQuestionAnswers(maximumSelections).length).toBeLessThanOrEqual(512) + }) + it('validates each grouped answer against its question shape', () => { const questions = [ { diff --git a/src/shared/agent-session-question-answer.ts b/src/shared/agent-session-question-answer.ts index f90df30ddff..072dfce200a 100644 --- a/src/shared/agent-session-question-answer.ts +++ b/src/shared/agent-session-question-answer.ts @@ -11,7 +11,8 @@ export type AgentSessionQuestionAnswer = { export function encodeAgentSessionQuestionAnswers( answers: readonly AgentSessionQuestionAnswer[] ): string { - return `${GROUP_ANSWER_PREFIX}${encodeURIComponent(JSON.stringify(answers))}` + // RPC already JSON-frames this value; escaping `%` alone preserves decodeURIComponent readers. + return `${GROUP_ANSWER_PREFIX}${JSON.stringify(answers).replaceAll('%', '%25')}` } export function decodeAgentSessionQuestionAnswers(