mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +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>
149 lines
5.6 KiB
TypeScript
149 lines
5.6 KiB
TypeScript
import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer'
|
|
import type { CodexAppServerConnection } from './codex-app-server-connection'
|
|
import { CODEX_PROMPT_MAX_ANSWER_BYTES } from './codex-prompt-registry-bounds'
|
|
import {
|
|
CODEX_USER_INPUT_METHOD,
|
|
type CodexPendingPrompt,
|
|
type CodexPromptClaim,
|
|
type CodexPromptRegistry
|
|
} from './codex-prompt-registry'
|
|
export {
|
|
codexJournalPromptIdPart,
|
|
MAX_CODEX_PROMPT_REGISTRY_ENTRIES,
|
|
MAX_CODEX_PROMPT_JOURNAL_BINDINGS,
|
|
MAX_CODEX_PROMPT_REGISTRY_BYTES,
|
|
encodeCodexJournalQuestionOptionId
|
|
} from './codex-prompt-registry-bounds'
|
|
export {
|
|
CODEX_COMMAND_APPROVAL_METHOD,
|
|
CODEX_FILE_CHANGE_APPROVAL_METHOD,
|
|
CODEX_USER_INPUT_METHOD,
|
|
CodexPromptRegistry,
|
|
isCodexPromptMethod,
|
|
type CodexPendingPrompt,
|
|
type CodexPromptClaim
|
|
} from './codex-prompt-registry'
|
|
|
|
/** The decisions Codex accepts for both approval requests. Anything else is a
|
|
* client-supplied option id that never came from a Codex prompt. */
|
|
export const CODEX_APPROVAL_DECISIONS = ['accept', 'acceptForSession', 'decline', 'cancel'] as const
|
|
export type CodexApprovalDecision = (typeof CODEX_APPROVAL_DECISIONS)[number]
|
|
|
|
function isCodexApprovalDecision(optionId: string): optionId is CodexApprovalDecision {
|
|
return CODEX_APPROVAL_DECISIONS.some((decision) => decision === optionId)
|
|
}
|
|
|
|
/** A user-input request can carry several questions but takes ONE reply, so an
|
|
* option id has to name the question it answers. */
|
|
export function encodeCodexQuestionOptionId(questionId: string, answer: string): string {
|
|
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
|
|
}
|
|
|
|
export function decodeCodexQuestionOptionId(
|
|
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
|
|
}
|
|
}
|
|
|
|
/** One answer, checked against the prompt but not yet recorded on it. */
|
|
export type CodexPreparedAnswer =
|
|
| { kind: 'decision'; decision: CodexApprovalDecision }
|
|
| { kind: 'answer'; questionId: string; answer: string }
|
|
|
|
/** Validates a client's choice against the prompt without recording it, so an answer Codex
|
|
* cannot take is refused before the journal commits it. */
|
|
export function prepareCodexPromptAnswer(
|
|
prompt: CodexPendingPrompt,
|
|
response: AgentSessionPromptResponse
|
|
): CodexPreparedAnswer {
|
|
if (prompt.method !== CODEX_USER_INPUT_METHOD) {
|
|
if (response.kind !== 'option' || !isCodexApprovalDecision(response.optionId)) {
|
|
throw new Error(`Codex item ${prompt.codexItemId} takes an approval decision`)
|
|
}
|
|
return { kind: 'decision', decision: response.optionId }
|
|
}
|
|
// Each Codex question is its own journal item, so an answer names exactly one question.
|
|
const entry =
|
|
response.kind === 'answers' && response.answers.length === 1 ? response.answers[0] : null
|
|
if (!entry) {
|
|
throw new Error(`Codex item ${prompt.codexItemId} takes one question answer`)
|
|
}
|
|
const optionId = entry.optionIds[0]
|
|
const decoded =
|
|
optionId === undefined
|
|
? { questionId: entry.questionId, answer: entry.other?.trim() ?? '' }
|
|
: (prompt.optionAnswers.get(optionId) ?? decodeCodexQuestionOptionId(optionId))
|
|
const questionId =
|
|
(decoded?.questionId
|
|
? (prompt.questionIdAliases.get(decoded.questionId) ?? decoded.questionId)
|
|
: null) ?? (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null)
|
|
const answer = decoded?.answer ?? optionId ?? ''
|
|
if (!questionId || !prompt.questionIds.includes(questionId)) {
|
|
throw new Error(`The answer does not name a question on Codex item ${prompt.codexItemId}`)
|
|
}
|
|
if (Buffer.byteLength(answer, 'utf8') > CODEX_PROMPT_MAX_ANSWER_BYTES) {
|
|
throw new Error('codex prompt answer exceeds bounded registry state')
|
|
}
|
|
return { kind: 'answer', questionId, answer }
|
|
}
|
|
|
|
/**
|
|
* Records one prepared answer and returns the reply payload once the request is fully
|
|
* answered. A multi-question user-input request stays pending until every
|
|
* question has an answer, because Codex takes one reply for all of them.
|
|
*/
|
|
export function applyCodexPromptAnswer(
|
|
prompt: CodexPendingPrompt,
|
|
prepared: CodexPreparedAnswer
|
|
): Record<string, unknown> | null {
|
|
if (prepared.kind === 'decision') {
|
|
return { decision: prepared.decision }
|
|
}
|
|
prompt.answers.set(prepared.questionId, prepared.answer)
|
|
if (prompt.questionIds.some((id) => !prompt.answers.has(id))) {
|
|
return null
|
|
}
|
|
const answers: Record<string, { answers: string[] }> = {}
|
|
for (const id of prompt.questionIds) {
|
|
const answer = prompt.answers.get(id)
|
|
if (answer === undefined) {
|
|
return null
|
|
}
|
|
answers[id] = { answers: [answer] }
|
|
}
|
|
return { answers }
|
|
}
|
|
|
|
/** Throws for a prompt Codex is no longer waiting on, which the wire reports as
|
|
* "recorded but not confirmed" rather than as a delivered answer. */
|
|
export function answerCodexPrompt(
|
|
registry: CodexPromptRegistry,
|
|
connection: Pick<CodexAppServerConnection, 'respond'>,
|
|
claim: CodexPromptClaim,
|
|
prepared: CodexPreparedAnswer
|
|
): void {
|
|
if (!registry.ownsClaim(claim)) {
|
|
throw new Error(`codex app-server is no longer waiting on ${claim.itemId}`)
|
|
}
|
|
const prompt = claim.prompt
|
|
const reply = applyCodexPromptAnswer(prompt, prepared)
|
|
if (reply === null) {
|
|
registry.releaseClaim(claim)
|
|
return
|
|
}
|
|
// Forget first: a second answer must find nothing rather than reply twice.
|
|
registry.forget(prompt)
|
|
connection.respond(prompt.requestId, reply)
|
|
}
|