mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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?.()
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
@@ -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' }])
|
||||
|
||||
Reference in New Issue
Block a user