fix(native-chat): restore Codex model picker dispatch (#13669)

* fix(native-chat): restore Codex model picker dispatch

* fix(native-chat): type Codex effort picker command
This commit is contained in:
Brennan Benson
2026-08-10 20:03:48 -07:00
committed by GitHub
parent 7d1a17465e
commit 3e3f48fe89
19 changed files with 276 additions and 41 deletions
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
@@ -7,10 +7,13 @@ import {
openMobileNativeChatSendBudget,
clearMobileNativeChatInput,
sendMobileNativeChatMessage,
sendMobileNativeChatMessageWithOutcome
sendMobileNativeChatMessageWithOutcome,
typeMobileNativeChatCommandWithOutcome
} from './mobile-native-chat-send'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
afterEach(() => vi.useRealTimers())
function clientWithResponse(response: unknown): RpcClient {
return {
sendRequest: vi.fn().mockResolvedValue(response)
@@ -290,6 +293,37 @@ describe('sendMobileNativeChatMessage', () => {
})
})
describe('typeMobileNativeChatCommandWithOutcome', () => {
it('writes the Codex picker command as keys instead of one pasted text write', async () => {
vi.useFakeTimers()
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
const result = typeMobileNativeChatCommandWithOutcome({
client,
terminal: 'term',
command: '/model'
})
await vi.runAllTimersAsync()
await expect(result).resolves.toBe('accepted')
expect(
vi.mocked(client.sendRequest).mock.calls.map((call) => {
const params = call[1] as { text: string; enter: boolean }
return { text: params.text, enter: params.enter }
})
).toEqual(
['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({
text,
enter: false
}))
)
})
})
describe('clearMobileNativeChatInput', () => {
const accepted = {
id: 'request',
@@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing'
type MobileTerminalClient = {
id: string
@@ -93,6 +94,27 @@ export async function sendMobileNativeChatMessage(
return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted'
}
export async function typeMobileNativeChatCommandWithOutcome(args: {
client: RpcClient
terminal: string
command: string
mobileClient?: MobileTerminalClient
deadline?: number
}): Promise<MobileNativeChatSendOutcome> {
return typeAgentTuiCommand({
command: args.command,
write: (key) =>
sendMobileNativeChatMessageWithOutcome({
client: args.client,
terminal: args.terminal,
text: key,
enter: false,
...(args.mobileClient ? { mobileClient: args.mobileClient } : {}),
...(args.deadline === undefined ? {} : { deadline: args.deadline })
})
})
}
/**
* Clear the agent's input line as its OWN write, before any body.
*
@@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const sendWithOutcome = vi.fn()
const clearInputWrite = vi.fn()
const typeCommandWithOutcome = vi.fn()
vi.mock('./mobile-native-chat-send', () => ({
sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args),
typeMobileNativeChatCommandWithOutcome: (...args: unknown[]) => typeCommandWithOutcome(...args),
clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args),
openMobileNativeChatSendBudget: () => Date.now() + 15_000,
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000,
@@ -84,6 +86,8 @@ describe('useMobileNativeChatMessageSend', () => {
sendWithOutcome.mockResolvedValue('accepted')
clearInputWrite.mockReset()
clearInputWrite.mockResolvedValue(true)
typeCommandWithOutcome.mockReset()
typeCommandWithOutcome.mockResolvedValue('accepted')
acceptSend.mockReset()
holdUnconfirmedSend.mockReset()
onCommandSend.mockReset()
@@ -260,6 +264,19 @@ describe('useMobileNativeChatMessageSend', () => {
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
})
it('routes typed picker commands around the pasted composer-text send', async () => {
mount(() => null, 'codex')
let outcome: string | undefined
await act(async () => {
outcome = await api!.dispatchCommand('/model', { delivery: 'type' })
})
expect(outcome).toBe('accepted')
expect(typeCommandWithOutcome).toHaveBeenCalledWith(
expect.objectContaining({ command: '/model', terminal: 'term' })
)
expect(sendWithOutcome).not.toHaveBeenCalled()
})
it('binds classification to the agent that started the send', async () => {
let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void
sendWithOutcome.mockReturnValue(
@@ -4,8 +4,10 @@ import {
clearMobileNativeChatInput,
openMobileNativeChatSendBudget,
sendMobileNativeChatMessageWithOutcome,
typeMobileNativeChatCommandWithOutcome,
type MobileNativeChatSendOutcome
} from './mobile-native-chat-send'
import type { CatalogCommandDelivery } from '../../../src/shared/agent-session-option-catalog'
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
import {
@@ -32,7 +34,10 @@ export type MobileNativeChatMessageSend = {
answerQuestion: (text: string) => Promise<boolean>
/** Session-option command dispatch (e.g. `/model sonnet`) — never touches the
* composer draft; callers need the outcome to track dispatched state. */
dispatchCommand: (text: string) => Promise<MobileNativeChatSendOutcome>
dispatchCommand: (
text: string,
options?: { delivery?: CatalogCommandDelivery }
) => Promise<MobileNativeChatSendOutcome>
}
/** The native-chat send seam: one write path shared by composer sends, image
@@ -269,12 +274,41 @@ export function useMobileNativeChatMessageSend(args: {
// spaces a send's body and its Enter ~500ms apart — so without this lock an
// apply lands between them and is submitted as part of the user's prompt.
const dispatchCommand = useCallback(
async (text: string): Promise<MobileNativeChatSendOutcome> => {
async (
text: string,
options?: { delivery?: CatalogCommandDelivery }
): Promise<MobileNativeChatSendOutcome> => {
const terminal = handleRef.current
if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) {
return 'rejected'
}
try {
if (options?.delivery === 'type') {
if (!client || !terminal || !enabled) {
return 'rejected'
}
const deadline = openMobileNativeChatSendBudget()
const mobileClient = deviceTokenRef.current
? { id: deviceTokenRef.current, type: 'mobile' as const }
: undefined
if (
!(await healMobileNativeChatStaleInput({
client,
terminal,
deviceToken: deviceTokenRef.current,
deadline
}))
) {
return 'rejected'
}
return typeMobileNativeChatCommandWithOutcome({
client,
terminal,
command: text,
...(mobileClient ? { mobileClient } : {}),
deadline
})
}
return await sendMessage(text, undefined, false, false)
} finally {
if (terminal) {
@@ -282,7 +316,7 @@ export function useMobileNativeChatMessageSend(args: {
}
}
},
[handleRef, sendMessage]
[client, deviceTokenRef, enabled, handleRef, sendMessage]
)
return { send, sendWithOutcome, answerQuestion, dispatchCommand }
@@ -14,7 +14,7 @@ describe('useMobileNativeChatSessionOptions', () => {
let renderer: ReactTestRenderer | null = null
let api: MobileNativeChatSessionOptionsController | null = null
let hookArgs: HookArgs
const dispatchCommand = vi.fn<(command: string) => Promise<MobileNativeChatSendOutcome>>()
const dispatchCommand = vi.fn<HookArgs['dispatchCommand']>()
const onAgentPicker = vi.fn()
function Probe(): null {
@@ -97,14 +97,23 @@ describe('useMobileNativeChatSessionOptions', () => {
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
})
it('applies Codex model changes through the native command', async () => {
it('types the Codex picker command and switches to the terminal', async () => {
mount({ agent: 'codex' })
expect(api!.snapshot[0]?.action).toBeUndefined()
expect(api!.snapshot[0]?.action).toEqual({ type: 'agent-picker' })
await act(async () => {
await api!.setOption('model', 'gpt-5.5')
await api!.invokeAction('model')
})
expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5')
expect(onAgentPicker).not.toHaveBeenCalled()
expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' })
expect(onAgentPicker).toHaveBeenCalledOnce()
})
it('types the Codex effort picker command', async () => {
mount({ agent: 'codex', reportedModel: 'gpt-5.5' })
await act(async () => {
await api!.invokeAction('effort')
})
expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' })
expect(onAgentPicker).toHaveBeenCalledOnce()
})
it('seeds the current model from a hook-reported provider model', () => {
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import {
getAgentSessionOptionCatalog,
type AgentSessionOptionCatalog,
type CatalogCommandDelivery,
type CatalogModel
} from '../../../src/shared/agent-session-option-catalog'
import type {
@@ -95,7 +96,10 @@ export function useMobileNativeChatSessionOptions(args: {
scopeKey: string | null
/** Provider model from live agent status, when the hook reported one. */
reportedModel: string | null
dispatchCommand: (command: string) => Promise<MobileNativeChatSendOutcome>
dispatchCommand: (
command: string,
options?: { delivery?: CatalogCommandDelivery }
) => Promise<MobileNativeChatSendOutcome>
/** A model change that must happen in the agent's own TUI picker was
* dispatched — bring the terminal view forward. */
onAgentPicker?: () => void
@@ -297,7 +301,9 @@ export function useMobileNativeChatSessionOptions(args: {
?.options.find((option) => option.id === id)?.apply
const midSession = apply?.midSession
if (midSession?.kind === 'agent-picker') {
const outcome = await dispatchCommand(midSession.command)
const outcome = midSession.delivery
? await dispatchCommand(midSession.command, { delivery: midSession.delivery })
: await dispatchCommand(midSession.command)
if (outcome === 'rejected') {
return false
}
@@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => ({
sendHandle: { cancel: vi.fn(), settleAfterMs: 500 },
sendNativeChatMessage: vi.fn(),
sendNativeChatMessageVerified: vi.fn(),
typeNativeChatCommand: vi.fn(),
trackPendingSend: vi.fn(),
setDraft: vi.fn(),
draftScopeKeys: [] as string[],
@@ -65,6 +66,7 @@ vi.mock('./native-chat-runtime-send', () => ({
sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args),
sendNativeChatMessageVerified: (...args: unknown[]) =>
mocks.sendNativeChatMessageVerified(...args),
typeNativeChatCommand: (...args: unknown[]) => mocks.typeNativeChatCommand(...args),
sendNativeChatMessageWithImageAttachments: vi.fn(),
submitNativeChatPrompt: vi.fn()
}))
@@ -181,6 +183,7 @@ describe('NativeChatComposer', () => {
})
mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle)
mocks.sendNativeChatMessageVerified.mockResolvedValue(true)
mocks.typeNativeChatCommand.mockResolvedValue(true)
mocks.sendHandle.settleAfterMs = 500
Object.defineProperty(window, 'api', {
configurable: true,
@@ -550,7 +553,7 @@ describe('NativeChatComposer', () => {
expect(onSwitchToTerminal).toHaveBeenCalledOnce()
})
it('applies a Codex model change without switching to the terminal', async () => {
it('types the Codex picker command and switches to the terminal', async () => {
mocks.sendHandle.settleAfterMs = 0
const onSwitchToTerminal = vi.fn()
render(
@@ -563,17 +566,17 @@ describe('NativeChatComposer', () => {
/>
)
// Codex model changes are value-bearing commands; only effort still uses the TUI picker.
await act(async () => {
await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'gpt-5.5')
await mocks.fieldProps?.sessionOptionsSurface?.invokeAction('model')
})
expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith(
expect(mocks.typeNativeChatCommand).toHaveBeenCalledWith(
{},
'pty-1',
'/model gpt-5.5',
'/model',
expect.any(AbortSignal)
)
expect(onSwitchToTerminal).not.toHaveBeenCalled()
expect(mocks.sendNativeChatMessageVerified).not.toHaveBeenCalled()
expect(onSwitchToTerminal).toHaveBeenCalledOnce()
})
})
@@ -565,7 +565,7 @@ describe('native chat PTY session options', () => {
)
const result = await surface.invokeAction('effort')
expect(dispatch).toHaveBeenCalledWith('/model')
expect(dispatch).toHaveBeenCalledWith('/model', { delivery: 'type' })
expect(onAgentPicker).toHaveBeenCalledOnce()
expect(result.snapshot).toHaveLength(1)
expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
@@ -12,6 +12,7 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({
import {
sendNativeChatMessage,
sendNativeChatMessageVerified,
typeNativeChatCommand,
sendNativeChatMessageWithImageAttachments,
submitNativeChatPrompt,
sendNativeChatAskAnswer,
@@ -252,6 +253,35 @@ describe('sendNativeChatMessageVerified', () => {
})
})
describe('typeNativeChatCommand', () => {
beforeEach(() => {
vi.useFakeTimers()
sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true)
resetNativeChatPtySendQueuesForTests()
})
afterEach(() => {
vi.useRealTimers()
resetNativeChatPtySendQueuesForTests()
})
it('writes the Codex picker command as keys instead of one pasted text write', async () => {
const result = typeNativeChatCommand(SETTINGS, PTY, '/model')
await vi.runAllTimersAsync()
await expect(result).resolves.toBe(true)
expectWriteOrder(sendRuntimePtyInputVerified.mock.calls, [
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
'/',
'm',
'o',
'd',
'e',
'l',
NATIVE_CHAT_SUBMIT
])
})
})
describe('sendNativeChatMessageWithImageAttachments', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -19,6 +19,7 @@ import {
buildNativeChatPasteBytes,
NATIVE_CHAT_SUBMIT
} from './native-chat-send'
import { typeAgentTuiCommand } from '../../../../shared/agent-tui-command-typing'
import {
cancelNativeChatPtySends,
enqueueNativeChatPtySend,
@@ -213,6 +214,24 @@ export async function sendNativeChatMessageVerified(
return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT)
}
/** Types a slash command as individual keys so Codex opens its command palette. */
export async function typeNativeChatCommand(
settings: RuntimeSettings,
ptyId: string,
command: string,
signal?: AbortSignal
): Promise<boolean> {
cancelNativeChatPtySends(ptyId)
await waitForNativeChatPtyIdle(ptyId)
const outcome = await typeAgentTuiCommand({
command,
signal,
write: async (key) =>
(await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected'
})
return outcome === 'accepted'
}
export function sendNativeChatMessageWithImageAttachments(
settings: RuntimeSettings,
ptyId: string,
@@ -109,7 +109,9 @@ async function handleAgentPicker(
ctx: SessionOptionApplyContext,
midSession: Extract<CatalogMidSessionApply, { kind: 'agent-picker' }>
): Promise<SessionOptionSetResult> {
await ctx.dispatchCommand(midSession.command)
await (midSession.delivery
? ctx.dispatchCommand(midSession.command, { delivery: midSession.delivery })
: ctx.dispatchCommand(midSession.command))
ctx.clearModelTruth()
const snapshot = ctx.publish()
ctx.onAgentPicker?.()
@@ -1,4 +1,7 @@
import type { CatalogAgentInteractionDetection } from '../../../../shared/agent-session-option-catalog'
import type {
CatalogAgentInteractionDetection,
CatalogCommandDelivery
} from '../../../../shared/agent-session-option-catalog'
import type { ClaudeModelSwitchOutcome } from './claude-model-switch-confirmation'
export type NativeChatSessionOptionDispatchResult = {
@@ -10,6 +13,7 @@ export type NativeChatSessionOptionDispatchCommand = (
options?: {
detectAgentInteraction?: CatalogAgentInteractionDetection
expectedChoiceLabel?: string
delivery?: CatalogCommandDelivery
}
) =>
| Promise<NativeChatSessionOptionDispatchResult | void>
@@ -6,7 +6,7 @@ import {
type NativeChatResolvedTarget
} from './native-chat-composer-target'
import { pushHistory, type HistoryState } from './native-chat-composer-state'
import { sendNativeChatMessageVerified } from './native-chat-runtime-send'
import { sendNativeChatMessageVerified, typeNativeChatCommand } from './native-chat-runtime-send'
import { cancelNativeChatPtySends, waitForNativeChatPtyIdle } from './native-chat-pty-send-queue'
import {
createClaudeModelSwitchConfirmationObserver,
@@ -86,12 +86,20 @@ export function useNativeChatSessionOptionCommand(args: {
// submit immediately so historical output cannot satisfy the match.
observer.arm()
}
const accepted = await sendNativeChatMessageVerified(
target.settings,
target.ptyId,
command,
sendController.signal
)
const accepted =
options?.delivery === 'type'
? await typeNativeChatCommand(
target.settings,
target.ptyId,
command,
sendController.signal
)
: await sendNativeChatMessageVerified(
target.settings,
target.ptyId,
command,
sendController.signal
)
if (!accepted) {
throw new Error('The terminal did not accept the command.')
}
@@ -204,7 +204,7 @@ function codexEffort(includeExtraHigh: boolean): CatalogOption {
launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`],
agentArgsOverride: hasCodexEffortOverride,
removeAgentArgs: removeCodexEffortOverride,
midSession: { kind: 'agent-picker', command: '/model' }
midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' }
}
}
}
@@ -228,12 +228,9 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
launchArgs: (value) => ['-m', String(value)],
agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']),
removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']),
// Codex accepts a model argument in its live /model command.
midSession: {
kind: 'command',
build: (value) => `/model ${String(value)}`,
pickerCommand: '/model'
}
// Codex classifies multi-character writes as pasted prose; type the bare
// command and let its own picker apply the account-supported model.
midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' }
},
unknownModelOptions: [codexEffort(true)]
}
@@ -6,6 +6,7 @@ import type {
} from './native-chat-session-options'
export type CatalogAgentInteractionDetection = 'claude-model-switch-confirmation'
export type CatalogCommandDelivery = 'type'
export type CatalogMidSessionApply =
| {
@@ -15,7 +16,7 @@ export type CatalogMidSessionApply =
detectAgentInteraction?: CatalogAgentInteractionDetection
}
| { kind: 'toggle-command'; command: string }
| { kind: 'agent-picker'; command: string }
| { kind: 'agent-picker'; command: string; delivery?: CatalogCommandDelivery }
| { kind: 'unsupported' }
export type CatalogOptionApply = {
@@ -20,6 +20,7 @@ import type { SessionOptionValue } from './native-chat-session-options'
export type {
AgentSessionOptionCatalog,
CatalogAgentInteractionDetection,
CatalogCommandDelivery,
CatalogMidSessionApply,
CatalogModel,
CatalogOption,
+43
View File
@@ -0,0 +1,43 @@
import { AGENT_TUI_CLEAR_INPUT_LINE } from './agent-tui-input-clear'
export type AgentTuiCommandWriteOutcome = 'accepted' | 'rejected' | 'unknown'
export const AGENT_TUI_COMMAND_KEY_INTERVAL_MS = 16
function waitForNextKey(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) {
return Promise.resolve(false)
}
return new Promise((resolve) => {
const timer = setTimeout(() => finish(true), AGENT_TUI_COMMAND_KEY_INTERVAL_MS)
const finish = (completed: boolean): void => {
clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
resolve(completed)
}
const onAbort = (): void => finish(false)
signal?.addEventListener('abort', onAbort, { once: true })
})
}
/** Sends one PTY write per key so slash-command TUIs do not classify it as pasted prose. */
export async function typeAgentTuiCommand(args: {
command: string
write: (key: string) => Promise<AgentTuiCommandWriteOutcome>
signal?: AbortSignal
}): Promise<AgentTuiCommandWriteOutcome> {
const keys = [AGENT_TUI_CLEAR_INPUT_LINE, ...args.command, '\r']
for (let index = 0; index < keys.length; index += 1) {
if (args.signal?.aborted) {
return 'rejected'
}
const outcome = await args.write(keys[index]!)
if (outcome !== 'accepted') {
return outcome
}
if (index < keys.length - 1 && !(await waitForNextKey(args.signal))) {
return 'rejected'
}
}
return 'accepted'
}
@@ -68,7 +68,7 @@ describe('buildNativeChatSessionOptionCommand', () => {
).toBe('/fast')
})
it('builds an absolute command for live Codex model changes', () => {
it('does not turn a Codex model pick into pasted slash-command prose', () => {
expect(
buildNativeChatSessionOptionCommand({
optionId: 'model',
@@ -79,7 +79,12 @@ describe('buildNativeChatSessionOptionCommand', () => {
models: CODEX_SESSION_OPTION_CATALOG.models,
record: createNativeChatSessionOptionRecord('codex')
})
).toBe('/model gpt-5.5')
).toBeNull()
expect(CODEX_SESSION_OPTION_CATALOG.modelApply.midSession).toEqual({
kind: 'agent-picker',
command: '/model',
delivery: 'type'
})
})
})
@@ -207,7 +207,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => {
})
})
it('exposes Codex model changes as native selectable values', () => {
it('routes Codex model changes through its typed TUI picker', () => {
const snapshot = buildNativeChatSessionOptionSnapshot({
catalog: CODEX_SESSION_OPTION_CATALOG,
models: CODEX_SESSION_OPTION_CATALOG.models,
@@ -216,7 +216,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => {
modelLabel: 'Model'
})
expect(snapshot[0]).toMatchObject({ settable: true })
expect(snapshot[0]?.action).toBeUndefined()
expect(snapshot[0]?.action).toEqual({ type: 'agent-picker' })
expect(snapshot[0]?.kind).toMatchObject({
type: 'select',
choices: expect.arrayContaining([{ value: 'gpt-5.5', label: 'GPT-5.5' }])