diff --git a/mobile/src/session/mobile-native-chat-ask.test.ts b/mobile/src/session/mobile-native-chat-ask.test.ts index 0127f55d458..54712e32c20 100644 --- a/mobile/src/session/mobile-native-chat-ask.test.ts +++ b/mobile/src/session/mobile-native-chat-ask.test.ts @@ -104,23 +104,82 @@ describe('parseAskFromStatus', () => { expect(parseAskFromStatus('{"foo":1}')).toBeNull() }) - it('parses Grok question notifications whose payload is the questions array', () => { - const grokQuestions = [ - { - question: 'Ship to which region?', - options: [ - { label: 'us-east', description: 'US East' }, - { label: 'eu-west', description: 'EU West' } - ], - multi_select: false - } - ] - const ask = parseAskFromStatus(JSON.stringify(grokQuestions), 'ask_user_question') - expect(ask?.questions[0]).toMatchObject({ - question: 'Ship to which region?', - options: [{ label: 'us-east' }, { label: 'eu-west' }], - multiSelect: false - }) + it('parses the captured Grok ask_user_question envelope and options', () => { + // Captured from ~/.grok/sessions/.../chat_history.jsonl. Keep the + // {questions:[...]} envelope: Grok does not send a bare array here. + const grokInput = { + questions: [ + { + question: 'How should we proceed with the 0.0.41 mobile release?', + options: [ + { + label: 'Both platforms (Recommended)', + description: + 'iOS: App Store Connect with existing TestFlight 0.0.41; Android: trigger release workflow' + }, + { + label: 'iOS only', + description: 'Prepare App Store Connect copy; no Android CI.' + }, + { + label: 'Android only', + description: 'Tag/dispatch Mobile Android Release with release_version 0.0.41' + }, + { + label: 'Status only for now', + description: 'Stop here; I’ll handle ASC/Android myself with the drafts above' + } + ], + multi_select: false + }, + { + question: 'How should we trigger the Android release (if shipping Android)?', + options: [ + { + label: 'Tag push mobile-android-v0.0.41 (Recommended)', + description: 'git tag on origin/main and push' + }, + { + label: 'workflow_dispatch', + description: 'Dispatch Mobile Android Release with release_version 0.0.41' + }, + { label: 'N/A — not shipping Android', description: 'Skip Android trigger' } + ], + multi_select: false + } + ] + } + const ask = parseAskFromStatus(JSON.stringify(grokInput), 'ask_user_question') + expect(ask?.questions).toHaveLength(2) + expect( + ask?.questions.flatMap((question) => question.options.map((option) => option.label)) + ).toEqual([ + 'Both platforms (Recommended)', + 'iOS only', + 'Android only', + 'Status only for now', + 'Tag push mobile-android-v0.0.41 (Recommended)', + 'workflow_dispatch', + 'N/A — not shipping Android' + ]) + expect(ask?.questions.every((question) => question.multiSelect)).toBe(false) + }) + + it('maps Grok multi_select to the selector flag', () => { + const capturedShapeWithMultiSelect = { + questions: [ + { + question: 'Pick release targets', + options: [{ label: 'iOS' }, { label: 'Android' }], + multi_select: true + } + ] + } + const ask = parseAskFromStatus( + JSON.stringify(capturedShapeWithMultiSelect), + 'ask_user_question' + ) + expect(ask?.questions[0]?.multiSelect).toBe(true) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index 82db799deed..927a0a722ee 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -256,29 +256,24 @@ describe('useMobileNativeChatAnswerSend', () => { ]) }) - it('submits a non-selector answer as pasted label text with a single Enter', async () => { + it('answers Grok selector prompts with the option number', async () => { const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'grok') await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) - // Grok's question tool commits the pasted answer: label text + one Enter. expect(sendRequest).toHaveBeenCalledTimes(1) - expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true }) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) }) - it('clears an orphaned image paste before an answer that commits with Enter (#10228)', async () => { + it('keeps the marker for a Grok selector answer', async () => { const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'grok') - // An earlier image send left its path on this terminal's composer line. markMobileNativeChatInputStale('terminal') await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) - // Without the leading clear, the pasted label + Enter would submit - // "Spaces" as one prompt. - expect(sendRequest).toHaveBeenCalledTimes(2) - expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '\x15', enter: false }) - expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: 'Spaces', enter: true }) - expect(isMobileNativeChatInputStale('terminal')).toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + expect(isMobileNativeChatInputStale('terminal')).toBe(true) }) it('keeps the marker for a selector answer, which cannot submit the composer', async () => { @@ -303,7 +298,7 @@ describe('useMobileNativeChatAnswerSend', () => { result: { send: { accepted: false } }, _meta: { runtimeId: 'runtime' } }) - await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok') + await mount({ sendRequest } as unknown as RpcClient, onSendError, 'omp') markMobileNativeChatInputStale('terminal') await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index 40925931d3c..1792b1ac930 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -223,8 +223,8 @@ export function useMobileNativeChatAnswerSend(args: { } return false } - // Grok commits pasted labels; Claude and Codex need their selector-specific - // keystrokes paced so each step renders before the next lands. + // Claude, Codex, and Grok need selector-specific keystrokes paced so each + // step renders before the next lands; other agents commit pasted labels. if (!shouldStepNativeChatAskAnswer(agentRef.current)) { // This shape pastes the label into the composer and commits it, so an // orphaned image paste would be submitted along with the answer (#10228). diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index 43adabae8f3..cec3be66c8a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -93,8 +93,8 @@ export function useNativeChatInteractiveSend( // Cancel any prior in-flight answer before starting a new one. cancelInFlight() const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId) - // Claude and Codex ignore pasted labels but have different selector state - // machines; Grok commits pasted text. OpenClaude follows Claude's path. + // Claude, Codex, and Grok ignore pasted labels but have different selector + // state machines. OpenClaude follows Claude's path. const stepsAnswer = shouldStepNativeChatAskAnswer(agent) const buildsCodexAnswer = resolveNativeChatTranscriptAgent(agent) === 'codex' // Why: pin the answered question's baseline BEFORE delivery. A late settle diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts index 7608f50c98c..43910480378 100644 --- a/src/shared/native-chat-agent-support.test.ts +++ b/src/shared/native-chat-agent-support.test.ts @@ -49,16 +49,17 @@ describe('nativeChatRequiresLocalTranscript', () => { }) describe('shouldStepNativeChatAskAnswer', () => { - it('steps the digit-commit selector agents (Claude, OpenClaude, Codex)', () => { + it('steps the digit-commit selector agents (Claude, OpenClaude, Codex, Grok)', () => { expect(shouldStepNativeChatAskAnswer('claude')).toBe(true) expect(shouldStepNativeChatAskAnswer('openclaude')).toBe(true) // Codex 0.145's request_user_input card ignores typed labels and commits on // the highlighted row, so pasted answers misdeliver like STA-1860. expect(shouldStepNativeChatAskAnswer('codex')).toBe(true) + // Grok's ask_user_question card also commits option digits directly. + expect(shouldStepNativeChatAskAnswer('grok')).toBe(true) }) it('does not step other or unknown agents', () => { - expect(shouldStepNativeChatAskAnswer('grok')).toBe(false) expect(shouldStepNativeChatAskAnswer('omp')).toBe(false) expect(shouldStepNativeChatAskAnswer('cursor')).toBe(false) expect(shouldStepNativeChatAskAnswer(null)).toBe(false) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index b5bd27c0016..a550ffe5da7 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -31,12 +31,13 @@ export function nativeChatRequiresLocalTranscript(agent: string | null | undefin /** True when the agent renders a digit-commit question selector that ignores * typed label text (pasting "Blue" + Enter commits the highlighted FIRST - * option — STA-1860): Claude's AskUserQuestion and Codex 0.145's - * request_user_input card both behave this way, so answers must be delivered - * as per-option keystrokes. Other agents commit a pasted answer. */ + * option — STA-1860): Claude's AskUserQuestion, Codex 0.145's + * request_user_input, and Grok's ask_user_question cards behave this way, + * so answers must be delivered as per-option keystrokes. Other agents commit + * a pasted answer. */ export function shouldStepNativeChatAskAnswer(agent: string | null | undefined): boolean { const transcriptAgent = resolveNativeChatTranscriptAgent(agent) - return transcriptAgent === 'claude' || transcriptAgent === 'codex' + return transcriptAgent === 'claude' || transcriptAgent === 'codex' || transcriptAgent === 'grok' } export function resolveNativeChatTranscriptAgent( diff --git a/src/shared/native-chat-ask.ts b/src/shared/native-chat-ask.ts index 9e116527609..8f0bf0a98ff 100644 --- a/src/shared/native-chat-ask.ts +++ b/src/shared/native-chat-ask.ts @@ -20,11 +20,8 @@ export function registerQuestionTool(toolName: string, parser: InteractiveQuesti } function parseQuestionsShape(input: unknown): AskPrompt | null { - const rawQuestions = Array.isArray(input) - ? input - : input && typeof input === 'object' - ? (input as { questions?: unknown }).questions - : undefined + const rawQuestions = + input && typeof input === 'object' ? (input as { questions?: unknown }).questions : undefined if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) { return null }