mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(mobile): preserve grouped prompt response compatibility
This commit is contained in:
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user