mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
* 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
185 lines
5.9 KiB
TypeScript
185 lines
5.9 KiB
TypeScript
import type { AgentJournalQuestion, AgentJournalQuestionItem } from './agent-session-journal-types'
|
|
|
|
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
|
|
|
|
export type AgentSessionQuestionAnswer = {
|
|
questionId: string
|
|
optionIds: string[]
|
|
other?: string
|
|
}
|
|
|
|
/** What a client chose on a prompt: a decision id for an approval, per-question answers for a question. */
|
|
export type AgentSessionPromptResponse =
|
|
| { kind: 'option'; optionId: string }
|
|
| { kind: 'answers'; answers: AgentSessionQuestionAnswer[] }
|
|
|
|
/** The questions an item asks: its grouped list, or the item itself as one question. */
|
|
export function agentSessionPromptQuestions(
|
|
body: Pick<AgentJournalQuestionItem, 'question' | 'options' | 'questions' | 'freeTextQuestionId'>
|
|
): AgentJournalQuestion[] {
|
|
if (body.questions) {
|
|
return body.questions
|
|
}
|
|
return [
|
|
{
|
|
id: body.freeTextQuestionId ?? SINGLE_QUESTION_ID,
|
|
question: body.question,
|
|
options: body.options,
|
|
multiSelect: false,
|
|
...(body.freeTextQuestionId ? { freeTextQuestionId: body.freeTextQuestionId } : {})
|
|
}
|
|
]
|
|
}
|
|
|
|
function encodeLegacyFreeTextAnswer(questionId: string, answer: string): string {
|
|
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
|
|
}
|
|
|
|
function decodeLegacyFreeTextAnswer(
|
|
optionId: string
|
|
): { questionId: string; answer: string } | null {
|
|
const separator = optionId.indexOf(':')
|
|
if (separator <= 0) {
|
|
return null
|
|
}
|
|
try {
|
|
return {
|
|
questionId: decodeURIComponent(optionId.slice(0, separator)),
|
|
answer: decodeURIComponent(optionId.slice(separator + 1))
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The answer packed into one option-id string, the only form older peers read: hosts that
|
|
* predate structured answers take it as `optionId`, and older clients render receipts from it.
|
|
*/
|
|
export function legacyAgentSessionSelectedOptionId(
|
|
body: Pick<AgentJournalQuestionItem, 'questions'>,
|
|
answers: readonly AgentSessionQuestionAnswer[]
|
|
): string | null {
|
|
if (body.questions) {
|
|
return encodeAgentSessionQuestionAnswers(answers)
|
|
}
|
|
const [answer] = answers
|
|
if (!answer || answers.length !== 1) {
|
|
return null
|
|
}
|
|
const other = answer.other?.trim()
|
|
return (
|
|
answer.optionIds[0] ?? (other ? encodeLegacyFreeTextAnswer(answer.questionId, other) : null)
|
|
)
|
|
}
|
|
|
|
/** Reads an answer an older client packed into `optionId`. */
|
|
export function legacyAgentSessionQuestionAnswers(
|
|
body: Pick<AgentJournalQuestionItem, 'question' | 'options' | 'questions' | 'freeTextQuestionId'>,
|
|
optionId: string
|
|
): AgentSessionQuestionAnswer[] | null {
|
|
const grouped = body.questions ? decodeAgentSessionQuestionAnswers(optionId) : null
|
|
if (grouped) {
|
|
return grouped
|
|
}
|
|
const questions = agentSessionPromptQuestions(body)
|
|
const questionId = questions.length === 1 ? questions[0]!.id : null
|
|
if (!questionId) {
|
|
return null
|
|
}
|
|
if (body.options.some((option) => option.id === optionId)) {
|
|
return [{ questionId, optionIds: [optionId] }]
|
|
}
|
|
const freeText = decodeLegacyFreeTextAnswer(optionId)
|
|
return body.freeTextQuestionId &&
|
|
freeText?.questionId === body.freeTextQuestionId &&
|
|
freeText.answer.trim().length > 0
|
|
? [{ questionId, optionIds: [], other: freeText.answer }]
|
|
: null
|
|
}
|
|
|
|
export function encodeAgentSessionQuestionAnswers(
|
|
answers: readonly AgentSessionQuestionAnswer[]
|
|
): string {
|
|
// RPC already JSON-frames this value; escaping `%` alone preserves decodeURIComponent readers.
|
|
return `${GROUP_ANSWER_PREFIX}${JSON.stringify(answers).replaceAll('%', '%25')}`
|
|
}
|
|
|
|
export function decodeAgentSessionQuestionAnswers(
|
|
encoded: string
|
|
): AgentSessionQuestionAnswer[] | null {
|
|
if (!encoded.startsWith(GROUP_ANSWER_PREFIX)) {
|
|
return null
|
|
}
|
|
try {
|
|
const parsed: unknown = JSON.parse(
|
|
decodeURIComponent(encoded.slice(GROUP_ANSWER_PREFIX.length))
|
|
)
|
|
if (!Array.isArray(parsed)) {
|
|
return null
|
|
}
|
|
const answers = parsed.flatMap((value): AgentSessionQuestionAnswer[] => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return []
|
|
}
|
|
const record = value as Record<string, unknown>
|
|
if (
|
|
typeof record.questionId !== 'string' ||
|
|
!Array.isArray(record.optionIds) ||
|
|
!record.optionIds.every((optionId) => typeof optionId === 'string') ||
|
|
(record.other !== undefined && typeof record.other !== 'string')
|
|
) {
|
|
return []
|
|
}
|
|
return [
|
|
{
|
|
questionId: record.questionId,
|
|
optionIds: record.optionIds,
|
|
...(typeof record.other === 'string' ? { other: record.other } : {})
|
|
}
|
|
]
|
|
})
|
|
return answers.length === parsed.length ? answers : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function isValidAgentSessionQuestionAnswers(
|
|
questions: readonly AgentJournalQuestion[],
|
|
answers: readonly AgentSessionQuestionAnswer[]
|
|
): boolean {
|
|
if (answers.length !== questions.length) {
|
|
return false
|
|
}
|
|
const byId = new Map(answers.map((answer) => [answer.questionId, answer]))
|
|
if (byId.size !== answers.length) {
|
|
return false
|
|
}
|
|
return questions.every((question) => {
|
|
const answer = byId.get(question.id)
|
|
if (!answer) {
|
|
return false
|
|
}
|
|
const offered = new Set(question.options.map((option) => option.id))
|
|
if (answer.optionIds.some((optionId) => !offered.has(optionId))) {
|
|
return false
|
|
}
|
|
const other = answer.other?.trim() ?? ''
|
|
if (other && !question.freeTextQuestionId) {
|
|
return false
|
|
}
|
|
const answerCount = answer.optionIds.length + (other ? 1 : 0)
|
|
return answerCount > 0 && (question.multiSelect || answerCount === 1)
|
|
})
|
|
}
|