mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 08:02:35 +00:00
* fix(native-chat): send typed question answers as structured answers, not an option id A typed "Other" answer was packed into the `optionId` of agentSession.respondToQuestion, a field capped at 1024 characters, so a long answer failed with "Invalid option id" and never reached the agent. respondToQuestion now carries per-question `answers` in their own field, bounded like a typed answer, and a host advertises agent-session.question-answers.v1 when it takes them. Clients fall back to the packed option id for older hosts. The host reads either form once into a typed response, records the structured answers on the resolution (and keeps the packed form older clients read), and the Claude and Codex adapters build their reply from the typed answers before the journal commits, so an answer the agent cannot take is refused rather than recorded unanswered. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): hold one answer per single-select question card Typing an answer deselects a picked option, and picking an option leaves the typed text in the field without sending it, so the card never shows two answers while sending one. Multi-select still sends picked options and typed text together. * fix(native-chat): keep keyboard tabbing from re-choosing a typed answer; accept untrimmed question ids Clicking or typing in the answer field chooses the typed answer; focus alone no longer does, so tabbing to Submit keeps the option the user picked. A question id is matched exactly by the host, so the wire no longer rejects agent-written ids with edge spaces, which older builds accepted. * fix(native-chat): choose the typed answer on click so a disabled or scrolled field cannot * test(native-chat): cover pointer events on a disabled answer field * refactor(native-chat): record the typed answer as a choice in the question card Choosing the typed answer is now an entry in the question's selection, set by typing or clicking the field and replaced by picking an option, instead of being inferred from an empty selection. Unpicking an option no longer silently chooses kept text. --------- Co-authored-by: Claude <noreply@anthropic.com>
182 lines
5.8 KiB
TypeScript
182 lines
5.8 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'
|
|
|
|
/** 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)
|
|
})
|
|
}
|