mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
fix(mobile): send typed question answers as structured answers (#23458)
* 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
This commit is contained in:
@@ -92,7 +92,7 @@ function mount(client: FakeRpcClient): Mounted {
|
||||
worktreeId: 'w1',
|
||||
client,
|
||||
connState: 'connected',
|
||||
agentSessionPromptCancelSupported: true,
|
||||
agentSessionHostSupport: { promptCancel: true, questionAnswers: false },
|
||||
setInput: () => {},
|
||||
liveInputTerminalHandles: new Set<string>(),
|
||||
activeHandle: 't1',
|
||||
|
||||
@@ -93,7 +93,8 @@ const HOST_COMPONENT_NAMES = new Set([
|
||||
// Moved, count unchanged, when the Markdown actions' Back `useEffect` became `useBackClaim`, the
|
||||
// seam that also claims the key on the page while a draft is dirty.
|
||||
const HEAD_MAIN_HOOK_SHA256 = 'f161e14a9c53d80c3dc75f51dd8ecb339b59b7239c9c3e067791b8612f51ede2'
|
||||
const HEAD_HOOK_BINDING_SHA256 = '7a9e256e2058635850253a74b9faa05bcafbbcaa1e5b0b3ef7f677dde85753a2'
|
||||
// Moved when the prompt-cancel flag became one structured-session host support object.
|
||||
const HEAD_HOOK_BINDING_SHA256 = 'db9f32cc60fc68adbcbb2acf9f9384ad0d78bbc6feef5d581449fadb647405fc'
|
||||
const HEAD_CALLBACK_IDENTITY_SHA256 =
|
||||
'373dca17a060e63d8cb4e32416ca2889b8404cee78f7b47e632940a9980baf23'
|
||||
// Pins that no callback body in the route changed unnoticed. Body text, not behaviour: the sends
|
||||
@@ -125,7 +126,8 @@ const HEAD_CALLBACK_BODY_SHA256 = '2ccbfb5ee57e7dfeb07dafaee6fa592b95862b3bc898a
|
||||
// the diff-comments effect, which now catches the loader's rejection. Count unchanged.
|
||||
// Moved again by the keyboard seam above, which is the +1 effect.
|
||||
// -1 effect for the Markdown actions' Back registration, which is `useBackClaim`'s own now.
|
||||
const HEAD_EFFECT_SHA256 = '69096e20a44fa03a2c364e00a617eeabc437a79bb850b9135408d35fbd4d4a71'
|
||||
// Moved by the capability probe setting that host support object.
|
||||
const HEAD_EFFECT_SHA256 = '9b045a547ed269acf95db16cc87e33a9035a20c6888fd30e0363e58bb6b7d883'
|
||||
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
|
||||
// Same pin for the 12 bodies that sit in nested functions rather than callbacks, moved by the same
|
||||
// rewrite of those send and read expressions. Count unchanged. Refreshed again in step 6 for
|
||||
@@ -181,7 +183,9 @@ const HEAD_STYLE_REFERENCE_SHA256 =
|
||||
const HEAD_IDENTITY_FIELD_SHA256 =
|
||||
'91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6'
|
||||
const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512'
|
||||
const HEAD_CAPABILITY_SHA256 = '67c3154b71b542bb63a4365d3ea75aef19ef133c02f509318619618221786fab'
|
||||
// Moved when structured-session features became one helper call; the capability strings it reads
|
||||
// are pinned by that helper's own test.
|
||||
const HEAD_CAPABILITY_SHA256 = 'ec1159d6e726383bf7121e9c642657fee5b4ebec049cb44ba132a303bb9a61e2'
|
||||
|
||||
type Definition = { declaration: ts.FunctionDeclaration; sourceFile: ts.SourceFile }
|
||||
type HookFacts = {
|
||||
@@ -550,7 +554,11 @@ function readCompatibilityFacts(definitions: ReadonlyMap<string, Definition>): {
|
||||
: ''
|
||||
const callText = canonical(node, sourceFile)
|
||||
if (
|
||||
['startRuntimeCapabilityProbe', 'supportsMobileQuickCommands'].includes(callName) ||
|
||||
[
|
||||
'startRuntimeCapabilityProbe',
|
||||
'supportsMobileQuickCommands',
|
||||
'structuredAgentSessionHostSupport'
|
||||
].includes(callName) ||
|
||||
(callName === 'includes' && callText.includes('capabilities.includes'))
|
||||
) {
|
||||
capabilities.push(callText)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import {
|
||||
agentSessionPromptQuestions,
|
||||
type AgentSessionQuestionAnswer
|
||||
} from '../../../src/shared/agent-session-question-answer'
|
||||
import type { MobileChatPermission } from './mobile-native-chat-permission'
|
||||
import type { MobileChatQuestion } from './mobile-native-chat-question'
|
||||
import {
|
||||
@@ -21,6 +25,12 @@ export type StructuredPromptResponseTarget = {
|
||||
optionId: string
|
||||
}
|
||||
|
||||
export type StructuredQuestionResponseTarget = {
|
||||
itemId: string
|
||||
expectedRevision: number
|
||||
answer: AgentSessionQuestionAnswer
|
||||
}
|
||||
|
||||
type PromptTokenPayload =
|
||||
| {
|
||||
kind: 'approval'
|
||||
@@ -32,6 +42,7 @@ type PromptTokenPayload =
|
||||
kind: 'question-option'
|
||||
itemId: string
|
||||
revision: number
|
||||
questionId: string
|
||||
optionId: string
|
||||
}
|
||||
| {
|
||||
@@ -55,10 +66,6 @@ export function pendingStructuredQuestion(
|
||||
return item.body.kind === 'question' && item.body.resolution.state === 'pending'
|
||||
}
|
||||
|
||||
function encodeQuestionAnswer(questionId: string, answer: string): string {
|
||||
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
|
||||
}
|
||||
|
||||
function encodePromptToken(payload: PromptTokenPayload): string {
|
||||
return `${STRUCTURED_PROMPT_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`
|
||||
}
|
||||
@@ -86,11 +93,16 @@ function decodePromptToken(value: string): PromptTokenPayload | null {
|
||||
optionId: decoded.optionId
|
||||
}
|
||||
}
|
||||
if (decoded.kind === 'question-option' && typeof decoded.optionId === 'string') {
|
||||
if (
|
||||
decoded.kind === 'question-option' &&
|
||||
typeof decoded.questionId === 'string' &&
|
||||
typeof decoded.optionId === 'string'
|
||||
) {
|
||||
return {
|
||||
kind: decoded.kind,
|
||||
itemId: decoded.itemId,
|
||||
revision: decoded.revision,
|
||||
questionId: decoded.questionId,
|
||||
optionId: decoded.optionId
|
||||
}
|
||||
}
|
||||
@@ -169,6 +181,10 @@ export function projectStructuredQuestion(
|
||||
{ itemId: prompt.itemId, expectedRevision: prompt.revision }
|
||||
)
|
||||
}
|
||||
const [question] = agentSessionPromptQuestions(prompt.body)
|
||||
if (!question) {
|
||||
return null
|
||||
}
|
||||
const optionDescriptions = prompt.body.options.map((option) => option.description)
|
||||
return {
|
||||
question: prompt.body.question,
|
||||
@@ -182,6 +198,7 @@ export function projectStructuredQuestion(
|
||||
kind: 'question-option',
|
||||
itemId: prompt.itemId,
|
||||
revision: prompt.revision,
|
||||
questionId: question.id,
|
||||
optionId: option.id
|
||||
})
|
||||
),
|
||||
@@ -228,13 +245,13 @@ export function structuredApprovalResponseTarget(
|
||||
export function structuredQuestionResponseTarget(
|
||||
response: string,
|
||||
currentPrompt: StructuredQuestionItem | null
|
||||
): StructuredPromptResponseTarget | null {
|
||||
): StructuredQuestionResponseTarget | null {
|
||||
const token = decodePromptToken(response)
|
||||
if (token?.kind === 'question-option') {
|
||||
return {
|
||||
itemId: token.itemId,
|
||||
expectedRevision: token.revision,
|
||||
optionId: token.optionId
|
||||
answer: { questionId: token.questionId, optionIds: [token.optionId] }
|
||||
}
|
||||
}
|
||||
if (token) {
|
||||
@@ -247,29 +264,30 @@ export function structuredQuestionResponseTarget(
|
||||
? {
|
||||
itemId: freeText.payload.itemId,
|
||||
expectedRevision: freeText.payload.revision,
|
||||
optionId: encodeQuestionAnswer(freeText.payload.questionId, answer)
|
||||
answer: { questionId: freeText.payload.questionId, optionIds: [], other: answer }
|
||||
}
|
||||
: null
|
||||
}
|
||||
if (!currentPrompt) {
|
||||
return null
|
||||
}
|
||||
const [question] = agentSessionPromptQuestions(currentPrompt.body)
|
||||
const trimmed = response.trim()
|
||||
const option = currentPrompt.body.options.find(
|
||||
(candidate) => candidate.id === response || candidate.label === trimmed
|
||||
)
|
||||
if (option) {
|
||||
if (question && option) {
|
||||
return {
|
||||
itemId: currentPrompt.itemId,
|
||||
expectedRevision: currentPrompt.revision,
|
||||
optionId: option.id
|
||||
answer: { questionId: question.id, optionIds: [option.id] }
|
||||
}
|
||||
}
|
||||
return currentPrompt.body.freeTextQuestionId && trimmed
|
||||
return question && currentPrompt.body.freeTextQuestionId && trimmed
|
||||
? {
|
||||
itemId: currentPrompt.itemId,
|
||||
expectedRevision: currentPrompt.revision,
|
||||
optionId: encodeQuestionAnswer(currentPrompt.body.freeTextQuestionId, trimmed)
|
||||
answer: { questionId: question.id, optionIds: [], other: trimmed }
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY
|
||||
} from '../../../src/shared/protocol-version'
|
||||
import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
|
||||
|
||||
describe('structuredAgentSessionHostSupport', () => {
|
||||
it('reads each structured-session feature from the host capability list', () => {
|
||||
expect(structuredAgentSessionHostSupport([])).toEqual({
|
||||
promptCancel: false,
|
||||
questionAnswers: false
|
||||
})
|
||||
expect(
|
||||
structuredAgentSessionHostSupport([AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY])
|
||||
).toEqual({ promptCancel: false, questionAnswers: true })
|
||||
expect(
|
||||
structuredAgentSessionHostSupport([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY])
|
||||
).toEqual({ promptCancel: true, questionAnswers: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY
|
||||
} from '../../../src/shared/protocol-version'
|
||||
|
||||
/** Structured-session features the connected host advertised; null until the status probe answers. */
|
||||
export type StructuredAgentSessionHostSupport = {
|
||||
promptCancel: boolean
|
||||
questionAnswers: boolean
|
||||
}
|
||||
|
||||
export function structuredAgentSessionHostSupport(
|
||||
capabilities: readonly string[]
|
||||
): StructuredAgentSessionHostSupport {
|
||||
return {
|
||||
promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY),
|
||||
questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalQuestion } from '../../../src/shared/agent-session-journal-types'
|
||||
import { decodeAgentSessionQuestionAnswers } from '../../../src/shared/agent-session-question-answer'
|
||||
import {
|
||||
formatQuestionAnswer,
|
||||
formatQuestionFreeTextAnswer,
|
||||
@@ -85,7 +84,7 @@ describe('mobile structured grouped questions', () => {
|
||||
expect(second).toMatchObject({ question: 'Which regions? (2 of 2)', multiSelect: true })
|
||||
})
|
||||
|
||||
it('submits the whole group as one encoded answer on the last step', () => {
|
||||
it('submits the whole group as one set of answers on the last step', () => {
|
||||
const questions = [question(), SECOND]
|
||||
const draft: GroupedQuestionDraft = {
|
||||
promptKey: PROMPT_KEY,
|
||||
@@ -102,9 +101,7 @@ describe('mobile structured grouped questions', () => {
|
||||
})
|
||||
|
||||
expect(result?.kind).toBe('submit')
|
||||
expect(
|
||||
decodeAgentSessionQuestionAnswers(result?.kind === 'submit' ? result.optionId : '')
|
||||
).toEqual([
|
||||
expect(result?.kind === 'submit' ? result.answers : null).toEqual([
|
||||
{ questionId: 'q1', optionIds: ['q1:choice-1'] },
|
||||
{ questionId: 'q2', optionIds: ['q2:choice-1', 'q2:choice-2'] }
|
||||
])
|
||||
@@ -121,9 +118,9 @@ describe('mobile structured grouped questions', () => {
|
||||
promptKey: PROMPT_KEY
|
||||
})
|
||||
|
||||
expect(
|
||||
decodeAgentSessionQuestionAnswers(result?.kind === 'submit' ? result.optionId : '')
|
||||
).toEqual([{ questionId: 'q1', optionIds: [], other: 'DuckDB' }])
|
||||
expect(result?.kind === 'submit' ? result.answers : null).toEqual([
|
||||
{ questionId: 'q1', optionIds: [], other: 'DuckDB' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps selected options and other text for grouped multi-select answers', () => {
|
||||
@@ -137,9 +134,9 @@ describe('mobile structured grouped questions', () => {
|
||||
promptKey: PROMPT_KEY
|
||||
})
|
||||
|
||||
expect(
|
||||
decodeAgentSessionQuestionAnswers(result?.kind === 'submit' ? result.optionId : '')
|
||||
).toEqual([{ questionId: 'q2', optionIds: ['q2:choice-1'], other: 'ap-south' }])
|
||||
expect(result?.kind === 'submit' ? result.answers : null).toEqual([
|
||||
{ questionId: 'q2', optionIds: ['q2:choice-1'], other: 'ap-south' }
|
||||
])
|
||||
})
|
||||
|
||||
it('gives each step a distinct card key so a selection cannot carry into the next question', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AgentJournalQuestion } from '../../../src/shared/agent-session-journal-types'
|
||||
import {
|
||||
encodeAgentSessionQuestionAnswers,
|
||||
isValidAgentSessionQuestionAnswers,
|
||||
type AgentSessionQuestionAnswer
|
||||
} from '../../../src/shared/agent-session-question-answer'
|
||||
@@ -11,7 +10,7 @@ import type { MobileChatQuestion } from './mobile-native-chat-question'
|
||||
* prompt. The host then leaves the flat `question.options` EMPTY and puts the real content in
|
||||
* `questions`, so a client that reads only the flat shape renders an unanswerable card and the turn
|
||||
* stalls. The phone has room for one question at a time, so the group is answered as steps and
|
||||
* submitted once — the host accepts the whole group as one encoded option id.
|
||||
* submitted once as one set of answers.
|
||||
*/
|
||||
export type GroupedQuestionDraft = {
|
||||
/** Identifies the exact prompt revision these answers belong to; a revised prompt discards them. */
|
||||
@@ -21,7 +20,7 @@ export type GroupedQuestionDraft = {
|
||||
|
||||
export type GroupedQuestionAdvance =
|
||||
| { kind: 'advance'; draft: GroupedQuestionDraft }
|
||||
| { kind: 'submit'; optionId: string }
|
||||
| { kind: 'submit'; answers: AgentSessionQuestionAnswer[] }
|
||||
|
||||
const GROUPED_TOKEN_PREFIX = 'structured-grouped-question:'
|
||||
|
||||
@@ -195,7 +194,7 @@ function answerFromResponse(
|
||||
|
||||
/**
|
||||
* Fold one answer into the draft. Returns `advance` while questions remain and `submit` with the
|
||||
* encoded group once the last one lands; null when the response does not answer this prompt step.
|
||||
* whole group once the last one lands; null when the response does not answer this prompt step.
|
||||
*/
|
||||
export function advanceGroupedQuestion(args: {
|
||||
response: string
|
||||
@@ -218,6 +217,6 @@ export function advanceGroupedQuestion(args: {
|
||||
}
|
||||
// Never send a group the host would refuse — the user would see a silent failure with no way back.
|
||||
return isValidAgentSessionQuestionAnswers(args.questions, answers)
|
||||
? { kind: 'submit', optionId: encodeAgentSessionQuestionAnswers(answers) }
|
||||
? { kind: 'submit', answers }
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import type { MobileNativeChatTab } from './mobile-native-chat-eligibility'
|
||||
import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
|
||||
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
|
||||
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
|
||||
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
|
||||
@@ -36,7 +37,7 @@ export function useMobileNativeChatController(args: {
|
||||
/** Live socket state; the lease collapses on disconnect but one render later. */
|
||||
connState: ConnectionState
|
||||
/** Host capability fact from the shared runtime status probe. */
|
||||
agentSessionPromptCancelSupported?: boolean | null
|
||||
agentSessionHostSupport?: StructuredAgentSessionHostSupport | null
|
||||
onSendError: (message: string) => void
|
||||
/** Retires a held failure banner. Any accepted chat write clears it — a delivered
|
||||
* answer or permission reply must not sit under a stale "not sent". */
|
||||
@@ -53,7 +54,7 @@ export function useMobileNativeChatController(args: {
|
||||
nativeChatTranscriptIsLocalReadable,
|
||||
nativeChatInputLeaseReady,
|
||||
connState,
|
||||
agentSessionPromptCancelSupported = null,
|
||||
agentSessionHostSupport = null,
|
||||
onSendError,
|
||||
onSendResolved
|
||||
} = args
|
||||
@@ -93,7 +94,7 @@ export function useMobileNativeChatController(args: {
|
||||
callerIdentity: deviceTokenRef.current ?? '',
|
||||
enabled: showNativeChat,
|
||||
connState,
|
||||
promptCancelSupported: agentSessionPromptCancelSupported,
|
||||
hostSupport: agentSessionHostSupport,
|
||||
onSendError
|
||||
})
|
||||
const {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
|
||||
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
|
||||
|
||||
@@ -15,7 +16,7 @@ export function useMobileNativeChatSessionLane({
|
||||
sessionId,
|
||||
sourceIdentity,
|
||||
callerIdentity,
|
||||
promptCancelSupported,
|
||||
hostSupport,
|
||||
enabled,
|
||||
connState,
|
||||
onSendError
|
||||
@@ -30,7 +31,7 @@ export function useMobileNativeChatSessionLane({
|
||||
sessionId: string | null
|
||||
sourceIdentity: Parameters<typeof useMobileNativeChatSession>[0]['sourceIdentity']
|
||||
callerIdentity: string
|
||||
promptCancelSupported?: boolean | null
|
||||
hostSupport: StructuredAgentSessionHostSupport | null
|
||||
enabled: boolean
|
||||
connState: ConnectionState
|
||||
onSendError: (message: string) => void
|
||||
@@ -50,7 +51,7 @@ export function useMobileNativeChatSessionLane({
|
||||
sessionId: structured ? sessionId : null,
|
||||
sourceIdentity,
|
||||
callerIdentity,
|
||||
promptCancelSupported,
|
||||
hostSupport,
|
||||
enabled,
|
||||
// Holds are connection-scoped; dropping this on transport loss lets the hook
|
||||
// reacquire the provider without clearing the cached transcript.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from 'react'
|
||||
import { Animated } from 'react-native'
|
||||
import { reconcileMobileSessionCreateWarningState } from './mobile-session-create-warning-state'
|
||||
import type { MobileSessionTerminalRuntimeModel } from './use-mobile-session-terminal-runtime'
|
||||
import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
|
||||
|
||||
export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTerminalRuntimeModel) {
|
||||
const {
|
||||
@@ -32,11 +33,10 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina
|
||||
null
|
||||
)
|
||||
const [quickCommandsSupported, setQuickCommandsSupported] = useState<boolean | null>(null)
|
||||
// Prompt cancellation is negotiated with the same host capability probe as
|
||||
// Structured-session features are negotiated with the same host capability probe as
|
||||
// the other session surfaces; consumers never maintain a second status cache.
|
||||
const [agentSessionPromptCancelSupported, setAgentSessionPromptCancelSupported] = useState<
|
||||
boolean | null
|
||||
>(null)
|
||||
const [agentSessionHostSupport, setAgentSessionHostSupport] =
|
||||
useState<StructuredAgentSessionHostSupport | null>(null)
|
||||
// Why: stable callbacks (handleFileTap) read the live value via this ref, since
|
||||
// the capability probe resolves after the callbacks are created.
|
||||
const browserScreencastSupportedRef = useRef(browserScreencastSupported)
|
||||
@@ -120,8 +120,8 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina
|
||||
setAgentSessionHistorySupported,
|
||||
quickCommandsSupported,
|
||||
setQuickCommandsSupported,
|
||||
agentSessionPromptCancelSupported,
|
||||
setAgentSessionPromptCancelSupported,
|
||||
agentSessionHostSupport,
|
||||
setAgentSessionHostSupport,
|
||||
browserScreencastSupportedRef,
|
||||
reconciledCreateWarningState,
|
||||
createWarning,
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useMobileSessionNativeChatDictation(
|
||||
worktreeId,
|
||||
client,
|
||||
connState,
|
||||
agentSessionPromptCancelSupported,
|
||||
agentSessionHostSupport,
|
||||
setInput,
|
||||
liveInputTerminalHandles,
|
||||
activeHandle,
|
||||
@@ -73,7 +73,7 @@ export function useMobileSessionNativeChatDictation(
|
||||
nativeChatTranscriptIsLocalReadable,
|
||||
nativeChatInputLeaseReady,
|
||||
connState,
|
||||
agentSessionPromptCancelSupported,
|
||||
agentSessionHostSupport,
|
||||
onSendError: nativeChatSendError.show,
|
||||
onSendResolved: nativeChatSendError.clear
|
||||
})
|
||||
|
||||
@@ -2,10 +2,8 @@ import { useEffect, useRef, useCallback, useMemo, useState } from 'react'
|
||||
import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
|
||||
import { supportsMobileQuickCommands } from '../terminal/quick-commands'
|
||||
import { MOBILE_AI_VAULT_CAPABILITY } from '../agent-history/agent-history-capability'
|
||||
import {
|
||||
AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY,
|
||||
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
|
||||
} from '../../../src/shared/protocol-version'
|
||||
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
|
||||
import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
|
||||
import { runAcceptedMobileSessionTabsEffects } from './mobile-session-tabs-accepted-effects'
|
||||
import type { SessionTabsStreamSource } from './mobile-session-tabs-stream-health'
|
||||
import { useMobileSessionTabsFetchReporting } from './use-mobile-session-tabs-fetch-reporting'
|
||||
@@ -34,7 +32,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc
|
||||
switchSessionTabRef,
|
||||
setBrowserScreencastSupported,
|
||||
setAgentSessionHistorySupported,
|
||||
setAgentSessionPromptCancelSupported,
|
||||
setAgentSessionHostSupport,
|
||||
setQuickCommandsSupported,
|
||||
nativeChatStream,
|
||||
fetchTerminals,
|
||||
@@ -152,7 +150,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc
|
||||
if (!client || connState !== 'connected') {
|
||||
setBrowserScreencastSupported(null)
|
||||
setAgentSessionHistorySupported(null)
|
||||
setAgentSessionPromptCancelSupported(null)
|
||||
setAgentSessionHostSupport(null)
|
||||
setQuickCommandsSupported(null)
|
||||
setShowQuickCommands(false)
|
||||
hostQueryReplyInputSupportedRef.current = false
|
||||
@@ -162,7 +160,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc
|
||||
// host; clear the prior capability before exposing host-specific actions.
|
||||
setBrowserScreencastSupported(null)
|
||||
setAgentSessionHistorySupported(null)
|
||||
setAgentSessionPromptCancelSupported(null)
|
||||
setAgentSessionHostSupport(null)
|
||||
setQuickCommandsSupported(null)
|
||||
setShowQuickCommands(false)
|
||||
hostQueryReplyInputSupportedRef.current = false
|
||||
@@ -171,9 +169,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc
|
||||
return startRuntimeCapabilityProbe(client, (capabilities) => {
|
||||
setBrowserScreencastSupported(capabilities.includes('browser.screencast.v1'))
|
||||
setAgentSessionHistorySupported(capabilities.includes(MOBILE_AI_VAULT_CAPABILITY))
|
||||
setAgentSessionPromptCancelSupported(
|
||||
capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY)
|
||||
)
|
||||
setAgentSessionHostSupport(structuredAgentSessionHostSupport(capabilities))
|
||||
setQuickCommandsSupported(supportsMobileQuickCommands(capabilities))
|
||||
// Why: hosts without this capability strip inputKind from terminal.send,
|
||||
// so a forwarded xterm reply would become floor-stealing shell input.
|
||||
|
||||
@@ -5,7 +5,14 @@ import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-j
|
||||
import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ sendRequest: vi.fn() }))
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sendRequest: vi.fn(),
|
||||
promptResponses: vi.fn(() => ({
|
||||
groupedDraft: null,
|
||||
respondPermission: vi.fn(),
|
||||
respondQuestion: vi.fn()
|
||||
}))
|
||||
}))
|
||||
vi.mock('./use-mobile-structured-agent-state', () => ({
|
||||
useMobileStructuredAgentState: () => ({
|
||||
state,
|
||||
@@ -25,11 +32,7 @@ vi.mock('./use-mobile-structured-agent-options', () => ({
|
||||
})
|
||||
}))
|
||||
vi.mock('./use-mobile-structured-prompt-responses', () => ({
|
||||
useMobileStructuredPromptResponses: () => ({
|
||||
groupedDraft: null,
|
||||
respondPermission: vi.fn(),
|
||||
respondQuestion: vi.fn()
|
||||
})
|
||||
useMobileStructuredPromptResponses: mocks.promptResponses
|
||||
}))
|
||||
vi.mock('./use-mobile-structured-send-operation-reconciliation', () => ({
|
||||
useMobileStructuredSendOperationReconciliation: vi.fn()
|
||||
@@ -104,7 +107,13 @@ const client: RpcClient = {
|
||||
close: () => {}
|
||||
}
|
||||
|
||||
function Harness({ promptCancelSupported }: { promptCancelSupported: boolean }): null {
|
||||
function Harness({
|
||||
promptCancelSupported,
|
||||
questionAnswersSupported = false
|
||||
}: {
|
||||
promptCancelSupported: boolean
|
||||
questionAnswersSupported?: boolean
|
||||
}): null {
|
||||
hook = useMobileStructuredAgentSession({
|
||||
client,
|
||||
sessionId: 'session-1',
|
||||
@@ -112,7 +121,7 @@ function Harness({ promptCancelSupported }: { promptCancelSupported: boolean }):
|
||||
enabled: true,
|
||||
connected: true,
|
||||
agent: 'codex',
|
||||
promptCancelSupported,
|
||||
hostSupport: { promptCancel: promptCancelSupported, questionAnswers: questionAnswersSupported },
|
||||
onSendError: vi.fn()
|
||||
})
|
||||
return null
|
||||
@@ -151,6 +160,17 @@ describe('mobile structured prompt cancellation', () => {
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('hands question answering the host answers capability', () => {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(Harness, { promptCancelSupported: false, questionAnswersSupported: true })
|
||||
)
|
||||
})
|
||||
expect(mocks.promptResponses).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ questionAnswersSupported: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('sends the clicked prompt identity on capable hosts', async () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { promptCancelSupported: true }))
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { MobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status'
|
||||
import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state'
|
||||
import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses'
|
||||
import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
|
||||
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
|
||||
import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing'
|
||||
import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send'
|
||||
@@ -77,8 +78,8 @@ export function useMobileStructuredAgentSession(args: {
|
||||
enabled: boolean
|
||||
/** Live transport only; gates the connection-scoped hold, nothing else. */
|
||||
connected: boolean
|
||||
/** Capability fact from the shared runtime status probe; null follows legacy cancellation. */
|
||||
promptCancelSupported?: boolean | null
|
||||
/** Capability facts from the shared runtime status probe; null follows the legacy wire. */
|
||||
hostSupport: StructuredAgentSessionHostSupport | null
|
||||
agent: string | null
|
||||
onSendError: (message: string) => void
|
||||
}): StructuredMobileSession {
|
||||
@@ -91,8 +92,9 @@ export function useMobileStructuredAgentSession(args: {
|
||||
sourceIdentity = '',
|
||||
enabled,
|
||||
onSendError,
|
||||
promptCancelSupported = null
|
||||
hostSupport
|
||||
} = args
|
||||
const promptCancelSupported = hostSupport?.promptCancel ?? null
|
||||
const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId])
|
||||
const operationIdsRef = useRef(new Map<string, string>())
|
||||
const commandPendingRef = useRef(false)
|
||||
@@ -240,6 +242,7 @@ export function useMobileStructuredAgentSession(args: {
|
||||
stateRef,
|
||||
sessionKey,
|
||||
mutate,
|
||||
questionAnswersSupported: hostSupport?.questionAnswers ?? null,
|
||||
onSendError
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
type StructuredAgentSessionState
|
||||
} from '../../../src/shared/structured-agent-session-reducer'
|
||||
import { projectStructuredQuestion } from './mobile-structured-agent-prompts'
|
||||
import { encodeAgentSessionQuestionAnswers } from '../../../src/shared/agent-session-question-answer'
|
||||
import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question'
|
||||
import {
|
||||
projectStructuredQuestion,
|
||||
type StructuredQuestionItem
|
||||
} from './mobile-structured-agent-prompts'
|
||||
import type {
|
||||
StructuredAgentSessionMutate,
|
||||
StructuredAgentSessionMutationResult
|
||||
@@ -72,6 +77,8 @@ function Probe(props: {
|
||||
sessionKey: string
|
||||
state: StructuredAgentSessionState
|
||||
mutate: StructuredAgentSessionMutate
|
||||
questionAnswersSupported?: boolean | null
|
||||
onSendError?: (message: string) => void
|
||||
}) {
|
||||
const stateRef = useRef(props.state)
|
||||
stateRef.current = props.state
|
||||
@@ -79,7 +86,8 @@ function Probe(props: {
|
||||
stateRef,
|
||||
sessionKey: props.sessionKey,
|
||||
mutate: props.mutate,
|
||||
onSendError: vi.fn()
|
||||
questionAnswersSupported: props.questionAnswersSupported ?? null,
|
||||
onSendError: props.onSendError ?? vi.fn()
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -172,4 +180,298 @@ describe('useMobileStructuredPromptResponses', () => {
|
||||
expect(hook().groupedDraft?.answers).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
describe('answer wire', () => {
|
||||
const LONG_ANSWER = 'Proceed with the replacement, but wait for the capture. '.repeat(30).trim()
|
||||
|
||||
function singlePrompt(): StructuredQuestionItem {
|
||||
return {
|
||||
itemId: 'item-s',
|
||||
revision: 3,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: {
|
||||
kind: 'question',
|
||||
question: 'Anything else?',
|
||||
options: [
|
||||
{ id: 'yes', label: 'Yes' },
|
||||
{ id: 'no', label: 'No' }
|
||||
],
|
||||
freeTextQuestionId: 'notes',
|
||||
resolution: {
|
||||
state: 'pending',
|
||||
selectedOptionId: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Records what was sent; the verdict is irrelevant to the wire shape under test.
|
||||
function recordingMutate() {
|
||||
const sent: { method: string; fields: Record<string, unknown> }[] = []
|
||||
const mutate: StructuredAgentSessionMutate = async (method, _fingerprint, fields) => {
|
||||
sent.push({ method, fields })
|
||||
return { status: 'rejected' }
|
||||
}
|
||||
return { mutate, sent }
|
||||
}
|
||||
|
||||
function mount(
|
||||
prompt: AgentJournalRenderItem,
|
||||
mutate: StructuredAgentSessionMutate,
|
||||
questionAnswersSupported: boolean | null,
|
||||
onSendError?: (message: string) => void
|
||||
): void {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(Probe, {
|
||||
sessionKey: 'session-a',
|
||||
state: sessionState(prompt),
|
||||
mutate,
|
||||
questionAnswersSupported,
|
||||
onSendError
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function sentFields(sent: ReturnType<typeof recordingMutate>['sent']): Record<string, unknown> {
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]!.method).toBe('agentSession.respondToQuestion')
|
||||
return sent[0]!.fields
|
||||
}
|
||||
|
||||
it('sends a long typed answer as structured answers to a capable host', async () => {
|
||||
const prompt = singlePrompt()
|
||||
const { mutate, sent } = recordingMutate()
|
||||
mount(prompt, mutate, true)
|
||||
const card = projectStructuredQuestion(prompt)!
|
||||
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(formatQuestionFreeTextAnswer(card, LONG_ANSWER))
|
||||
})
|
||||
|
||||
expect(sentFields(sent)).toEqual({
|
||||
itemId: 'item-s',
|
||||
expectedRevision: 3,
|
||||
answers: [{ questionId: 'notes', optionIds: [], other: LONG_ANSWER }]
|
||||
})
|
||||
})
|
||||
|
||||
it('packs a typed answer into the option id for a host that predates answers', async () => {
|
||||
const prompt = singlePrompt()
|
||||
const { mutate, sent } = recordingMutate()
|
||||
mount(prompt, mutate, null)
|
||||
const card = projectStructuredQuestion(prompt)!
|
||||
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(formatQuestionFreeTextAnswer(card, ' DuckDB '))
|
||||
})
|
||||
|
||||
expect(sentFields(sent)).toEqual({
|
||||
itemId: 'item-s',
|
||||
expectedRevision: 3,
|
||||
optionId: 'notes:DuckDB'
|
||||
})
|
||||
})
|
||||
|
||||
it('tries structured answers for an answer too long to pack while support is unknown', async () => {
|
||||
const prompt = singlePrompt()
|
||||
const { mutate, sent } = recordingMutate()
|
||||
mount(prompt, mutate, null)
|
||||
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(
|
||||
formatQuestionFreeTextAnswer(projectStructuredQuestion(prompt)!, LONG_ANSWER)
|
||||
)
|
||||
})
|
||||
|
||||
expect(sentFields(sent)).toEqual({
|
||||
itemId: 'item-s',
|
||||
expectedRevision: 3,
|
||||
answers: [{ questionId: 'notes', optionIds: [], other: LONG_ANSWER }]
|
||||
})
|
||||
})
|
||||
|
||||
it('tells the user to update an older host instead of sending an answer it must refuse', async () => {
|
||||
const prompt = singlePrompt()
|
||||
const { mutate, sent } = recordingMutate()
|
||||
const onSendError = vi.fn()
|
||||
mount(prompt, mutate, false, onSendError)
|
||||
|
||||
let accepted = true
|
||||
await act(async () => {
|
||||
accepted = await hook().respondQuestion(
|
||||
formatQuestionFreeTextAnswer(projectStructuredQuestion(prompt)!, LONG_ANSWER)
|
||||
)
|
||||
})
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(sent).toEqual([])
|
||||
expect(onSendError).toHaveBeenCalledWith(
|
||||
'Update Orca on your computer to send answers this long'
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[true, { answers: [{ questionId: 'notes', optionIds: ['no'] }] }],
|
||||
[false, { optionId: 'no' }]
|
||||
])(
|
||||
'answers an option tap for the question it was shown on (answers: %s)',
|
||||
async (supported, wire) => {
|
||||
const prompt = singlePrompt()
|
||||
const { mutate, sent } = recordingMutate()
|
||||
mount(prompt, mutate, supported)
|
||||
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(projectStructuredQuestion(prompt)!.optionTokens[1]!)
|
||||
})
|
||||
|
||||
expect(sentFields(sent)).toEqual({ itemId: 'item-s', expectedRevision: 3, ...wire })
|
||||
}
|
||||
)
|
||||
|
||||
it.each([true, false])('submits a grouped question once (answers: %s)', async (supported) => {
|
||||
const prompt = groupedPrompt('item-g', 1)
|
||||
const { mutate, sent } = recordingMutate()
|
||||
mount(prompt, mutate, supported)
|
||||
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(projectedResponse(prompt, null))
|
||||
})
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(projectedResponse(prompt, hook().groupedDraft))
|
||||
})
|
||||
|
||||
const answers = [
|
||||
{ questionId: 'q1', optionIds: ['q1:choice-1'] },
|
||||
{ questionId: 'q2', optionIds: ['q2:choice-1'] }
|
||||
]
|
||||
expect(sentFields(sent)).toEqual({
|
||||
itemId: 'item-g',
|
||||
expectedRevision: 1,
|
||||
...(supported ? { answers } : { optionId: encodeAgentSessionQuestionAnswers(answers) })
|
||||
})
|
||||
})
|
||||
|
||||
// Claude always sends a question list, so this is the path a long typed Claude answer takes.
|
||||
it.each([
|
||||
[true, true],
|
||||
[null, true],
|
||||
[false, false]
|
||||
])('submits a grouped long typed answer (answers: %s)', async (supported, expectSent) => {
|
||||
const base = groupedPrompt('item-g', 1)
|
||||
if (base.body.kind !== 'question' || !base.body.questions) {
|
||||
throw new Error('expected a grouped question')
|
||||
}
|
||||
const [first, second] = base.body.questions
|
||||
const prompt: AgentJournalRenderItem = {
|
||||
...base,
|
||||
body: { ...base.body, questions: [first!, { ...second!, freeTextQuestionId: 'q2' }] }
|
||||
}
|
||||
const { mutate, sent } = recordingMutate()
|
||||
const onSendError = vi.fn()
|
||||
mount(prompt, mutate, supported, onSendError)
|
||||
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(projectedResponse(prompt, null))
|
||||
})
|
||||
const step = projectStructuredQuestion(prompt, hook().groupedDraft)!
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(formatQuestionFreeTextAnswer(step, LONG_ANSWER))
|
||||
})
|
||||
|
||||
if (!expectSent) {
|
||||
expect(sent).toEqual([])
|
||||
expect(onSendError).toHaveBeenCalledWith(
|
||||
'Update Orca on your computer to send answers this long'
|
||||
)
|
||||
return
|
||||
}
|
||||
expect(sentFields(sent)).toEqual({
|
||||
itemId: 'item-g',
|
||||
expectedRevision: 1,
|
||||
answers: [
|
||||
{ questionId: 'q1', optionIds: ['q1:choice-1'] },
|
||||
{ questionId: 'q2', optionIds: [], other: LONG_ANSWER }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a grouped draft an older host cannot take so it sends after an update', async () => {
|
||||
const base = groupedPrompt('item-g', 1)
|
||||
if (base.body.kind !== 'question' || !base.body.questions) {
|
||||
throw new Error('expected a grouped question')
|
||||
}
|
||||
const [first, second] = base.body.questions
|
||||
const prompt: AgentJournalRenderItem = {
|
||||
...base,
|
||||
body: { ...base.body, questions: [{ ...first!, freeTextQuestionId: 'q1' }, second!] }
|
||||
}
|
||||
const { mutate, sent } = recordingMutate()
|
||||
const onSendError = vi.fn()
|
||||
mount(prompt, mutate, false, onSendError)
|
||||
|
||||
let advanced = false
|
||||
await act(async () => {
|
||||
advanced = await hook().respondQuestion(
|
||||
formatQuestionFreeTextAnswer(projectStructuredQuestion(prompt, null)!, LONG_ANSWER)
|
||||
)
|
||||
})
|
||||
expect(advanced).toBe(true)
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(projectedResponse(prompt, hook().groupedDraft))
|
||||
})
|
||||
|
||||
expect(sent).toEqual([])
|
||||
expect(onSendError).toHaveBeenCalledWith(
|
||||
'Update Orca on your computer to send answers this long'
|
||||
)
|
||||
expect(hook().groupedDraft).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
renderer?.update(
|
||||
createElement(Probe, {
|
||||
sessionKey: 'session-a',
|
||||
state: sessionState(prompt),
|
||||
mutate,
|
||||
questionAnswersSupported: true,
|
||||
onSendError
|
||||
})
|
||||
)
|
||||
})
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(projectedResponse(prompt, hook().groupedDraft))
|
||||
})
|
||||
|
||||
expect(sentFields(sent)).toEqual({
|
||||
itemId: 'item-g',
|
||||
expectedRevision: 1,
|
||||
answers: [
|
||||
{ questionId: 'q1', optionIds: [], other: LONG_ANSWER },
|
||||
{ questionId: 'q2', optionIds: ['q2:choice-1'] }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('names the single question an option tap answers when the prompt has no typed field', async () => {
|
||||
const base = singlePrompt()
|
||||
const { freeTextQuestionId: _omitted, ...body } = base.body
|
||||
const prompt: StructuredQuestionItem = { ...base, body }
|
||||
const { mutate, sent } = recordingMutate()
|
||||
mount(prompt, mutate, true)
|
||||
|
||||
await act(async () => {
|
||||
await hook().respondQuestion(projectStructuredQuestion(prompt)!.optionTokens[0]!)
|
||||
})
|
||||
|
||||
expect(sentFields(sent)).toEqual({
|
||||
itemId: 'item-s',
|
||||
expectedRevision: 3,
|
||||
answers: [{ questionId: 'q1', optionIds: ['yes'] }]
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { AgentSessionPromptResult } from '../../../src/shared/agent-session-wire'
|
||||
import type { AgentJournalQuestionItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import {
|
||||
AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH,
|
||||
legacyAgentSessionSelectedOptionId,
|
||||
type AgentSessionQuestionAnswer
|
||||
} from '../../../src/shared/agent-session-question-answer'
|
||||
import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer'
|
||||
import {
|
||||
pendingStructuredApproval,
|
||||
@@ -7,7 +13,10 @@ import {
|
||||
structuredApprovalResponseTarget,
|
||||
structuredQuestionResponseTarget
|
||||
} from './mobile-structured-agent-prompts'
|
||||
import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc'
|
||||
import type {
|
||||
StructuredAgentSessionMutate,
|
||||
StructuredAgentSessionMutationResult
|
||||
} from './mobile-structured-agent-session-rpc'
|
||||
import {
|
||||
advanceGroupedQuestion,
|
||||
groupedQuestionPromptKey,
|
||||
@@ -23,13 +32,15 @@ export function useMobileStructuredPromptResponses(args: {
|
||||
stateRef: { readonly current: StructuredAgentSessionState }
|
||||
sessionKey: string
|
||||
mutate: StructuredAgentSessionMutate
|
||||
/** Host takes structured `answers`; otherwise the answer is packed into `optionId`. */
|
||||
questionAnswersSupported: boolean | null
|
||||
onSendError: (message: string) => void
|
||||
}): {
|
||||
groupedDraft: GroupedQuestionDraft | null
|
||||
respondPermission: (optionId: string) => Promise<boolean>
|
||||
respondQuestion: (answer: string) => Promise<boolean>
|
||||
} {
|
||||
const { mutate, onSendError, sessionKey, stateRef } = args
|
||||
const { mutate, onSendError, questionAnswersSupported, sessionKey, stateRef } = args
|
||||
// Partially answered grouped question, held only until its last step is submitted. The session it
|
||||
// was collected in is stored with it and checked on read, so switching sessions drops the draft
|
||||
// without an effect that would render the stale one for a frame first.
|
||||
@@ -62,6 +73,38 @@ export function useMobileStructuredPromptResponses(args: {
|
||||
[mutate, onSendError, stateRef]
|
||||
)
|
||||
|
||||
const sendAnswers = useCallback(
|
||||
(
|
||||
target: { itemId: string; expectedRevision: number },
|
||||
body: Pick<AgentJournalQuestionItem, 'questions'>,
|
||||
answers: AgentSessionQuestionAnswer[]
|
||||
): Promise<StructuredAgentSessionMutationResult<AgentSessionPromptResult> | null> => {
|
||||
const send = (fields: Record<string, unknown>) =>
|
||||
mutate<AgentSessionPromptResult>(
|
||||
'agentSession.respondToQuestion',
|
||||
'agentSession.respondTo:question',
|
||||
{ ...target, ...fields }
|
||||
)
|
||||
if (questionAnswersSupported === true) {
|
||||
return send({ answers })
|
||||
}
|
||||
const optionId = legacyAgentSessionSelectedOptionId(body, answers)
|
||||
if (optionId === null) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (optionId.length > AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH) {
|
||||
// Too long to pack for any host: while support is unknown, `answers` is the only form that can land.
|
||||
if (questionAnswersSupported === null) {
|
||||
return send({ answers })
|
||||
}
|
||||
onSendError('Update Orca on your computer to send answers this long')
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
return send({ optionId })
|
||||
},
|
||||
[mutate, onSendError, questionAnswersSupported]
|
||||
)
|
||||
|
||||
const respondQuestion = useCallback(
|
||||
async (answer: string): Promise<boolean> => {
|
||||
const prompt = stateRef.current.items.find(pendingStructuredQuestion) ?? null
|
||||
@@ -80,11 +123,14 @@ export function useMobileStructuredPromptResponses(args: {
|
||||
setCollected({ sessionKey, draft: grouped.draft })
|
||||
return true
|
||||
}
|
||||
const result = await mutate<AgentSessionPromptResult>(
|
||||
'agentSession.respondToQuestion',
|
||||
'agentSession.respondTo:question',
|
||||
{ itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: grouped.optionId }
|
||||
const result = await sendAnswers(
|
||||
{ itemId: prompt.itemId, expectedRevision: prompt.revision },
|
||||
prompt.body,
|
||||
grouped.answers
|
||||
)
|
||||
if (!result) {
|
||||
return false
|
||||
}
|
||||
if (result.status !== 'rejected') {
|
||||
// The group left the phone; a retry must start from the first question, not a stale tail.
|
||||
setCollected((current) =>
|
||||
@@ -103,18 +149,22 @@ export function useMobileStructuredPromptResponses(args: {
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
const result = await mutate<AgentSessionPromptResult>(
|
||||
'agentSession.respondToQuestion',
|
||||
'agentSession.respondTo:question',
|
||||
target
|
||||
const result = await sendAnswers(
|
||||
{ itemId: target.itemId, expectedRevision: target.expectedRevision },
|
||||
// Grouped questions returned above, so this is a single question.
|
||||
{},
|
||||
[target.answer]
|
||||
)
|
||||
if (!result) {
|
||||
return false
|
||||
}
|
||||
if (result.status === 'unknown') {
|
||||
onSendError('Answer unconfirmed — check chat before retrying')
|
||||
return false
|
||||
}
|
||||
return result.status === 'accepted'
|
||||
},
|
||||
[groupedDraft, mutate, onSendError, sessionKey, stateRef]
|
||||
[groupedDraft, onSendError, sendAnswers, sessionKey, stateRef]
|
||||
)
|
||||
|
||||
return { groupedDraft, respondPermission, respondQuestion }
|
||||
|
||||
@@ -5,6 +5,9 @@ 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
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import { z } from 'zod'
|
||||
import { isAgentSessionSurfaceTabId } from '../agent-session-surface-tab-id'
|
||||
import { isAgentSessionId } from '../agent-session-record'
|
||||
import { normalizeExecutionHostId } from '../execution-host'
|
||||
import { AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES } from '../agent-session-question-answer'
|
||||
import {
|
||||
AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES,
|
||||
AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH
|
||||
} from '../agent-session-question-answer'
|
||||
import {
|
||||
AGENT_SESSION_ID_MAX_LENGTH,
|
||||
AGENT_SESSION_HISTORY_DIRECTIONS,
|
||||
@@ -13,7 +16,7 @@ import {
|
||||
export const MAX_ID_LENGTH = AGENT_SESSION_ID_MAX_LENGTH
|
||||
|
||||
// Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded.
|
||||
export const MAX_RESPONSE_OPTION_ID_LENGTH = 1024
|
||||
export const MAX_RESPONSE_OPTION_ID_LENGTH = AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH
|
||||
|
||||
export const MAX_PROMPT_BYTES = 256 * 1024
|
||||
|
||||
|
||||
Reference in New Issue
Block a user