From 75bcd328cfb35e54c252738ee4c9aed128d3fa41 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:27:06 +0000 Subject: [PATCH] fix(mobile): answer multi-question asks like desktop and clear Claude's wait on phone answers (#33968) * fix(runtime): clear Claude's question wait when a phone keystroke answers it Claude sends no hook after AskUserQuestion is answered; desktop infers it, phone answers left the pane waiting. * fix(mobile): answer multi-question cards like desktop, with Skip and back-stepping Mobile required every answer and locked grouped structured questions once tapped; the card rules and structured mapping now live in shared code. * fix(mobile): announce ask card options as checkboxes or radios * fix(mobile): answer a grouped question only on the prompt it was picked on * test(runtime): drop the multi-question case the shared answer rule already covers * refactor(native-chat): share the typed-answer rules and fold the host answer check into terminal.send --- mobile/src/session/MobileNativeChatView.tsx | 2 +- .../mobile-native-chat-controller-contract.ts | 6 +- ...e-structured-agent-prompts-grouped.test.ts | 48 --- .../mobile-structured-agent-prompts.ts | 22 +- ...obile-structured-agent-session-contract.ts | 2 +- ...mobile-structured-grouped-question.test.ts | 289 ------------------ .../mobile-structured-grouped-question.ts | 211 ------------- .../input/MobileNativeChatAsk.test.tsx | 81 ++++- .../native-chat/input/MobileNativeChatAsk.tsx | 224 +++++--------- .../input/MobileNativeChatAskOptionRow.tsx | 96 ++++++ .../input/MobileNativeChatPromptCard.tsx | 10 +- .../input/MobileNativeChatStructuredAsk.tsx | 30 ++ .../input/mobile-native-chat-question.ts | 7 +- ...tured-agent-session-prompt-cancel.test.tsx | 1 - .../use-mobile-structured-agent-session.ts | 5 +- ...obile-structured-prompt-responses.test.tsx | 202 ++---------- .../use-mobile-structured-prompt-responses.ts | 58 ++-- .../methods/terminal/terminal-send-method.ts | 4 +- .../terminal-send-question-answered.ts | 58 ++++ .../terminal-send-question-answered.test.ts | 68 +++++ .../native-chat/NativeChatQuestionCard.tsx | 41 +-- .../NativeChatStructuredQuestionCard.tsx | 42 +-- src/shared/native-chat-ask-card-rules.ts | 51 ++++ src/shared/structured-agent-question-card.ts | 59 ++++ 24 files changed, 644 insertions(+), 973 deletions(-) delete mode 100644 mobile/src/session/mobile-structured-agent-prompts-grouped.test.ts delete mode 100644 mobile/src/session/mobile-structured-grouped-question.test.ts delete mode 100644 mobile/src/session/mobile-structured-grouped-question.ts create mode 100644 mobile/src/session/native-chat/input/MobileNativeChatAskOptionRow.tsx create mode 100644 mobile/src/session/native-chat/input/MobileNativeChatStructuredAsk.tsx create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-send-question-answered.ts create mode 100644 src/main/runtime/rpc/terminal-send-question-answered.test.ts create mode 100644 src/shared/native-chat-ask-card-rules.ts create mode 100644 src/shared/structured-agent-question-card.ts diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index f4a6ef6003a..f3de19cb4cc 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -126,7 +126,7 @@ type Props = ComposerTrayProps & { onCollapsePrompt?: () => void collapsedPrompt?: { title: string; expand: () => void } | null question?: MobileChatQuestion | null - onAnswerQuestion?: (text: string) => Promise + onAnswerQuestion?: (text: string, prompt?: MobileChatQuestion['prompt']) => Promise permission?: MobileChatPermission | null promptKey?: string | null onRespondPermission?: (send: string) => Promise diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 004c4cd4b23..7afb41e487f 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -78,7 +78,11 @@ export type MobileNativeChatController = { nativeChatBackgroundTasks: MobileStructuredBackgroundTasks | null nativeChatFilePaths: string[] loadNativeChatFiles: (query: string) => void - handleNativeChatQuestionAnswer?: (text: string) => Promise + /** A grouped structured answer names the prompt it was rendered for. */ + handleNativeChatQuestionAnswer?: ( + text: string, + prompt?: MobileChatQuestion['prompt'] + ) => Promise handleNativeChatSend: (text: string, images?: string[]) => Promise // Preserve unknown outcomes to heal orphaned image pastes; share their deadline with text and retain the original pre-paste sentAt. handleNativeChatSendWithOutcome: ( diff --git a/mobile/src/session/mobile-structured-agent-prompts-grouped.test.ts b/mobile/src/session/mobile-structured-agent-prompts-grouped.test.ts deleted file mode 100644 index 9134e96379f..00000000000 --- a/mobile/src/session/mobile-structured-agent-prompts-grouped.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - projectStructuredQuestion, - type StructuredQuestionItem -} from './mobile-structured-agent-prompts' - -// Grouped Claude prompts leave flat question/options as placeholders; real content lives in questions. -function groupedPrompt(): StructuredQuestionItem { - return { - itemId: 'item-1', - revision: 1, - sequence: 1, - observedAt: 1, - body: { - kind: 'question', - question: '2 grouped questions from Claude', - options: [], - questions: [ - { - id: 'q1', - question: 'Which database?', - multiSelect: false, - options: [{ id: 'q1:choice-1', label: 'Postgres', description: 'Durable server' }], - freeTextQuestionId: 'q1' - }, - { - id: 'q2', - question: 'Which regions?', - multiSelect: true, - options: [{ id: 'q2:choice-1', label: 'us-east' }], - freeTextQuestionId: 'q2' - } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } - } -} - -describe('structured question projection for grouped Claude prompts', () => { - it('renders an answerable question instead of the empty placeholder card', () => { - const projected = projectStructuredQuestion(groupedPrompt()) - - expect(projected?.question).toBe('Which database? (1 of 2)') - expect(projected?.options).toEqual(['Postgres']) - expect(projected?.optionDescriptions).toEqual(['Durable server']) - expect(projected?.optionTokens.filter(Boolean)).toHaveLength(1) - }) -}) diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts index 2408af7bb81..eaa44b49b73 100644 --- a/mobile/src/session/mobile-structured-agent-prompts.ts +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -6,11 +6,6 @@ import { import { chatApprovalFromJournal } from '../../../src/shared/native-chat-interactive-prompt' import type { MobileChatPermission } from './native-chat/input/mobile-native-chat-permission' import type { MobileChatQuestion } from './native-chat/input/mobile-native-chat-question' -import { - groupedQuestionPromptKey, - projectGroupedQuestion, - type GroupedQuestionDraft -} from './mobile-structured-grouped-question' export type StructuredApprovalItem = AgentJournalRenderItem & { body: Extract @@ -149,19 +144,20 @@ export function projectStructuredPermission( } export function projectStructuredQuestion( - prompt: StructuredQuestionItem | null, - groupedDraft: GroupedQuestionDraft | null = null + prompt: StructuredQuestionItem | null ): MobileChatQuestion | null { if (prompt?.body.kind !== 'question') { return null } if (prompt.body.questions) { - return projectGroupedQuestion( - prompt.body.questions, - groupedDraft, - groupedQuestionPromptKey(prompt.itemId, prompt.revision), - { itemId: prompt.itemId, expectedRevision: prompt.revision } - ) + return { + question: prompt.body.question, + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision }, + options: [], + multiSelect: false, + optionTokens: [], + questions: prompt.body.questions + } } const [question] = agentSessionPromptQuestions(prompt.body) if (!question) { diff --git a/mobile/src/session/mobile-structured-agent-session-contract.ts b/mobile/src/session/mobile-structured-agent-session-contract.ts index 206065ad704..4476fb9ec07 100644 --- a/mobile/src/session/mobile-structured-agent-session-contract.ts +++ b/mobile/src/session/mobile-structured-agent-session-contract.ts @@ -37,7 +37,7 @@ export type StructuredMobileSession = ReturnType Promise - respondQuestion: (answer: string) => Promise + respondQuestion: (answer: string, prompt?: MobileChatQuestion['prompt']) => Promise cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise queued: MobileStructuredQueuedMessageControls commandRefusalCauses: MobileNativeChatCommandRefusalCauses diff --git a/mobile/src/session/mobile-structured-grouped-question.test.ts b/mobile/src/session/mobile-structured-grouped-question.test.ts deleted file mode 100644 index 44a6e64c25a..00000000000 --- a/mobile/src/session/mobile-structured-grouped-question.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentJournalQuestion } from '../../../src/shared/agent-session-journal-types' -import { - formatQuestionAnswer, - formatQuestionFreeTextAnswer, - mobileChatQuestionKey -} from './native-chat/input/mobile-native-chat-question' -import { - advanceGroupedQuestion, - groupedQuestionPromptKey, - projectGroupedQuestion, - type GroupedQuestionDraft -} from './mobile-structured-grouped-question' - -const PROMPT_KEY = groupedQuestionPromptKey('item-1', 3) - -function question(overrides: Partial = {}): AgentJournalQuestion { - return { - id: 'q1', - question: 'Which database?', - multiSelect: false, - options: [ - { id: 'q1:choice-1', label: 'Postgres' }, - { id: 'q1:choice-2', label: 'SQLite' } - ], - freeTextQuestionId: 'q1', - ...overrides - } -} - -const SECOND = question({ - id: 'q2', - question: 'Which regions?', - multiSelect: true, - options: [ - { id: 'q2:choice-1', label: 'us-east' }, - { id: 'q2:choice-2', label: 'eu-west' } - ], - freeTextQuestionId: 'q2' -}) - -/** Mirrors what the question card sends back for a single-select tap. */ -function tapOption(projected: NonNullable>, at: number) { - return projected.optionTokens[at] ?? '' -} - -describe('mobile structured grouped questions', () => { - it('projects the first question with real options instead of the empty flat shape', () => { - const projected = projectGroupedQuestion([question(), SECOND], null, PROMPT_KEY) - - expect(projected).toMatchObject({ - question: 'Which database? (1 of 2)', - options: ['Postgres', 'SQLite'], - multiSelect: false, - allowOther: true - }) - expect(projected?.optionTokens.every((token) => Boolean(token))).toBe(true) - expect(projected?.freeTextToken).toBeTruthy() - }) - - it('steps to the next question once the first is answered, without sending anything', () => { - const questions = [question(), SECOND] - const first = projectGroupedQuestion(questions, null, PROMPT_KEY)! - - const advance = advanceGroupedQuestion({ - response: tapOption(first, 0), - questions, - draft: null, - promptKey: PROMPT_KEY - }) - - expect(advance).toEqual({ - kind: 'advance', - draft: { promptKey: PROMPT_KEY, answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] } - }) - // Read advance non-optionally so a missing step cannot fall back to the first draft and pass. - expect(advance).toBeDefined() - const second = projectGroupedQuestion( - questions, - advance!.kind === 'advance' ? advance!.draft : null, - PROMPT_KEY - ) - expect(second).toMatchObject({ question: 'Which regions? (2 of 2)', multiSelect: true }) - }) - - it('submits the whole group as one set of answers on the last step', () => { - const questions = [question(), SECOND] - const draft: GroupedQuestionDraft = { - promptKey: PROMPT_KEY, - answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] - } - const second = projectGroupedQuestion(questions, draft, PROMPT_KEY)! - - const result = advanceGroupedQuestion({ - // Multi-select joins its selected option tokens the way the card does. - response: formatQuestionAnswer(second, ['us-east', 'eu-west']), - questions, - draft, - promptKey: PROMPT_KEY - }) - - expect(result?.kind).toBe('submit') - expect(result?.kind === 'submit' ? result.answers : null).toEqual([ - { questionId: 'q1', optionIds: ['q1:choice-1'] }, - { questionId: 'q2', optionIds: ['q2:choice-1', 'q2:choice-2'] } - ]) - }) - - it('carries a free-text answer as `other` for the question it was typed against', () => { - const questions = [question()] - const only = projectGroupedQuestion(questions, null, PROMPT_KEY)! - - const result = advanceGroupedQuestion({ - response: formatQuestionFreeTextAnswer(only, ' DuckDB '), - questions, - draft: null, - promptKey: PROMPT_KEY - }) - - expect(result?.kind === 'submit' ? result.answers : null).toEqual([ - { questionId: 'q1', optionIds: [], other: 'DuckDB' } - ]) - }) - - it('projects editor metadata and preserves allowed empty and whitespace answers', () => { - const editor = question({ - options: [], - freeTextInput: { - allowEmpty: true, - multiline: true, - initialValue: 'draft', - placeholder: 'Edit response' - } - }) - const projected = projectGroupedQuestion([editor], null, PROMPT_KEY)! - expect(projected.freeTextInput).toEqual(editor.freeTextInput) - - for (const answer of ['', ' \n ']) { - const result = advanceGroupedQuestion({ - response: formatQuestionFreeTextAnswer(projected, answer), - questions: [editor], - draft: null, - promptKey: PROMPT_KEY - }) - expect(result).toEqual({ - kind: 'submit', - answers: [{ questionId: 'q1', optionIds: [], other: answer }] - }) - } - - const legacy = question({ options: [] }) - expect( - advanceGroupedQuestion({ - response: formatQuestionFreeTextAnswer(projected, ''), - questions: [legacy], - draft: null, - promptKey: PROMPT_KEY - }) - ).toBeNull() - }) - - it('keeps selected options and other text for grouped multi-select answers', () => { - const questions = [SECOND] - const only = projectGroupedQuestion(questions, null, PROMPT_KEY)! - - const result = advanceGroupedQuestion({ - response: `${tapOption(only, 0)}, ${formatQuestionFreeTextAnswer(only, 'ap-south')}`, - questions, - draft: null, - promptKey: PROMPT_KEY - }) - - 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', () => { - // Identical question text must still remount by id/step or prior checkboxes become the next answer. - const questions = [ - question({ id: 'q1', question: 'Approve?' }), - question({ id: 'q2', question: 'Approve?' }) - ] - const first = projectGroupedQuestion(questions, null, PROMPT_KEY)! - const second = projectGroupedQuestion( - questions, - { promptKey: PROMPT_KEY, answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] }, - PROMPT_KEY - )! - - expect(first.question).toBe('Approve? (1 of 2)') - expect(second.question).toBe('Approve? (2 of 2)') - expect(mobileChatQuestionKey(first)).not.toBe(mobileChatQuestionKey(second)) - }) - - it('discards a draft collected against a superseded prompt revision', () => { - const questions = [question(), SECOND] - const stale: GroupedQuestionDraft = { - promptKey: groupedQuestionPromptKey('item-1', 2), - answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] - } - - expect(projectGroupedQuestion(questions, stale, PROMPT_KEY)).toMatchObject({ - question: 'Which database? (1 of 2)' - }) - }) - - it('refuses a response that does not answer the current step', () => { - const questions = [question(), SECOND] - - expect( - advanceGroupedQuestion({ - response: 'Postgres', - questions, - draft: null, - promptKey: PROMPT_KEY - }) - ).toBeNull() - }) - - it('refuses an option token rendered for a superseded prompt revision', () => { - const stale = projectGroupedQuestion([question()], null, groupedQuestionPromptKey('item-1', 2))! - - expect( - advanceGroupedQuestion({ - response: tapOption(stale, 0), - questions: [question()], - draft: null, - promptKey: PROMPT_KEY - }) - ).toBeNull() - }) - - it('refuses free text rendered for a superseded prompt revision', () => { - const stale = projectGroupedQuestion([question()], null, groupedQuestionPromptKey('item-1', 2))! - - expect( - advanceGroupedQuestion({ - response: formatQuestionFreeTextAnswer(stale, 'stale answer'), - questions: [question()], - draft: null, - promptKey: PROMPT_KEY - }) - ).toBeNull() - }) - - it('rejects a multi-select response when one selected token is malformed', () => { - const questions = [SECOND] - const only = projectGroupedQuestion(questions, null, PROMPT_KEY)! - - expect( - advanceGroupedQuestion({ - response: `${tapOption(only, 0)}, not-a-grouped-token`, - questions, - draft: null, - promptKey: PROMPT_KEY - }) - ).toBeNull() - }) - - it('rejects a multi-select response when one selected token belongs to another prompt', () => { - const questions = [SECOND] - const current = projectGroupedQuestion(questions, null, PROMPT_KEY)! - const stale = projectGroupedQuestion(questions, null, groupedQuestionPromptKey('item-1', 2))! - - expect( - advanceGroupedQuestion({ - response: `${tapOption(current, 0)}, ${tapOption(stale, 1)}`, - questions, - draft: null, - promptKey: PROMPT_KEY - }) - ).toBeNull() - }) - - it('refuses an empty multi-select rather than sending a group the host would reject', () => { - const questions = [SECOND] - const only = projectGroupedQuestion(questions, null, PROMPT_KEY)! - - expect( - advanceGroupedQuestion({ - response: formatQuestionAnswer(only, []), - questions, - draft: null, - promptKey: PROMPT_KEY - }) - ).toBeNull() - }) -}) diff --git a/mobile/src/session/mobile-structured-grouped-question.ts b/mobile/src/session/mobile-structured-grouped-question.ts deleted file mode 100644 index 87385ddebb8..00000000000 --- a/mobile/src/session/mobile-structured-grouped-question.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { AgentJournalQuestion } from '../../../src/shared/agent-session-journal-types' -import { - isValidAgentSessionQuestionAnswers, - type AgentSessionQuestionAnswer -} from '../../../src/shared/agent-session-question-answer' -import type { MobileChatQuestion } from './native-chat/input/mobile-native-chat-question' - -// Grouped prompts put content in questions and leave flat options empty; collect steps locally and submit the whole answer set once. -export type GroupedQuestionDraft = { - /** Identifies the exact prompt revision these answers belong to; a revised prompt discards them. */ - promptKey: string - answers: AgentSessionQuestionAnswer[] -} - -export type GroupedQuestionAdvance = - | { kind: 'advance'; draft: GroupedQuestionDraft } - | { kind: 'submit'; answers: AgentSessionQuestionAnswer[] } - -const GROUPED_TOKEN_PREFIX = 'structured-grouped-question:' - -type GroupedTokenPayload = - | { kind: 'option'; promptKey: string; questionId: string; optionId: string } - | { kind: 'free-text'; promptKey: string; questionId: string } - -export function groupedQuestionPromptKey(itemId: string, revision: number): string { - return `${itemId}:${revision}` -} - -function encodeGroupedToken(payload: GroupedTokenPayload): string { - return `${GROUPED_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}` -} - -function decodeGroupedToken(value: string): GroupedTokenPayload | null { - if (!value.startsWith(GROUPED_TOKEN_PREFIX)) { - return null - } - try { - const decoded = JSON.parse( - decodeURIComponent(value.slice(GROUPED_TOKEN_PREFIX.length)) - ) as Record - if (typeof decoded.promptKey !== 'string' || typeof decoded.questionId !== 'string') { - return null - } - if (decoded.kind === 'option' && typeof decoded.optionId === 'string') { - return { - kind: 'option', - promptKey: decoded.promptKey, - questionId: decoded.questionId, - optionId: decoded.optionId - } - } - if (decoded.kind === 'free-text') { - return { kind: 'free-text', promptKey: decoded.promptKey, questionId: decoded.questionId } - } - } catch { - return null - } - return null -} - -function decodeGroupedFreeTextAnswer(value: string): { - promptKey: string - questionId: string - answer: string -} | null { - if (!value.startsWith(GROUPED_TOKEN_PREFIX)) { - return null - } - // The payload is percent-encoded, so the first `:` after the prefix is the answer separator. - const separator = value.indexOf(':', GROUPED_TOKEN_PREFIX.length) - if (separator === -1) { - return null - } - const payload = decodeGroupedToken(value.slice(0, separator)) - if (payload?.kind !== 'free-text') { - return null - } - try { - return { - promptKey: payload.promptKey, - questionId: payload.questionId, - answer: decodeURIComponent(value.slice(separator + 1)) - } - } catch { - return null - } -} - -function answersFor( - draft: GroupedQuestionDraft | null, - promptKey: string -): AgentSessionQuestionAnswer[] { - return draft && draft.promptKey === promptKey ? draft.answers : [] -} - -export function projectGroupedQuestion( - questions: readonly AgentJournalQuestion[], - draft: GroupedQuestionDraft | null, - promptKey: string, - promptIdentity?: { itemId: string; expectedRevision: number } -): MobileChatQuestion | null { - const answered = answersFor(draft, promptKey).length - const question = questions[answered] - if (!question) { - return null - } - const heading = question.header ? `${question.header}: ${question.question}` : question.question - const optionDescriptions = question.options.map((option) => option.description) - return { - question: - questions.length > 1 ? `${heading} (${answered + 1} of ${questions.length})` : heading, - ...(promptIdentity ? { prompt: promptIdentity } : {}), - options: question.options.map((option) => option.label), - ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), - multiSelect: question.multiSelect, - allowOther: Boolean(question.freeTextQuestionId), - ...(question.freeTextInput ? { freeTextInput: question.freeTextInput } : {}), - optionTokens: question.options.map((option) => - encodeGroupedToken({ - kind: 'option', - promptKey, - questionId: question.id, - optionId: option.id - }) - ), - ...(question.freeTextQuestionId - ? { - freeTextToken: encodeGroupedToken({ - kind: 'free-text', - promptKey, - questionId: question.id - }) - } - : {}) - } -} - -function answerFromResponse( - response: string, - question: AgentJournalQuestion, - promptKey: string -): AgentSessionQuestionAnswer | null { - // Multi-select submits comma-joined parts; tokens and free text are encoded, so the separator is stable. - const optionIds: string[] = [] - let other: string | undefined - for (const part of response.split(', ')) { - const trimmed = part.trim() - const freeText = decodeGroupedFreeTextAnswer(trimmed) - if (freeText) { - const allowEmpty = question.freeTextInput?.allowEmpty === true - const answer = allowEmpty ? freeText.answer : freeText.answer.trim() - if ( - freeText.promptKey !== promptKey || - freeText.questionId !== question.id || - (answer.length === 0 && !allowEmpty) || - other !== undefined - ) { - return null - } - other = answer - continue - } - - const payload = decodeGroupedToken(trimmed) - if ( - payload?.kind !== 'option' || - payload.promptKey !== promptKey || - payload.questionId !== question.id - ) { - return null - } - optionIds.push(payload.optionId) - } - const offered = new Set(question.options.map((option) => option.id)) - if (optionIds.some((optionId) => !offered.has(optionId))) { - return null - } - if (other !== undefined && !question.freeTextQuestionId) { - return null - } - const answerCount = optionIds.length + (other !== undefined ? 1 : 0) - if (answerCount === 0 || (!question.multiSelect && answerCount !== 1)) { - return null - } - return { questionId: question.id, optionIds, ...(other !== undefined ? { other } : {}) } -} - -export function advanceGroupedQuestion(args: { - response: string - questions: readonly AgentJournalQuestion[] - draft: GroupedQuestionDraft | null - promptKey: string -}): GroupedQuestionAdvance | null { - const collected = answersFor(args.draft, args.promptKey) - const question = args.questions[collected.length] - if (!question) { - return null - } - const answer = answerFromResponse(args.response, question, args.promptKey) - if (!answer) { - return null - } - const answers = [...collected, answer] - if (answers.length < args.questions.length) { - return { kind: 'advance', draft: { promptKey: args.promptKey, answers } } - } - // Validate before submission so an unanswerable refusal cannot strand the group. - return isValidAgentSessionQuestionAnswers(args.questions, answers) - ? { kind: 'submit', answers } - : null -} diff --git a/mobile/src/session/native-chat/input/MobileNativeChatAsk.test.tsx b/mobile/src/session/native-chat/input/MobileNativeChatAsk.test.tsx index 9a6a34e0ef8..c07eeadba23 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatAsk.test.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatAsk.test.tsx @@ -1,9 +1,11 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AskPrompt } from '../../../../../src/shared/native-chat-ask-types' +import type { AskPrompt } from '../../../../../src/shared/native-chat-ask' import { NATIVE_CHAT_QUESTION_AUTO_ADVANCE_MS } from '../../../../../src/shared/native-chat-question-auto-advance' +import { encodeAgentSessionQuestionAnswers } from '../../../../../src/shared/agent-session-question-answer' import { MobileNativeChatAsk } from './MobileNativeChatAsk' +import { MobileNativeChatStructuredAsk } from './MobileNativeChatStructuredAsk' vi.mock('react-native', () => ({ Keyboard: { dismiss: () => {} }, @@ -47,10 +49,10 @@ function press(label: string): void { act(() => row.props.onPress()) } -async function submit(): Promise { +async function submit(label = 'Submit'): Promise { const button = tree.root.find( (node) => - isHost(node, 'Pressable') && node.findAll((n) => n.props.children === 'Submit').length > 0 + isHost(node, 'Pressable') && node.findAll((n) => n.props.children === label).length > 0 ) expect(button.props.disabled).toBe(false) await act(async () => button.props.onPress()) @@ -130,6 +132,30 @@ describe('MobileNativeChatAsk single-select auto-advance', () => { expect(onAnswer).toHaveBeenCalledExactlyOnceWith([{ indices: [0] }]) }) + it('sends the answered questions when the last one is skipped', async () => { + const onAnswer = render({ questions: [indent, fruit] }) + + press('Tabs') + await passAutoAdvanceBeat() + await submit('Skip') + + expect(onAnswer).toHaveBeenCalledExactlyOnceWith([{ indices: [0] }, { indices: [] }]) + }) + + it('cancels the prompt when every question is skipped', async () => { + const onCancel = vi.fn(async () => true) + const onAnswer = render( + { questions: [indent] }, + vi.fn(async () => true), + onCancel + ) + + await submit('Skip') + + expect(onCancel).toHaveBeenCalledOnce() + expect(onAnswer).not.toHaveBeenCalled() + }) + it('sends nothing when the question is cancelled before the pick moves on', async () => { const onCancel = vi.fn(async () => true) const onAnswer = render( @@ -150,3 +176,52 @@ describe('MobileNativeChatAsk single-select auto-advance', () => { expect(onAnswer).not.toHaveBeenCalled() }) }) + +describe('MobileNativeChatStructuredAsk', () => { + it('lets an earlier question change before the whole set is sent', async () => { + vi.useFakeTimers() + const onAnswer = vi.fn(async () => true) + const options = (id: string, labels: string[]) => + labels.map((label, i) => ({ id: `${id}:${i}`, label })) + act(() => { + tree = create( + createElement(MobileNativeChatStructuredAsk, { + questions: [ + { + id: 'q1', + question: 'Tabs or spaces?', + multiSelect: false, + options: options('q1', ['Tabs', 'Spaces']) + }, + { + id: 'q2', + question: 'Which fruit?', + multiSelect: false, + options: options('q2', ['Apple', 'Banana']) + } + ], + onAnswer + }) + ) + }) + + press('Tabs') + await passAutoAdvanceBeat() + const firstTab = tree.root.find( + (node) => + isHost(node, 'Pressable') && node.findAll((n) => n.props.children === 'Step 1').length > 0 + ) + act(() => firstTab.props.onPress()) + press('Spaces') + await passAutoAdvanceBeat() + press('Banana') + await passAutoAdvanceBeat() + + expect(onAnswer).toHaveBeenCalledExactlyOnceWith( + encodeAgentSessionQuestionAnswers([ + { questionId: 'q1', optionIds: ['q1:1'] }, + { questionId: 'q2', optionIds: ['q2:1'] } + ]) + ) + }) +}) diff --git a/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx b/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx index a6b90c16bd6..38677ef58da 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatAsk.tsx @@ -1,12 +1,20 @@ -import { useMemo, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' import { Check } from 'lucide-react-native' -import type { AskAnswerSelection } from '../../../../../src/shared/native-chat-ask-answer' -import type { AskPrompt } from '../../../../../src/shared/native-chat-ask-types' +import type { AskAnswerSelection, AskPrompt } from '../../../../../src/shared/native-chat-ask' +import type { AgentJournalFreeTextInput } from '../../../../../src/shared/agent-session-journal-types' +import { + askAcceptsEmptyAnswer, + askConfirmAction, + askConfirmLabel, + askTypedText, + toggleAskOption +} from '../../../../../src/shared/native-chat-ask-card-rules' import { colors, radii, spacing, typography } from '../../../theme/mobile-theme' import { TEXT_INPUT_FONT_SIZE } from '../../../platform/text-input-font-size' import { MobileNativeChatCardHeaderAction } from '../../MobileNativeChatCardHeaderAction' import { useMobileNativeChatAskAutoAdvance } from './use-mobile-native-chat-ask-auto-advance' +import { MobileNativeChatAskOptionRow } from './MobileNativeChatAskOptionRow' type Props = { prompt: AskPrompt @@ -15,8 +23,13 @@ type Props = { onCancel?: () => Promise // Collapse writes nothing to the agent. onCollapse?: () => void + /** Per question; structured prompts hide "Other…" where the provider takes no free text. */ + allowOther?: readonly boolean[] + freeTextInputs?: readonly (AgentJournalFreeTextInput | undefined)[] } +const CONFIRM_LABEL = { submit: 'Submit', next: 'Next', skip: 'Skip' } as const + // Sentinel index for the free-text "Other…" row (never a real option index). const OTHER = -1 @@ -24,15 +37,25 @@ export function MobileNativeChatAsk({ prompt, onAnswer, onCancel, - onCollapse + onCollapse, + allowOther, + freeTextInputs }: Props): React.JSX.Element { const [index, setIndex] = useState(0) const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) - const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) + const [otherText, setOtherText] = useState(() => + prompt.questions.map((_, qi) => freeTextInputs?.[qi]?.initialValue ?? '') + ) const [submitting, setSubmitting] = useState(false) const submittingRef = useRef(false) const autoAdvance = useMobileNativeChatAskAutoAdvance() + // A question with no options is answered by typing alone. + const typesOnly = (qi: number): boolean => + allowOther?.[qi] !== false && prompt.questions[qi]?.options.length === 0 + const acceptsEmpty = (qi: number): boolean => + askAcceptsEmptyAnswer(prompt.questions[qi], freeTextInputs?.[qi]) + // Delay single-select advance to show the choice; Other still needs text. const toggle = (qi: number, optIndex: number, multi: boolean): void => { // Block same-frame taps before disabled state renders. @@ -41,12 +64,7 @@ export function MobileNativeChatAsk({ } const cur = selections[qi] ?? [] const picked = !cur.includes(optIndex) - const chosen = !picked - ? cur.filter((i) => i !== optIndex) - : multi - ? [...cur, optIndex] - : [optIndex] - const next = selections.map((s, i) => (i === qi ? chosen : s)) + const next = selections.map((s, i) => (i === qi ? toggleAskOption(cur, optIndex, multi) : s)) setSelections(next) autoAdvance.cancel() if (picked && !multi && optIndex !== OTHER) { @@ -65,26 +83,23 @@ export function MobileNativeChatAsk({ // Pass the new selection explicitly because setState has not committed it yet. const selectionFor = (qi: number, sel = selections): AskAnswerSelection => { const picked = (sel[qi] ?? []).filter((i) => i !== OTHER) - const other = (sel[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : '' - return other ? { indices: picked, other } : { indices: picked } + const typed = typesOnly(qi) || (sel[qi] ?? []).includes(OTHER) + const other = typed ? askTypedText(otherText[qi] ?? '', freeTextInputs?.[qi]) : '' + return other || acceptsEmpty(qi) ? { indices: picked, other } : { indices: picked } } const isAnswered = (qi: number, sel = selections): boolean => { const answer = selectionFor(qi, sel) - return answer.indices.length > 0 || (answer.other ?? '').length > 0 + return answer.indices.length > 0 || (answer.other ?? '').length > 0 || acceptsEmpty(qi) } const total = prompt.questions.length const isLast = index === total - 1 - const currentAnswered = useMemo(() => isAnswered(index), [selections, otherText, index]) - const allAnswered = useMemo( - () => prompt.questions.every((_, i) => isAnswered(i)), - [otherText, prompt.questions, selections] - ) - const canAdvance = !submitting && (isLast ? allAnswered : currentAnswered) + const anyAnswered = (sel = selections): boolean => + prompt.questions.some((_, i) => isAnswered(i, sel)) const submit = async (sel: number[][]): Promise => { - if (!prompt.questions.every((_, i) => isAnswered(i, sel)) || submittingRef.current) { + if (!anyAnswered(sel) || submittingRef.current) { return } submittingRef.current = true @@ -106,8 +121,34 @@ export function MobileNativeChatAsk({ } } + const cancel = async (): Promise => { + autoAdvance.cancel() + if (!submittingRef.current && onCancel) { + submittingRef.current = true + setSubmitting(true) + try { + await onCancel() + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + } + + const confirm = (): void => { + const action = askConfirmAction({ isLast, anyAnswered: anyAnswered() }) + if (action === 'cancel') { + void cancel() + } else if (action === 'proceed') { + void advance() + } + } + const q = prompt.questions[index]! + const otherAllowed = allowOther?.[index] !== false const otherSelected = (selections[index] ?? []).includes(OTHER) + const freeTextInput = freeTextInputs?.[index] + const currentAnswered = isAnswered(index) return ( @@ -156,7 +197,7 @@ export function MobileNativeChatAsk({ /> {q.options.map((opt, optIndex) => ( - toggle(index, optIndex, q.multiSelect)} /> ))} - toggle(index, OTHER, q.multiSelect)} - /> - {otherSelected ? ( + {otherAllowed && !typesOnly(index) ? ( + toggle(index, OTHER, q.multiSelect)} + /> + ) : null} + {otherSelected || typesOnly(index) ? ( setOther(index, v)} - placeholder="Type your answer" + placeholder={freeTextInput?.placeholder ?? 'Type your answer'} placeholderTextColor={colors.textMuted} - multiline + // A declared input (Pi's `input`) is one line unless it asks for more, as on desktop. + multiline={freeTextInput ? freeTextInput.multiline === true : true} autoFocus /> ) : null} @@ -189,19 +233,7 @@ export function MobileNativeChatAsk({ { - autoAdvance.cancel() - if (!submittingRef.current && onCancel) { - submittingRef.current = true - setSubmitting(true) - try { - await onCancel() - } finally { - submittingRef.current = false - setSubmitting(false) - } - } - }} + onPress={() => void cancel()} disabled={submitting} hitSlop={8} > @@ -213,12 +245,12 @@ export function MobileNativeChatAsk({ ) : null} void advance()} - disabled={!canAdvance} + style={[styles.next, !currentAnswered && styles.nextSkip]} + onPress={confirm} + disabled={submitting} > - - {isLast ? 'Submit' : 'Next'} + + {CONFIRM_LABEL[askConfirmLabel(currentAnswered, isLast)]} @@ -226,49 +258,6 @@ export function MobileNativeChatAsk({ ) } -function OptionRow({ - label, - description, - selected, - multi, - disabled, - onPress -}: { - label: string - description?: string - selected: boolean - multi?: boolean - disabled: boolean - onPress: () => void -}): React.JSX.Element { - return ( - - {/* Multi-select reads as a checkbox (square); single-select as a radio (circle). */} - - {selected ? : null} - - - {label} - {description ? ( - - {description} - - ) : null} - - - ) -} - const styles = StyleSheet.create({ card: { maxHeight: 380, @@ -319,51 +308,6 @@ const styles = StyleSheet.create({ fontWeight: '600', marginVertical: spacing.sm }, - option: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - padding: spacing.sm, - borderRadius: radii.card, - backgroundColor: colors.bgRaised, - borderWidth: 1, - borderColor: colors.borderSubtle, - marginBottom: spacing.xs - }, - optionSelected: { - borderColor: colors.statusGreen - }, - check: { - width: 18, - height: 18, - borderWidth: 1.5, - borderColor: colors.textMuted, - alignItems: 'center', - justifyContent: 'center' - }, - checkCircle: { - borderRadius: 9 - }, - checkSquare: { - borderRadius: 4 - }, - checkOn: { - backgroundColor: colors.statusGreen, - borderColor: colors.statusGreen - }, - optionBody: { - flex: 1, - gap: 2 - }, - optionLabel: { - color: colors.textPrimary, - fontSize: typography.bodySize, - fontWeight: '600' - }, - optionDescription: { - color: colors.textSecondary, - fontSize: typography.metaSize - }, input: { backgroundColor: colors.bgRaised, borderWidth: 1, @@ -403,7 +347,7 @@ const styles = StyleSheet.create({ borderRadius: radii.button, backgroundColor: colors.textPrimary }, - nextDisabled: { + nextSkip: { backgroundColor: colors.bgRaised }, nextText: { @@ -411,7 +355,7 @@ const styles = StyleSheet.create({ fontSize: typography.bodySize, fontWeight: '700' }, - nextTextDisabled: { + nextSkipText: { color: colors.textMuted } }) diff --git a/mobile/src/session/native-chat/input/MobileNativeChatAskOptionRow.tsx b/mobile/src/session/native-chat/input/MobileNativeChatAskOptionRow.tsx new file mode 100644 index 00000000000..70a2b557113 --- /dev/null +++ b/mobile/src/session/native-chat/input/MobileNativeChatAskOptionRow.tsx @@ -0,0 +1,96 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../../../theme/mobile-theme' + +export function MobileNativeChatAskOptionRow({ + label, + description, + selected, + multi, + disabled, + onPress +}: { + label: string + description?: string + selected: boolean + multi?: boolean + disabled: boolean + onPress: () => void +}): React.JSX.Element { + return ( + + {/* Multi-select reads as a checkbox (square); single-select as a radio (circle). */} + + {selected ? : null} + + + {label} + {description ? ( + + {description} + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + option: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + padding: spacing.sm, + borderRadius: radii.card, + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginBottom: spacing.xs + }, + optionSelected: { + borderColor: colors.statusGreen + }, + check: { + width: 18, + height: 18, + borderWidth: 1.5, + borderColor: colors.textMuted, + alignItems: 'center', + justifyContent: 'center' + }, + checkCircle: { + borderRadius: 9 + }, + checkSquare: { + borderRadius: 4 + }, + checkOn: { + backgroundColor: colors.statusGreen, + borderColor: colors.statusGreen + }, + optionBody: { + flex: 1, + gap: 2 + }, + optionLabel: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + optionDescription: { + color: colors.textSecondary, + fontSize: typography.metaSize + } +}) diff --git a/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.tsx b/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.tsx index 4516f363179..4aaecc365c0 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatPromptCard.tsx @@ -6,6 +6,7 @@ import { MobileNativeChatAsk } from './MobileNativeChatAsk' import { MobileNativeChatPermission } from './MobileNativeChatPermission' import type { MobileChatPermission } from './mobile-native-chat-permission' import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' +import { MobileNativeChatStructuredAsk } from './MobileNativeChatStructuredAsk' import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' import { MobileNativeChatPromptStrip } from './MobileNativeChatPromptStrip' @@ -41,7 +42,7 @@ export function MobileNativeChatPromptCard({ permission?: MobileChatPermission | null onRespondPermission?: (send: string) => Promise question?: MobileChatQuestion | null - onAnswerQuestion?: (text: string) => Promise + onAnswerQuestion?: (text: string, prompt?: MobileChatQuestion['prompt']) => Promise }): React.JSX.Element | null { const card = ask && requiresTerminal ? ( @@ -87,6 +88,13 @@ export function MobileNativeChatPromptCard({ onCancel={onCancelPrompt} onCollapse={onCollapsePrompt} /> + ) : question?.questions ? ( + (await onAnswerQuestion?.(answers, question.prompt)) ?? false} + onCancel={onCancelPrompt && (() => onCancelPrompt(question.prompt))} + /> ) : question ? ( Promise + onCancel?: () => Promise +}): React.JSX.Element { + return ( + { + const answers = structuredQuestionAnswers(questions, selections) + return answers ? onAnswer(encodeAgentSessionQuestionAnswers(answers)) : false + }} + onCancel={onCancel} + /> + ) +} diff --git a/mobile/src/session/native-chat/input/mobile-native-chat-question.ts b/mobile/src/session/native-chat/input/mobile-native-chat-question.ts index 145171e26fa..e935188748e 100644 --- a/mobile/src/session/native-chat/input/mobile-native-chat-question.ts +++ b/mobile/src/session/native-chat/input/mobile-native-chat-question.ts @@ -1,4 +1,7 @@ -import type { AgentJournalFreeTextInput } from '../../../../../src/shared/agent-session-journal-types' +import type { + AgentJournalFreeTextInput, + AgentJournalQuestion +} from '../../../../../src/shared/agent-session-journal-types' export type MobileChatQuestion = { question: string @@ -14,6 +17,8 @@ export type MobileChatQuestion = { /** Opaque prefix used when free-text answers must target a specific prompt. */ freeTextToken?: string freeTextInput?: AgentJournalFreeTextInput + /** A grouped structured prompt, answered on the stepped ask card. */ + questions?: readonly AgentJournalQuestion[] } export function mobileChatQuestionKey(question: MobileChatQuestion): string { diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index ce34af4fbe2..ff41c8941dd 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -8,7 +8,6 @@ import type { RpcClient } from '../transport/rpc-client' const mocks = vi.hoisted(() => ({ sendRequest: vi.fn(), promptResponses: vi.fn(() => ({ - groupedDraft: null, respondPermission: vi.fn(), respondQuestion: vi.fn() })) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 791915909bb..d7cdff45cf3 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -115,9 +115,8 @@ export function useMobileStructuredAgentSession( [conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption] ) - const { groupedDraft, respondPermission, respondQuestion } = useMobileStructuredPromptResponses({ + const { respondPermission, respondQuestion } = useMobileStructuredPromptResponses({ stateRef, - sessionKey, mutate, questionAnswersSupported: hostSupport?.questionAnswers ?? null, onSendError @@ -300,7 +299,7 @@ export function useMobileStructuredAgentSession( receipts: structuredQuestionTranscript(transcriptItems).receipts, actionablePromptIds: state.actionablePromptIds, permission: projectStructuredPermission(approvalPrompt), - question: projectStructuredQuestion(questionPrompt, groupedDraft), + question: projectStructuredQuestion(questionPrompt), respondPermission, respondQuestion, queued, diff --git a/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx b/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx index 0d3399d487f..b17a48c575c 100644 --- a/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx +++ b/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx @@ -1,8 +1,11 @@ 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 { + encodeAgentSessionQuestionAnswers, + type AgentSessionQuestionAnswer +} from '../../../src/shared/agent-session-question-answer' import { EMPTY_STRUCTURED_AGENT_SESSION, type StructuredAgentSessionState @@ -12,11 +15,7 @@ import { projectStructuredQuestion, type StructuredQuestionItem } from './mobile-structured-agent-prompts' -import type { - StructuredAgentSessionMutate, - StructuredAgentSessionMutationResult -} from './mobile-structured-agent-session-rpc' -import { groupedQuestionPromptKey } from './mobile-structured-grouped-question' +import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' type PromptResponses = ReturnType @@ -63,17 +62,7 @@ function sessionState(prompt: AgentJournalRenderItem): StructuredAgentSessionSta return { ...EMPTY_STRUCTURED_AGENT_SESSION, status: 'ready', items: [prompt] } } -function projectedResponse(prompt: StructuredQuestionItem, 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 questionAnswersSupported?: boolean | null @@ -83,7 +72,6 @@ function Probe(props: { stateRef.current = props.state currentHook = useMobileStructuredPromptResponses({ stateRef, - sessionKey: props.sessionKey, mutate: props.mutate, questionAnswersSupported: props.questionAnswersSupported ?? null, onSendError: props.onSendError ?? vi.fn() @@ -105,81 +93,6 @@ afterEach(() => { }) 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 - ) => void - const pendingMutation = new Promise< - StructuredAgentSessionMutationResult - >((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 - 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) - } - ) - describe('answer wire', () => { const LONG_ANSWER = 'Proceed with the replacement, but wait for the capture. '.repeat(30).trim() @@ -225,7 +138,6 @@ describe('useMobileStructuredPromptResponses', () => { act(() => { renderer = create( createElement(Probe, { - sessionKey: 'session-a', state: sessionState(prompt), mutate, questionAnswersSupported, @@ -353,22 +265,37 @@ describe('useMobileStructuredPromptResponses', () => { } ) + const RENDERED_GROUP = { itemId: 'item-g', expectedRevision: 1 } + + it('refuses grouped answers picked on a prompt that has since been replaced', async () => { + const { mutate, sent } = recordingMutate() + mount(groupedPrompt('item-g', 2), mutate, true) + + const answered = await hook().respondQuestion( + encodeAgentSessionQuestionAnswers([ + { questionId: 'q1', optionIds: ['q1:choice-1'] }, + { questionId: 'q2', optionIds: ['q2:choice-1'] } + ]), + RENDERED_GROUP + ) + + expect(answered).toBe(false) + expect(sent).toEqual([]) + }) + 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'] } ] + await act(async () => { + await hook().respondQuestion(encodeAgentSessionQuestionAnswers(answers), RENDERED_GROUP) + }) + expect(sentFields(sent)).toEqual({ itemId: 'item-g', expectedRevision: 1, @@ -400,12 +327,12 @@ describe('useMobileStructuredPromptResponses', () => { const onSendError = vi.fn() mount(prompt, mutate, supported, onSendError) + const answers: AgentSessionQuestionAnswer[] = [ + { questionId: 'q1', optionIds: ['q1:choice-1'] }, + { questionId: 'q2', optionIds: [], other: LONG_ANSWER } + ] 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)) + await hook().respondQuestion(encodeAgentSessionQuestionAnswers(answers), RENDERED_GROUP) }) if (!expectSent) { @@ -415,70 +342,7 @@ describe('useMobileStructuredPromptResponses', () => { ) 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: StructuredQuestionItem = { - ...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'] } - ] - }) + expect(sentFields(sent)).toEqual({ itemId: 'item-g', expectedRevision: 1, answers }) }) it('names the single question an option tap answers when the prompt has no typed field', async () => { diff --git a/mobile/src/session/use-mobile-structured-prompt-responses.ts b/mobile/src/session/use-mobile-structured-prompt-responses.ts index 01ad8b9f70d..bd9041791e8 100644 --- a/mobile/src/session/use-mobile-structured-prompt-responses.ts +++ b/mobile/src/session/use-mobile-structured-prompt-responses.ts @@ -1,8 +1,10 @@ -import { useCallback, useState } from 'react' +import { useCallback } 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, + decodeAgentSessionQuestionAnswers, + isValidAgentSessionQuestionAnswers, legacyAgentSessionSelectedOptionId, type AgentSessionQuestionAnswer } from '../../../src/shared/agent-session-question-answer' @@ -19,31 +21,19 @@ import type { StructuredAgentSessionMutate, StructuredAgentSessionMutationResult } from './mobile-structured-agent-session-rpc' -import { - advanceGroupedQuestion, - groupedQuestionPromptKey, - type GroupedQuestionDraft -} from './mobile-structured-grouped-question' +import type { MobileChatQuestion } from './native-chat/input/mobile-native-chat-question' export function useMobileStructuredPromptResponses(args: { stateRef: { readonly current: StructuredAgentSessionState } - sessionKey: string mutate: StructuredAgentSessionMutate // Older hosts require answers packed into optionId. questionAnswersSupported: boolean | null onSendError: (message: string) => void }): { - groupedDraft: GroupedQuestionDraft | null respondPermission: (optionId: string) => Promise - respondQuestion: (answer: string) => Promise + respondQuestion: (answer: string, rendered?: MobileChatQuestion['prompt']) => Promise } { - const { mutate, onSendError, questionAnswersSupported, sessionKey, stateRef } = args - // Tag grouped drafts with the session so a switch cannot expose stale answers before an effect. - const [collected, setCollected] = useState<{ - sessionKey: string - draft: GroupedQuestionDraft - } | null>(null) - const groupedDraft = collected?.sessionKey === sessionKey ? collected.draft : null + const { mutate, onSendError, questionAnswersSupported, stateRef } = args const respondPermission = useCallback( async (optionId: string): Promise => { @@ -101,39 +91,27 @@ export function useMobileStructuredPromptResponses(args: { ) const respondQuestion = useCallback( - async (answer: string): Promise => { + async (answer: string, rendered?: MobileChatQuestion['prompt']): Promise => { const prompt = actionableStructuredQuestion(stateRef.current) if (prompt?.body.questions) { - const promptKey = groupedQuestionPromptKey(prompt.itemId, prompt.revision) - const grouped = advanceGroupedQuestion({ - response: answer, - questions: prompt.body.questions, - draft: groupedDraft, - promptKey - }) - if (!grouped) { + // Claude reuses question ids across prompts, so answers must match the prompt they were picked on. + const answers = decodeAgentSessionQuestionAnswers(answer) + if ( + !answers || + rendered?.itemId !== prompt.itemId || + rendered.expectedRevision !== prompt.revision || + !isValidAgentSessionQuestionAnswers(prompt.body.questions, answers) + ) { return false } - if (grouped.kind === 'advance') { - setCollected({ sessionKey, draft: grouped.draft }) - return true - } const result = await sendAnswers( { itemId: prompt.itemId, expectedRevision: prompt.revision }, prompt.body, - grouped.answers + answers ) if (!result) { return false } - if (result.status !== 'rejected') { - // Once submitted, retries must restart at the first question. - setCollected((current) => - current?.sessionKey === sessionKey && current.draft.promptKey === promptKey - ? null - : current - ) - } if (result.status === 'unknown') { onSendError('Answer unconfirmed — check chat before retrying') return false @@ -158,8 +136,8 @@ export function useMobileStructuredPromptResponses(args: { } return result.status === 'accepted' }, - [groupedDraft, onSendError, sendAnswers, sessionKey, stateRef] + [onSendError, sendAnswers, stateRef] ) - return { groupedDraft, respondPermission, respondQuestion } + return { respondPermission, respondQuestion } } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index 8406131478a..e6112dd1bec 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -19,6 +19,7 @@ import { ensureUnsupportedTerminalPromptReceipt, observeReplayedTerminalPrompt } from './terminal-prompt-receipt' +import { observeQuestionAnswerSend } from './terminal-send-question-answered' export const TERMINAL_SEND_METHODS = [ defineMethod({ @@ -215,6 +216,7 @@ export const TERMINAL_SEND_METHODS = [ mobileFloorClaim.current = claim } : undefined + const answered = observeQuestionAnswerSend(params, runtime) let result let acceptedPromptCheckpoint: unknown try { @@ -301,7 +303,7 @@ export const TERMINAL_SEND_METHODS = [ ) } // Deliberate mobile input claims its driver floor; clientless sends use the current mobile driver. - return { send: result } + return { send: answered(result) } } }) ] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-question-answered.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-question-answered.ts new file mode 100644 index 00000000000..85665e8ecc2 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-question-answered.ts @@ -0,0 +1,58 @@ +import type { z } from 'zod' +import { agentHookServer } from '../../../../agent-hooks/server' +import type { OrcaRuntimeService } from '../../../orca-runtime' +import type { TerminalSend } from '../../../../../shared/rpc-contract/terminal-unary-params' +import { + isPotentialQuestionAnsweredSubmitInput, + isQuestionAnsweredSubmitInput, + type AgentQuestionAnsweredInferenceRequest +} from '../../../../../shared/agent-question-answered-intent' + +/** Snapshot before the write so a hook racing the answer wins over inference, as on desktop. */ +function captureQuestionAnsweredBaseline( + input: string, + getPaneKey: () => string | null +): AgentQuestionAnsweredInferenceRequest | null { + // Reject ordinary input before resolving the pane or parsing prompt JSON. + if (!isPotentialQuestionAnsweredSubmitInput(input)) { + return null + } + const paneKey = getPaneKey() + if (!paneKey) { + return null + } + const [row] = agentHookServer.getStatusSnapshotForPane(paneKey) + if (!row || !isQuestionAnsweredSubmitInput(input, row.interactivePrompt)) { + return null + } + return { + paneKey, + baselineUpdatedAt: row.receivedAt, + baselineStateStartedAt: row.stateStartedAt, + baselinePrompt: row.prompt, + baselineAgentType: row.agentType + } +} + +/** Claude sends no hook once a question is answered, so an accepted send of its submit key clears the wait. */ +export function observeQuestionAnswerSend( + params: z.infer, + runtime: Pick +): (result: T) => T { + // Desktop renderers infer answers from their own input; other writers rely on the host. + const baseline = + params.inputKind === 'query-reply' || + params.interrupt === true || + params.client?.type === 'desktop' + ? null + : captureQuestionAnsweredBaseline( + `${params.text ?? ''}${params.enter === true ? '\r' : ''}`, + () => runtime.getLiveTerminalPaneKey(params.terminal) + ) + return (result) => { + if (baseline && result.accepted === true) { + agentHookServer.inferQuestionAnswered(baseline) + } + return result + } +} diff --git a/src/main/runtime/rpc/terminal-send-question-answered.test.ts b/src/main/runtime/rpc/terminal-send-question-answered.test.ts new file mode 100644 index 00000000000..9e2d66d7c32 --- /dev/null +++ b/src/main/runtime/rpc/terminal-send-question-answered.test.ts @@ -0,0 +1,68 @@ +import './unused-default-rpc-methods.test-fixture' +import { afterEach, expect, it, vi } from 'vitest' +import { AgentHookServer, agentHookServer } from '../../agent-hooks/server' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' +import { stubRuntime } from './terminal-multiplex-test-harness' + +const PANE_KEY = makePaneKey('tab-question', '11111111-1111-4111-8111-111111111111') + +afterEach(() => { + vi.restoreAllMocks() +}) + +function claudeAsking(questions: unknown[]): AgentHookServer { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-question', + worktreeId: 'worktree-question', + hookEventName: 'PreToolUse', + toolUseId: 'tool-question', + payload: { + state: 'waiting', + agentType: 'claude', + toolName: 'AskUserQuestion', + interactivePrompt: JSON.stringify({ questions }) + } + }, + 'connection-1' + ) + vi.spyOn(agentHookServer, 'getStatusSnapshotForPane').mockImplementation((paneKey) => + server.getStatusSnapshotForPane(paneKey) + ) + vi.spyOn(agentHookServer, 'inferQuestionAnswered').mockImplementation((request) => + server.inferQuestionAnswered(request) + ) + return server +} + +async function phoneSends(text: string): Promise { + const runtime = stubRuntime({ + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + getLiveTerminalPaneKey: () => PANE_KEY, + sendTerminal: vi + .fn() + .mockResolvedValue({ handle: 'terminal-1', accepted: true, bytesWritten: 1 }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const response = await dispatcher.dispatch({ + id: 'req-1', + authToken: 'tok', + method: 'terminal.send', + params: { terminal: 'terminal-1', text, client: { id: 'phone', type: 'mobile' } } + }) + expect(response.ok).toBe(true) +} + +const option = (label: string) => ({ label, description: '' }) + +it('clears the Claude question wait when a phone keystroke finishes the answer', async () => { + const server = claudeAsking([{ question: 'Pick', options: [option('A'), option('B')] }]) + + await phoneSends('2') + + expect(server.getStatusSnapshotForPane(PANE_KEY)[0]?.state).not.toBe('waiting') +}) diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index af734c8f5ff..349a9fc7541 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -11,6 +11,13 @@ import { useNativeChatQuestionAutoAdvance } from './use-native-chat-question-aut import { useNativeChatQuestionKeys } from './use-native-chat-question-keys' import { isEditableTarget } from '@/lib/editable-target' import type { AgentJournalFreeTextInput } from '../../../../shared/agent-session-journal-types' +import { + askAcceptsEmptyAnswer, + askConfirmAction, + askConfirmLabel, + askTypedText, + toggleAskOption +} from '../../../../shared/native-chat-ask-card-rules' export type NativeChatQuestionCardProps = { prompt: AskPrompt @@ -34,6 +41,12 @@ export type NativeChatQuestionCardProps = { // Reserve a non-option index so single-select holds either an option or typed text. const TYPED_ANSWER = -1 +const CONFIRM_LABEL = { + submit: () => translate('components.native-chat.question.send', 'Submit'), + next: () => translate('components.native-chat.question.next', 'Next'), + skip: () => translate('components.native-chat.question.skip', 'Skip') +} as const + /** Single-select advances after a pick; multi-select confirms explicitly, and multi-question prompts retain earlier answers. */ export function NativeChatQuestionCard({ prompt, @@ -67,9 +80,9 @@ export function NativeChatQuestionCard({ freeTextInput?.placeholder ?? translate('components.native-chat.question.otherPlaceholder', 'Type your answer') const acceptsEmpty = (qi: number): boolean => - freeTextInputs?.[qi]?.allowEmpty === true && prompt.questions[qi]?.options.length === 0 + askAcceptsEmptyAnswer(prompt.questions[qi], freeTextInputs?.[qi]) const typedText = (qi: number, oth = otherText): string => - freeTextInputs?.[qi]?.allowEmpty ? (oth[qi] ?? '') : (oth[qi] ?? '').trim() + askTypedText(oth[qi] ?? '', freeTextInputs?.[qi]) // Picking an option deselects typed text without erasing it from the field. const typedAnswerChosen = (qi: number, sel = selections, oth = otherText): boolean => @@ -141,11 +154,7 @@ export function NativeChatQuestionCard({ const pickOption = (optionIndex: number): void => { const cur = selections[index] ?? [] const picked = !cur.includes(optionIndex) - const chosen = !picked - ? cur.filter((pickedIndex) => pickedIndex !== optionIndex) - : q.multiSelect - ? [...cur, optionIndex].sort((a, b) => a - b) - : [optionIndex] + const chosen = toggleAskOption(cur, optionIndex, q.multiSelect) const next = selections.map((s, i) => (i === index ? chosen : s)) setSelections(next) autoAdvance.cancel() @@ -155,17 +164,13 @@ export function NativeChatQuestionCard({ } useNativeChatQuestionKeys(cardRef, shouldFocus && !isSubmitting, q.options.length, pickOption) - // Only the final question submits; an empty final Skip dismisses, while empty Enter preserves the prompt. const confirm = (fromKeyboard = false): void => { autoAdvance.cancel() - if (!isLast) { - advanceOrSubmit(selections, otherText) - return - } const anyAnswered = prompt.questions.some((_, i) => answerFor(i).length > 0 || acceptsEmpty(i)) - if (anyAnswered) { - submitAll(selections, otherText) - } else if (!fromKeyboard) { + const action = askConfirmAction({ isLast, anyAnswered, fromKeyboard }) + if (action === 'proceed') { + advanceOrSubmit(selections, otherText) + } else if (action === 'cancel') { onCancel() } } @@ -353,11 +358,7 @@ export function NativeChatQuestionCard({ > {isSubmitting ? translate('components.native-chat.question.sending', 'Sending…') - : currentAnswered - ? isLast - ? translate('components.native-chat.question.send', 'Submit') - : translate('components.native-chat.question.next', 'Next') - : translate('components.native-chat.question.skip', 'Skip')} + : CONFIRM_LABEL[askConfirmLabel(currentAnswered, isLast)]()} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.tsx index 20643c73806..d3dec081866 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredQuestionCard.tsx @@ -1,6 +1,10 @@ import type { RefObject } from 'react' import type { AgentJournalQuestion } from '../../../../shared/agent-session-journal-types' import type { AgentSessionPromptResponse } from '../../../../shared/agent-session-question-answer' +import { + structuredQuestionAnswers, + structuredQuestionCard +} from '../../../../shared/structured-agent-question-card' import { NativeChatQuestionCard } from './NativeChatQuestionCard' export function NativeChatStructuredQuestionCard({ @@ -18,38 +22,16 @@ export function NativeChatStructuredQuestionCard({ shouldFocus?: boolean answerInputRef?: RefObject }): React.JSX.Element { + const card = structuredQuestionCard(questions) return ( ({ - question: question.question, - ...(question.header ? { header: question.header } : {}), - multiSelect: question.multiSelect, - options: question.options.map((option) => ({ - label: option.label, - ...(option.description ? { description: option.description } : {}) - })) - })) - }} - allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))} - freeTextInputs={questions.map((question) => question.freeTextInput)} - onAnswer={(answers) => { - const chosen = questions.map((question, index) => { - const answer = answers[index] - const other = question.freeTextInput?.allowEmpty ? answer?.other : answer?.other?.trim() - return { - questionId: question.id, - optionIds: (answer?.indices ?? []).flatMap((optionIndex) => { - const id = question.options[optionIndex]?.id - return id ? [id] : [] - }), - ...(other !== undefined && (other.length > 0 || question.freeTextInput?.allowEmpty) - ? { other } - : {}) - } - }) - if (chosen.every((answer) => answer.optionIds.length > 0 || answer.other !== undefined)) { - onAnswer({ kind: 'answers', answers: chosen }) + prompt={card.prompt} + allowOther={card.allowOther} + freeTextInputs={card.freeTextInputs} + onAnswer={(selections) => { + const answers = structuredQuestionAnswers(questions, selections) + if (answers) { + onAnswer({ kind: 'answers', answers }) } }} onCancel={onCancel} diff --git a/src/shared/native-chat-ask-card-rules.ts b/src/shared/native-chat-ask-card-rules.ts new file mode 100644 index 00000000000..aa0e3e134e0 --- /dev/null +++ b/src/shared/native-chat-ask-card-rules.ts @@ -0,0 +1,51 @@ +import type { AgentJournalFreeTextInput } from './agent-session-journal-types' +import type { AskQuestion } from './native-chat-ask-types' + +// How a stepped question card picks, labels and confirms; desktop and mobile draw it differently. + +/** Multi-select keeps picks in option order so the answer reads as the options do. */ +export function toggleAskOption( + picked: readonly number[], + optionIndex: number, + multiSelect: boolean +): number[] { + if (picked.includes(optionIndex)) { + return picked.filter((index) => index !== optionIndex) + } + return multiSelect ? [...picked, optionIndex].sort((a, b) => a - b) : [optionIndex] +} + +/** Free text is kept verbatim only where the provider accepts an empty or spaced answer. */ +export function askTypedText(raw: string, input: AgentJournalFreeTextInput | undefined): string { + return input?.allowEmpty ? raw : raw.trim() +} + +/** A typing-only question whose provider takes an empty answer counts as answered. */ +export function askAcceptsEmptyAnswer( + question: AskQuestion | undefined, + input: AgentJournalFreeTextInput | undefined +): boolean { + return input?.allowEmpty === true && question?.options.length === 0 +} + +/** Proceed advances, or submits on the last question; an empty final Skip dismisses, while empty Enter keeps the prompt. */ +export function askConfirmAction(args: { + isLast: boolean + anyAnswered: boolean + fromKeyboard?: boolean +}): 'proceed' | 'cancel' | 'stay' { + if (!args.isLast || args.anyAnswered) { + return 'proceed' + } + return args.fromKeyboard ? 'stay' : 'cancel' +} + +export function askConfirmLabel( + currentAnswered: boolean, + isLast: boolean +): 'submit' | 'next' | 'skip' { + if (!currentAnswered) { + return 'skip' + } + return isLast ? 'submit' : 'next' +} diff --git a/src/shared/structured-agent-question-card.ts b/src/shared/structured-agent-question-card.ts new file mode 100644 index 00000000000..ceb9fd8e6ff --- /dev/null +++ b/src/shared/structured-agent-question-card.ts @@ -0,0 +1,59 @@ +import type { AgentJournalFreeTextInput, AgentJournalQuestion } from './agent-session-journal-types' +import type { AgentSessionQuestionAnswer } from './agent-session-question-answer' +import type { AskAnswerSelection } from './native-chat-ask-answer' +import type { AskPrompt } from './native-chat-ask-types' +import { askTypedText } from './native-chat-ask-card-rules' + +/** A structured prompt drawn with the same stepped card as a terminal ask. */ +type StructuredQuestionCard = { + prompt: AskPrompt + allowOther: boolean[] + freeTextInputs: (AgentJournalFreeTextInput | undefined)[] +} + +export function structuredQuestionCard( + questions: readonly AgentJournalQuestion[] +): StructuredQuestionCard { + return { + prompt: { + questions: questions.map((question) => ({ + question: question.question, + ...(question.header ? { header: question.header } : {}), + multiSelect: question.multiSelect, + options: question.options.map((option) => ({ + label: option.label, + ...(option.description ? { description: option.description } : {}) + })) + })) + }, + allowOther: questions.map((question) => Boolean(question.freeTextQuestionId)), + freeTextInputs: questions.map((question) => question.freeTextInput) + } +} + +/** Null until every question has an answer, because hosts refuse a partial set. */ +export function structuredQuestionAnswers( + questions: readonly AgentJournalQuestion[], + selections: readonly AskAnswerSelection[] +): AgentSessionQuestionAnswer[] | null { + const answers = questions.map((question, index) => { + const selection = selections[index] + const other = + selection?.other === undefined + ? undefined + : askTypedText(selection.other, question.freeTextInput) + return { + questionId: question.id, + optionIds: (selection?.indices ?? []).flatMap((optionIndex) => { + const id = question.options[optionIndex]?.id + return id ? [id] : [] + }), + ...(other !== undefined && (other.length > 0 || question.freeTextInput?.allowEmpty) + ? { other } + : {}) + } + }) + return answers.every((answer) => answer.optionIds.length > 0 || answer.other !== undefined) + ? answers + : null +}