fix(native-chat): model Grok question card keys

This commit is contained in:
Brennan Benson
2026-08-27 20:00:30 -07:00
parent 1d87ce244f
commit 4ae1e0ae52
9 changed files with 195 additions and 18 deletions
@@ -5,6 +5,7 @@ import {
formatAskAnswer,
parseAskFromStatus
} from '../../../src/shared/native-chat-ask'
import { buildGrokAskAnswerKeys } from '../../../src/shared/native-chat-grok-ask-answer'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage {
@@ -236,3 +237,60 @@ describe('buildAskAnswerKeys', () => {
])
})
})
describe('buildGrokAskAnswerKeys', () => {
it('selects and submits a single-question single-select answer', () => {
const prompt = {
questions: [
{ question: 'q', multiSelect: false, options: [{ label: 'Tabs' }, { label: 'Spaces' }] }
]
}
expect(buildGrokAskAnswerKeys(prompt, [{ indices: [1] }])).toEqual([
{ raw: '2' },
{ raw: '\r' }
])
})
it('moves between questions with Grok navigation before submitting', () => {
const prompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
expect(buildGrokAskAnswerKeys(prompt, [{ indices: [1] }, { indices: [0] }])).toEqual([
{ raw: '2' },
{ raw: 'l' },
{ raw: '1' },
{ raw: '\r' }
])
})
it('picks every Grok multi-select option before submitting', () => {
const prompt = {
questions: [
{
question: 'q',
multiSelect: true,
options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }]
}
]
}
expect(buildGrokAskAnswerKeys(prompt, [{ indices: [0, 2] }])).toEqual([
{ raw: '1' },
{ raw: '3' },
{ raw: '\r' }
])
})
it('opens Grok free text with z before typing and submitting', () => {
const prompt = {
questions: [{ question: 'q', multiSelect: false, options: [{ label: 'A' }] }]
}
expect(buildGrokAskAnswerKeys(prompt, [{ indices: [], other: 'custom' }])).toEqual([
{ raw: 'z' },
{ text: 'custom' },
{ raw: '\r' }
])
})
})
@@ -256,13 +256,21 @@ describe('useMobileNativeChatAnswerSend', () => {
])
})
it('answers Grok selector prompts with the option number', async () => {
it('answers Grok selector prompts with its pick and submit keys', 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)
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
let result: Promise<boolean> | undefined
await act(async () => {
result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
})
await act(async () => vi.runAllTimersAsync())
await expect(result).resolves.toBe(true)
expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([
expect.objectContaining({ text: '2', enter: false }),
expect.objectContaining({ text: '\r', enter: false })
])
})
it('keeps the marker for a Grok selector answer', async () => {
@@ -270,9 +278,17 @@ describe('useMobileNativeChatAnswerSend', () => {
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'grok')
markMobileNativeChatInputStale('terminal')
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
let result: Promise<boolean> | undefined
await act(async () => {
result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
})
await act(async () => vi.runAllTimersAsync())
await expect(result).resolves.toBe(true)
expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([
expect.objectContaining({ text: '2', enter: false }),
expect.objectContaining({ text: '\r', enter: false })
])
expect(isMobileNativeChatInputStale('terminal')).toBe(true)
})
@@ -731,7 +747,7 @@ describe('useMobileNativeChatAnswerSend', () => {
settle.push(resolve)
})
)
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok')
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'omp')
let first: Promise<boolean> | undefined
let second: Promise<boolean> | undefined
@@ -739,6 +755,7 @@ describe('useMobileNativeChatAnswerSend', () => {
first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }])
await Promise.resolve()
})
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Tabs', enter: true })
await act(async () => {
second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
@@ -797,13 +814,14 @@ describe('useMobileNativeChatAnswerSend', () => {
settle.push(resolve)
})
)
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok')
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'omp')
let first: Promise<boolean> | undefined
await act(async () => {
first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true })
await act(async () => {
answerSend?.cancelPending()
settle[0]!(acceptedResponse())
@@ -7,6 +7,7 @@ import {
type AskAnswerSelection,
type AskPrompt
} from '../../../src/shared/native-chat-ask'
import { buildGrokAskAnswerKeys } from '../../../src/shared/native-chat-grok-ask-answer'
import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
import {
@@ -258,10 +259,13 @@ export function useMobileNativeChatAnswerSend(args: {
const sent = (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
return sent && writeTurnsRef.current.get(handle) === turn
}
const transcriptAgent = resolveNativeChatTranscriptAgent(agentRef.current)
const groups =
resolveNativeChatTranscriptAgent(agentRef.current) === 'codex'
transcriptAgent === 'codex'
? buildCodexAskAnswerKeys(prompt, selections)
: buildAskAnswerKeys(prompt, selections)
: transcriptAgent === 'grok'
? buildGrokAskAnswerKeys(prompt, selections)
: buildAskAnswerKeys(prompt, selections)
for (let index = 0; index < groups.length; index += 1) {
if (generationRef.current !== generation) {
return false
@@ -13,10 +13,12 @@ import {
type AskQuestion,
type InteractiveQuestionParser
} from '../../../../shared/native-chat-ask'
import { buildGrokAskAnswerKeys } from '../../../../shared/native-chat-grok-ask-answer'
export {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
buildGrokAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
parseAskFromStatus,
@@ -66,12 +66,12 @@ describe('useNativeChatInteractiveSend', () => {
it('routes a non-selector answer through the pasted-text send path', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'grok')
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'omp')
)
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
// Grok commits a pasted answer: label text 'B', not option-number keystrokes.
// OMP commits a pasted answer: label text 'B', not option-number keystrokes.
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith(
{ terminalTabId: 'tab-1' },
'pty-1',
@@ -80,6 +80,22 @@ describe('useNativeChatInteractiveSend', () => {
expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled()
})
it('routes a Grok answer through its question-card keystrokes', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'grok')
)
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith(
{ terminalTabId: 'tab-1' },
'pty-1',
[{ raw: '2' }, { raw: '\r' }],
expect.any(Function)
)
expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled()
})
it('routes a Codex answer through the option-number keystroke path', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex')
@@ -10,6 +10,7 @@ import {
import {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
buildGrokAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
type AskAnswerSelection,
@@ -96,7 +97,7 @@ export function useNativeChatInteractiveSend(
// 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'
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
// Why: pin the answered question's baseline BEFORE delivery. A late settle
// callback (paced writes + remote acceptance can span seconds on SSH) must
// not read the live status and mint a fresh baseline for a replacement
@@ -132,9 +133,11 @@ export function useNativeChatInteractiveSend(
? sendNativeChatAskAnswer(
settings,
targetPtyId,
buildsCodexAnswer
transcriptAgent === 'codex'
? buildCodexAskAnswerKeys(prompt, selections)
: buildAskAnswerKeys(prompt, selections),
: transcriptAgent === 'grok'
? buildGrokAskAnswerKeys(prompt, selections)
: buildAskAnswerKeys(prompt, selections),
onSettled
)
: sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections))
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { isQuestionAnsweredSubmitInput } from '../../../../shared/agent-question-answered-intent'
import { createAgentQuestionAnsweredInference } from './agent-question-answered-inference'
const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
@@ -99,6 +100,19 @@ describe('agent question-answered inference', () => {
}
})
it('keeps waiting when a Grok multi_select digit only picks a partial answer', () => {
const interactivePrompt = JSON.stringify({
questions: [
{
question: 'pick several',
multi_select: true,
options: [{ label: 'A' }, { label: 'B' }]
}
]
})
expect(isQuestionAnsweredSubmitInput('1', interactivePrompt)).toBe(false)
})
it('keeps waiting when the synthetic free-text row is selected', () => {
const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry())
+11 -2
View File
@@ -43,8 +43,17 @@ function readSingleSelectOptionCount(interactivePrompt: string | undefined): num
if (!Array.isArray(parsed.questions) || parsed.questions.length !== 1) {
return -1
}
const [question] = parsed.questions as { multiSelect?: unknown; options?: unknown }[]
if (!question || question.multiSelect === true || !Array.isArray(question.options)) {
const [question] = parsed.questions as {
multiSelect?: unknown
multi_select?: unknown
options?: unknown
}[]
if (
!question ||
question.multiSelect === true ||
question.multi_select === true ||
!Array.isArray(question.options)
) {
return -1
}
return question.options.length
+53
View File
@@ -0,0 +1,53 @@
import type { AskAnswerKeyGroup, AskAnswerSelection, AskPrompt } from './native-chat-ask'
const GROK_ENTER = '\r'
const GROK_NEXT_QUESTION = 'l'
const GROK_FREE_TEXT = 'z'
function optionKey(index: number): string | null {
if (index >= 0 && index < 9) {
return String(index + 1)
}
if (index >= 9 && index < 15) {
return String.fromCharCode('a'.charCodeAt(0) + index - 9)
}
return null
}
/** Grok picks options by shortcut, moves questions with `l`, and opens free text with `z`. */
export function buildGrokAskAnswerKeys(
prompt: AskPrompt,
selections: AskAnswerSelection[]
): AskAnswerKeyGroup[] {
const groups: AskAnswerKeyGroup[] = []
prompt.questions.forEach((question, questionIndex) => {
const selection = selections[questionIndex]
const other = (selection?.other ?? '').trim()
const selectedIndices = question.multiSelect
? (selection?.indices ?? [])
: (selection?.indices.slice(0, 1) ?? [])
if (!other || question.multiSelect) {
for (const index of selectedIndices) {
const key = optionKey(index)
if (key) {
groups.push({ raw: key })
}
}
}
if (other) {
const freeText = question.multiSelect
? other
: [question.options[selectedIndices[0] ?? -1]?.label, other].filter(Boolean).join(', ')
groups.push({ raw: GROK_FREE_TEXT }, { text: freeText }, { raw: GROK_ENTER })
} else if (questionIndex < prompt.questions.length - 1) {
groups.push({ raw: GROK_NEXT_QUESTION })
} else if (selectedIndices.length > 0) {
groups.push({ raw: GROK_ENTER })
}
})
return groups
}