fix(agent-status): map codex request_user_input questions to Needs You (#9861)
* fix(agent-status): map codex request_user_input questions to waiting Codex 0.145 asks user questions via the auto-allowed request_user_input tool (experimental default_mode_request_user_input): PreToolUse fires while blocked on the answer with no Stop, so Orca showed the pane as working/idle instead of Needs You. Map that PreToolUse to waiting (mirrors grok's ask_user_question), exempt question waits from the codex yolo auto-approval suppressor, and deliver native-chat answers to the digit-commit selector by option number (typed labels are ignored and Enter commits the highlighted first option). Older codex versions emit no such event and are unchanged. * fix(native-chat): preserve codex question answer semantics
This commit is contained in:
parent
4c2bb508c3
commit
0121f571e4
|
|
@ -185,12 +185,45 @@ describe('useMobileNativeChatAnswerSend', () => {
|
|||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
|
||||
})
|
||||
|
||||
it('submits a non-Claude answer as pasted label text with a single Enter', async () => {
|
||||
it('submits a Codex answer by option-number keystroke like Claude', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex')
|
||||
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
|
||||
// Codex's question tool commits the pasted answer: label text + one Enter.
|
||||
// Codex's request_user_input card ignores pasted labels; the digit selects AND commits.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
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')
|
||||
|
||||
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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -64,14 +64,14 @@ describe('useNativeChatInteractiveSend', () => {
|
|||
mocks.sendNativeChatMessage.mockReturnValue(handle)
|
||||
})
|
||||
|
||||
it('routes a non-Claude answer through the pasted-text send path', () => {
|
||||
it('routes a non-selector answer through the pasted-text send path', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex')
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'grok')
|
||||
)
|
||||
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
|
||||
// Codex commits a pasted answer: label text 'B', not option-number keystrokes.
|
||||
// Grok commits a pasted answer: label text 'B', not option-number keystrokes.
|
||||
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith(
|
||||
{ terminalTabId: 'tab-1' },
|
||||
'pty-1',
|
||||
|
|
@ -80,6 +80,40 @@ describe('useNativeChatInteractiveSend', () => {
|
|||
expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a Codex answer through the option-number keystroke path', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex')
|
||||
)
|
||||
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
|
||||
// Codex's request_user_input card ignores typed labels (STA-1860 shape):
|
||||
// the 2nd option is delivered as its digit '2', which selects AND commits.
|
||||
expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith(
|
||||
{ terminalTabId: 'tab-1' },
|
||||
'pty-1',
|
||||
[{ raw: '2' }],
|
||||
expect.any(Function)
|
||||
)
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -83,6 +83,25 @@ describe('Codex auto-approval status suppression', () => {
|
|||
).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves request_user_input question waits even under yolo attribution', () => {
|
||||
registerCodexLaunchConfig({
|
||||
agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '',
|
||||
launchToken
|
||||
})
|
||||
|
||||
expect(
|
||||
shouldSuppressCodexAutoApprovalStatus(
|
||||
{
|
||||
state: 'waiting',
|
||||
prompt: 'pick a color',
|
||||
agentType: 'codex',
|
||||
toolName: 'request_user_input'
|
||||
},
|
||||
{ paneKey, tabId: 'tab-1', launchToken }
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves manual Codex permission attention', () => {
|
||||
registerCodexLaunchConfig({ agentArgs: '', launchToken })
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isAskUserQuestionTool } from '../../../../shared/agent-question-answered-intent'
|
||||
import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume'
|
||||
import { getSyntheticAgentTitleProfile } from '../../../../shared/synthetic-agent-title'
|
||||
import { resolveTuiAgentPermissionMode } from '../../../../shared/tui-agent-permissions'
|
||||
|
|
@ -28,6 +29,10 @@ export function shouldSuppressCodexAutoApprovalStatus(
|
|||
if (payload.agentType !== 'codex' || !isCodexAutoApprovedPermissionState(payload.state)) {
|
||||
return false
|
||||
}
|
||||
// Why: request_user_input waits are real questions the user must answer — yolo auto-approval never resolves them, so they must keep driving status.
|
||||
if (isAskUserQuestionTool(payload.toolName)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const state = useAppStore.getState()
|
||||
if (typeof state.getAgentLaunchConfigForStatusMetadata !== 'function') {
|
||||
|
|
|
|||
|
|
@ -2519,6 +2519,82 @@ describe('shared agent-hook-listener', () => {
|
|||
expect(next?.payload.toolInput).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps Codex request_user_input PreToolUse to waiting with the question card, then clears on the answer', () => {
|
||||
// Real Codex 0.145 shapes: PreToolUse fires while blocked on the answer (no Stop),
|
||||
// PostToolUse carries the answers, Stop ends the turn.
|
||||
const questions = {
|
||||
questions: [
|
||||
{
|
||||
id: 'color_preference',
|
||||
header: 'Color',
|
||||
question: 'Which color do you prefer: red or blue?',
|
||||
options: [{ label: 'Blue', description: 'Choose blue.' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
const waiting = normalizeHookPayload(
|
||||
state,
|
||||
'codex',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'request_user_input',
|
||||
tool_input: questions,
|
||||
tool_use_id: 'call_1'
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(waiting?.payload.state).toBe('waiting')
|
||||
expect(waiting?.payload.toolName).toBe('request_user_input')
|
||||
expect(waiting?.payload.interactivePrompt).toBe(JSON.stringify(questions))
|
||||
|
||||
const answered = normalizeHookPayload(
|
||||
state,
|
||||
'codex',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'request_user_input',
|
||||
tool_input: questions,
|
||||
tool_response: '{"answers":{"color_preference":{"answers":["Blue"]}}}',
|
||||
tool_use_id: 'call_1'
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(answered?.payload.state).toBe('working')
|
||||
expect(answered?.payload.interactivePrompt).toBeUndefined()
|
||||
|
||||
const stop = normalizeHookPayload(
|
||||
state,
|
||||
'codex',
|
||||
{ paneKey: PANE_KEY, payload: { hook_event_name: 'Stop' } },
|
||||
'production'
|
||||
)
|
||||
expect(stop?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
it('keeps ordinary Codex PreToolUse mapped to working', () => {
|
||||
const working = normalizeHookPayload(
|
||||
state,
|
||||
'codex',
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
payload: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'shell',
|
||||
tool_input: { command: 'ls' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(working?.payload.state).toBe('working')
|
||||
expect(working?.payload.interactivePrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears stale Droid tool input when a same-tool update has explicit unpreviewable input', () => {
|
||||
normalizeHookPayload(
|
||||
state,
|
||||
|
|
|
|||
|
|
@ -3230,13 +3230,17 @@ function normalizeCodexEvent(
|
|||
return normalizeCodexSubagentLifecycleEvent(state, eventName, paneKey, hookPayload)
|
||||
}
|
||||
|
||||
// Why: Codex's request_user_input (0.145+) is auto-allowed, so it fires PreToolUse while blocked on a human answer; map to waiting like grok's ask_user_question.
|
||||
const isUserInputPreTool =
|
||||
eventName === 'PreToolUse' &&
|
||||
isAskUserQuestionTool(readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name'))
|
||||
const stateName =
|
||||
eventName === 'SessionStart' ||
|
||||
eventName === 'UserPromptSubmit' ||
|
||||
eventName === 'PreToolUse' ||
|
||||
(eventName === 'PreToolUse' && !isUserInputPreTool) ||
|
||||
eventName === 'PostToolUse'
|
||||
? 'working'
|
||||
: eventName === 'PermissionRequest'
|
||||
: eventName === 'PermissionRequest' || isUserInputPreTool
|
||||
? 'waiting'
|
||||
: eventName === 'Stop'
|
||||
? 'done'
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ export type AgentQuestionAnsweredInferenceRequest = {
|
|||
baselineAgentType: AgentType | undefined
|
||||
}
|
||||
|
||||
/** True for the AskUserQuestion tool across the casing variants different
|
||||
* agents emit (`AskUserQuestion` / `ask_user_question` / `askUserQuestion`).
|
||||
/** True for the ask-the-user-a-question tool across agents: Claude's
|
||||
* `AskUserQuestion`, grok/Pi's `ask_user_question`, and Codex ≥0.145's
|
||||
* `request_user_input` (same questions/options input shape).
|
||||
* Why: this is the structured "pick an option" prompt whose full input the
|
||||
* clients render as a live card. */
|
||||
export function isAskUserQuestionTool(toolName: string | undefined): boolean {
|
||||
return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuserquestion'
|
||||
const normalized = toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase()
|
||||
return normalized === 'askuserquestion' || normalized === 'requestuserinput'
|
||||
}
|
||||
|
||||
const QUESTION_ANSWER_ENTER_INPUTS: ReadonlySet<string> = new Set([
|
||||
|
|
|
|||
|
|
@ -31,13 +31,15 @@ describe('isNativeChatSupportedAgent', () => {
|
|||
})
|
||||
|
||||
describe('shouldStepNativeChatAskAnswer', () => {
|
||||
it('steps only the Claude-format agents (Claude, OpenClaude)', () => {
|
||||
it('steps the digit-commit selector agents (Claude, OpenClaude, Codex)', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
it('does not step other or unknown agents', () => {
|
||||
expect(shouldStepNativeChatAskAnswer('codex')).toBe(false)
|
||||
expect(shouldStepNativeChatAskAnswer('grok')).toBe(false)
|
||||
expect(shouldStepNativeChatAskAnswer('cursor')).toBe(false)
|
||||
expect(shouldStepNativeChatAskAnswer(null)).toBe(false)
|
||||
|
|
|
|||
|
|
@ -12,11 +12,14 @@ export function isNativeChatSupportedAgent(agent: string | null | undefined): bo
|
|||
return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent)
|
||||
}
|
||||
|
||||
/** True when the agent renders Claude's multi-step AskUserQuestion — one question
|
||||
* per step, each Enter advancing — so a multi-line answer must be paced per line.
|
||||
* Other agents submit the whole answer with a single Enter. */
|
||||
/** 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. */
|
||||
export function shouldStepNativeChatAskAnswer(agent: string | null | undefined): boolean {
|
||||
return resolveNativeChatTranscriptAgent(agent) === 'claude'
|
||||
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
|
||||
return transcriptAgent === 'claude' || transcriptAgent === 'codex'
|
||||
}
|
||||
|
||||
export function resolveNativeChatTranscriptAgent(
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
|
|
|||
Loading…
Reference in New Issue