fix(native-chat): step Grok question answers

This commit is contained in:
Brennan Benson
2026-08-27 20:00:30 -07:00
parent 77fd46b441
commit 1d87ce244f
7 changed files with 97 additions and 44 deletions
@@ -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)
})
})
@@ -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
// "<image path>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)
@@ -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).
@@ -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
+3 -2
View File
@@ -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)
+5 -4
View File
@@ -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(
+2 -5
View File
@@ -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
}