fix(mobile): preserve grouped prompt response compatibility

This commit is contained in:
Merge Sim
2026-09-06 10:31:07 -07:00
parent db4fa4484d
commit 6a82f2e480
6 changed files with 268 additions and 6 deletions
@@ -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<typeof useMobileStructuredPromptResponses>
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<AgentSessionPromptResult>
) => void
const pendingMutation = new Promise<
StructuredAgentSessionMutationResult<AgentSessionPromptResult>
>((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<boolean>
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)
}
)
})
@@ -66,11 +66,12 @@ export function useMobileStructuredPromptResponses(args: {
async (answer: string): Promise<boolean> => {
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')
@@ -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()
@@ -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,
@@ -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 = [
{
+2 -1
View File
@@ -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(