fix(native-chat): preserve codex question answer semantics

This commit is contained in:
Brennan Benson
2026-07-21 22:44:43 -07:00
parent dcb037f3b6
commit c822e7e3e5
7 changed files with 174 additions and 21 deletions
@@ -195,6 +195,29 @@ describe('useMobileNativeChatAnswerSend', () => {
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
})
it('does not send a trailing Enter after Codex submits a multi-question answer', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex')
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
let result: Promise<boolean> | undefined
await act(async () => {
result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }])
})
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: '1', enter: false })
])
})
it('submits a non-selector answer as pasted label text with a single Enter', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'grok')
@@ -3,16 +3,20 @@ import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
import {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
type AskAnswerSelection,
type AskPrompt
} from './mobile-native-chat-ask'
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
import { shouldStepNativeChatAskAnswer } from '../../../src/shared/native-chat-agent-support'
import {
resolveNativeChatTranscriptAgent,
shouldStepNativeChatAskAnswer
} from '../../../src/shared/native-chat-agent-support'
/** Sends an AskUserQuestion answer to the active chat pane. Claude's selector is
* answered by option-number keystrokes; other agents get pasted label text.
/** Sends an ask-user answer to the active chat pane. Claude and Codex selectors
* use their agent-specific keystrokes; other agents get pasted label text.
* Extracted from the session route to keep that file under its line cap and to
* own the pending-timer lifecycle in one place. */
export type MobileNativeChatAnswerSend = {
@@ -32,8 +36,8 @@ function sanitizeAskFreeText(text: string): string {
/**
* Owns the ask-answer send sequence for the mobile native chat. Reads the live
* pane/agent through refs (the route already keeps them current) so the returned
* callbacks stay stable. Claude answers are delivered as `buildAskAnswerKeys`
* keystroke groups written one selector-step apart over the EXISTING
* callbacks stay stable. Selector answers are delivered as keystroke groups
* written one step apart over the EXISTING
* `terminal.send` passthrough (raw text, no enter) — same contract the
* permission card already uses, so old runtimes replay them verbatim (no new
* RPC; keystrokes are built client-side). The scheduled wait chain is cancelled
@@ -136,14 +140,15 @@ export function useMobileNativeChatAnswerSend(args: {
}
return false
}
// Non-Claude question tools commit a pasted answer, so send the label text
// with one Enter. Claude's arrow-navigate selector ignores pasted labels
// (STA-1860): drive it by option-number keystrokes instead, one group per
// selector step so each renders before the next lands.
// Grok commits pasted labels; Claude and Codex need their selector-specific
// keystrokes paced so each step renders before the next lands.
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
}
const groups = buildAskAnswerKeys(prompt, selections)
const groups =
resolveNativeChatTranscriptAgent(agentRef.current) === 'codex'
? buildCodexAskAnswerKeys(prompt, selections)
: buildAskAnswerKeys(prompt, selections)
for (let index = 0; index < groups.length; index += 1) {
if (generationRef.current !== generation) {
return false
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
parseApprovalFromStatus,
@@ -240,6 +241,52 @@ describe('buildAskAnswerKeys', () => {
})
})
describe('buildCodexAskAnswerKeys', () => {
it("submits the final multi-question option without Claude's extra Enter", () => {
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
expect(buildCodexAskAnswerKeys(prompt, [{ indices: [1] }, { indices: [0] }])).toEqual([
{ raw: '2' },
{ raw: '1' }
])
})
it('adds free text as notes before committing the selected row', () => {
expect(
buildCodexAskAnswerKeys(single(['Tabs', 'Spaces']), [
{ indices: [1], other: 'Keep existing files' }
])
).toEqual([{ raw: '\x1b[B' }, { raw: '\t' }, { text: 'Keep existing files' }, { raw: '\r' }])
})
it("targets Codex's synthetic None-of-the-above row for a custom answer", () => {
expect(
buildCodexAskAnswerKeys(single(['Tabs', 'Spaces']), [{ indices: [], other: 'Four spaces' }])
).toEqual([{ raw: '\x1b[A' }, { raw: '\t' }, { text: 'Four spaces' }, { raw: '\r' }])
})
it('clears skipped rows and confirms the partial answer once', () => {
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
expect(buildCodexAskAnswerKeys(prompt, [{ indices: [] }, { indices: [1] }])).toEqual([
{ raw: '\x7f' },
{ raw: '\x1b[C' },
{ raw: '2' },
{ raw: '\r' }
])
})
})
describe('hasAskAnswer', () => {
it('is true for a picked option or typed text, false when empty', () => {
expect(hasAskAnswer(single(['A', 'B']), [{ indices: [1] }])).toBe(true)
@@ -1,6 +1,7 @@
import { translate } from '@/i18n/i18n'
import {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
parseAskFromStatus,
@@ -15,6 +16,7 @@ import {
export {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
parseAskFromStatus,
@@ -98,6 +98,22 @@ describe('useNativeChatInteractiveSend', () => {
expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled()
})
it('does not send a trailing Enter after Codex submits a multi-question answer', () => {
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex')
)
act(() => result.current.sendAnswer(prompt, [{ indices: [1] }, { indices: [0] }]))
expect(mocks.sendNativeChatAskAnswer.mock.calls[0]?.[2]).toEqual([{ raw: '2' }, { raw: '1' }])
})
it('routes a Claude answer through the option-number keystroke path', () => {
const { result } = renderHook(() =>
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude')
@@ -3,9 +3,13 @@ import { useAppStore } from '../../store'
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import type { AgentType } from '../../../../shared/native-chat-types'
import { shouldStepNativeChatAskAnswer } from '../../../../shared/native-chat-agent-support'
import {
resolveNativeChatTranscriptAgent,
shouldStepNativeChatAskAnswer
} from '../../../../shared/native-chat-agent-support'
import {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
type AskAnswerSelection,
@@ -42,9 +46,8 @@ export type NativeChatInteractiveSend = {
* Reuse the desktop composer's exact send path for the interactive cards:
* resolve this tab's live ptyId + runtime owner settings, then write bytes via
* `sendRuntimePtyInput` (which branches local pty:write vs remote runtime RPC,
* so SSH panes work unchanged). Claude's AskUserQuestion answers are delivered
* as selector keystrokes (by option number, `sendNativeChatAskAnswer`); other
* agents' question tools commit a pasted answer, so those still go through
* so SSH panes work unchanged). Claude and Codex answers use their respective
* selector keystrokes via `sendNativeChatAskAnswer`; other agents still go through
* `sendNativeChatMessage`. Control strings (option digits, ESC) are written raw.
*/
export function useNativeChatInteractiveSend(
@@ -90,13 +93,10 @@ export function useNativeChatInteractiveSend(
// Cancel any prior in-flight answer before starting a new one.
cancelInFlight()
const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId)
// Claude's AskUserQuestion is an arrow-navigate selector: it commits by the
// highlighted option, not a pasted label, so answer it with per-option
// keystrokes (by option number), paced so each step renders before the next.
// Other agents' question tools commit a pasted answer, so send label text.
// Gate on the transcript agent (not `=== 'claude'`) so OpenClaude — which
// runs the same selector — takes the keystroke path too.
// Claude and Codex ignore pasted labels but have different selector state
// machines; Grok commits pasted text. 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
// 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,7 +132,9 @@ export function useNativeChatInteractiveSend(
? sendNativeChatAskAnswer(
settings,
targetPtyId,
buildAskAnswerKeys(prompt, selections),
buildsCodexAnswer
? buildCodexAskAnswerKeys(prompt, selections)
: buildAskAnswerKeys(prompt, selections),
onSettled
)
: sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections))
+58
View File
@@ -160,6 +160,9 @@ export function formatAskAnswer(prompt: AskPrompt, selections: AskAnswerSelectio
// has applied it.
const ASK_ENTER = '\r'
const ASK_NEXT_TAB = '\x1b[C'
const ASK_PREVIOUS_ROW = '\x1b[A'
const ASK_NEXT_ROW = '\x1b[B'
const ASK_NOTES = '\t'
/** Build the ordered keystroke groups that answer a Claude Code AskUserQuestion.
* Each group is written a step apart so the selector applies it before the next.
@@ -221,6 +224,61 @@ export function buildAskAnswerKeys(
return groups
}
/** Build keystrokes for Codex's request_user_input overlay.
*
* Unlike Claude, Codex submits on the final option digit and attaches free text
* as notes to the highlighted row. The overlay starts on the first row, so note
* answers move to the target without committing, open notes with Tab, then
* submit with Enter. */
export function buildCodexAskAnswerKeys(
prompt: AskPrompt,
selections: AskAnswerSelection[]
): AskAnswerKeyGroup[] {
const groups: AskAnswerKeyGroup[] = []
let hasUnanswered = false
prompt.questions.forEach((question, questionIndex) => {
const selection = selections[questionIndex]
const selectedIndex = selection?.indices[0]
const note = (selection?.other ?? '').trim()
if (note) {
const targetIndex = selectedIndex ?? question.options.length
const rowCount = question.options.length + 1
const nextSteps = targetIndex
const previousSteps = rowCount - targetIndex
const usePrevious = previousSteps < nextSteps
const navigationKey = usePrevious ? ASK_PREVIOUS_ROW : ASK_NEXT_ROW
const navigationSteps = usePrevious ? previousSteps : nextSteps
for (let index = 0; index < navigationSteps; index += 1) {
groups.push({ raw: navigationKey })
}
groups.push({ raw: ASK_NOTES }, { text: note }, { raw: ASK_ENTER })
return
}
if (selectedIndex !== undefined) {
groups.push({ raw: String(selectedIndex + 1) })
return
}
hasUnanswered = true
groups.push({ raw: '\x7f' })
if (questionIndex < prompt.questions.length - 1) {
groups.push({ raw: ASK_NEXT_TAB })
} else {
groups.push({ raw: ASK_ENTER })
}
})
// Codex opens a confirmation after the last question when any were skipped;
// Proceed is highlighted by default, so one Enter submits the partial answer.
if (hasUnanswered) {
groups.push({ raw: ASK_ENTER })
}
return groups
}
/** Whether any question in `selections` carries an answer worth submitting. */
export function hasAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): boolean {
return prompt.questions.some((_, i) => isAnswered(selections[i]))