fix(native-chat): pass session-reported slash commands through to the session (#34048)

OMP's ACP session advertises commands it handles itself (e.g. /fast), but
native chat claimed them as host-only TUI commands and refused them as
unavailable. Commands the running session reports in its slash menu are now
left unclaimed so they send through, on desktop and mobile. Mobile reads the
session command list once in the native-chat lane and shares it between the
send path and the composer menu.
This commit is contained in:
Neil
2026-10-26 12:48:34 -07:00
committed by GitHub
parent f551e0addb
commit 0283880bb1
17 changed files with 134 additions and 59 deletions
@@ -83,7 +83,7 @@ vi.mock('../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionS
vi.mock('../../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' }))
export const visible = { value: true }
const session = { messages: [], folded: [], status: 'ready', transcriptLoading: false }
const structured = {
const structuredSession = {
session,
isWorking: false,
turnId: null,
@@ -102,6 +102,7 @@ const structured = {
setStructuredOption: vi.fn(),
invokeStructuredOption: vi.fn()
}
const sessionCommands = { commands: undefined, refresh: vi.fn() }
vi.mock('../use-mobile-session-view-mode', () => ({
useMobileSessionViewMode: () => ({
isTabChatView: () => visible.value,
@@ -111,7 +112,7 @@ vi.mock('../use-mobile-session-view-mode', () => ({
})
}))
vi.mock('../use-mobile-native-chat-session-lane', () => ({
useMobileNativeChatSessionLane: () => ({ session, structuredSession: structured })
useMobileNativeChatSessionLane: () => ({ session, structuredSession, sessionCommands })
}))
vi.mock('../native-chat/composer/use-mobile-native-chat-drafts', () => ({
useMobileNativeChatDrafts: () => ({
@@ -22,6 +22,7 @@ import type { MobileNativeChatPendingMessage } from './native-chat/mobile-native
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import type { MobileNativeChatVisualSource } from './mobile-native-chat-visual-read'
import type { useMobileStructuredSessionCommands } from './use-mobile-structured-session-commands'
export type MobileNativeChatController = {
sessionViewReady: boolean
@@ -93,5 +94,6 @@ export type MobileNativeChatController = {
// Read parked TUI launch text to clear one line per Ctrl+U before pasting images.
readSeededLaunchDraft: () => string | null
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
nativeChatSessionCommands: ReturnType<typeof useMobileStructuredSessionCommands>
nativeChatAcceptsImages: boolean
}
@@ -1,3 +1,4 @@
import type { AgentSessionSlashCommand } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send'
@@ -56,6 +57,8 @@ export type MobileStructuredAgentSessionArgs = {
/** Capability facts from the shared runtime status probe; null follows the legacy wire. */
hostSupport: StructuredAgentSessionHostSupport | null
agent: string | null
/** What the session reported for its `/` menu; undefined until read or on an older host. */
sessionCommands?: readonly AgentSessionSlashCommand[]
appendComposerText?: (text: string) => boolean
onSendError: MobileNativeChatSendErrorReporter
// Accepted queued actions retire the route’s failure banner.
@@ -108,6 +108,19 @@ describe('mobile structured conversation commands', () => {
expect(input.onError).toHaveBeenCalled()
}
)
it('leaves a session-reported command such as OMP /fast to an ordinary send', async () => {
const { input, sendRequest } = setup()
input.text = '/fast'
input.controller.agent = 'omp'
await dispatchMobileStructuredCommand(input)
expect(input.onError).toHaveBeenCalledWith(
expect.stringContaining('/fast is not available'),
undefined
)
input.controller.sessionCommands = [{ name: 'fast', kind: 'command' }]
expect(await dispatchMobileStructuredCommand(input)).toBeNull()
expect(sendRequest).not.toHaveBeenCalled()
})
it('/clear is a plain command on every host: no withdrawal fields, nothing persisted', async () => {
// The host carries cards to replacement sessions; client restoration would duplicate them.
const { input, sendRequest } = setup()
@@ -44,7 +44,13 @@ export async function dispatchMobileStructuredCommand(input: {
if (input.pending.current) {
return 'rejected'
}
if (!isStructuredAgentSessionComposerCommand(input.text, input.controller.agent)) {
if (
!isStructuredAgentSessionComposerCommand(
input.text,
input.controller.agent,
input.controller.sessionCommands
)
) {
return null
}
if (input.hasAttachments) {
@@ -1,31 +1,22 @@
import { useMemo } from 'react'
import type { AgentSessionConversationCommand } from '../../../../../src/shared/agent-session/agent-session-conversation-command'
import type { RpcClient } from '../../../transport/rpc-client'
import type { MobileNativeChatStructuredComposer } from './MobileNativeChatComposer'
import type { MobileNativeChatFileAttachments } from './use-mobile-native-chat-file-attachments'
import { useMobileStructuredSessionCommands } from '../../use-mobile-structured-session-commands'
import type { useMobileStructuredSessionCommands } from '../../use-mobile-structured-session-commands'
const NO_COMMANDS: readonly AgentSessionConversationCommand[] = []
export function useMobileNativeChatStructuredComposer(args: {
client: RpcClient | null
hostId: string | null
sessionId: string | null
structured: boolean
connected: boolean
sessionCommands: ReturnType<typeof useMobileStructuredSessionCommands>
/** The host keeps files a phone uploads; an older one refuses the upload. */
acceptsFiles: boolean
conversationCommands: readonly AgentSessionConversationCommand[] | undefined
files: MobileNativeChatFileAttachments
}): MobileNativeChatStructuredComposer | null {
const { client, hostId, sessionId, structured, connected, acceptsFiles, files } = args
const { structured, acceptsFiles, files } = args
const conversationCommands = args.conversationCommands ?? NO_COMMANDS
const { commands, refresh } = useMobileStructuredSessionCommands({
client,
hostId,
sessionId,
enabled: structured && connected
})
const { commands, refresh } = args.sessionCommands
const { files: pendingFiles, attachFiles, removeFiles } = files
// Memoize commands separately so attachment changes cannot rebuild the menu catalog.
const catalog = useMemo(
@@ -81,6 +81,9 @@ vi.mock('./use-mobile-session-view-mode', () => ({
toggleTabChatView: vi.fn()
})
}))
vi.mock('./use-mobile-structured-session-commands', () => ({
useMobileStructuredSessionCommands: () => ({ commands: undefined, refresh: vi.fn() })
}))
vi.mock('./use-mobile-native-chat-session', () => ({
useMobileNativeChatSession: () => sessionState
}))
@@ -54,7 +54,6 @@ export function useMobileNativeChatController(args: {
onSendResolved
} = args
const {
activeChatAgent,
activeChatAgentConfirmed,
activeChatAgentRef,
activeChatResolution,
@@ -75,22 +74,20 @@ export function useMobileNativeChatController(args: {
// Fixed hook order mounts the lane before drafts; defer Edit’s append through a ref and delete nothing until copying succeeds.
const appendComposerTextRef = useRef<(text: string) => boolean>(() => false)
const { structuredSession: structuredNativeChat, session: nativeChatSession } =
useMobileNativeChatSessionLane({
client,
structured: activeChatStructured,
agent: activeChatAgent,
resolvedAgent: activeChatResolution?.agent ?? null,
transcriptPath: activeChatResolution?.transcriptPath ?? null,
sessionId: activeChatSessionId,
sourceIdentity,
enabled: showNativeChat,
connState,
hostSupport: agentSessionHostSupport,
appendComposerTextRef,
onSendError,
onActionResolved: onSendResolved
})
const nativeChatLane = useMobileNativeChatSessionLane({
client,
hostId,
structured: activeChatStructured,
resolution: activeChatResolution,
sourceIdentity,
enabled: showNativeChat,
connState,
hostSupport: agentSessionHostSupport,
appendComposerTextRef,
onSendError,
onActionResolved: onSendResolved
})
const { structuredSession: structuredNativeChat, session: nativeChatSession } = nativeChatLane
const {
composerText: chatComposerText,
setComposerText: setChatComposerText,
@@ -207,6 +204,7 @@ export function useMobileNativeChatController(args: {
const structuredNativeChatSend = useMobileStructuredNativeChatSendBridge({
agent: activeChatResolution?.agent,
sessionCommands: nativeChatLane.sessionCommands.commands,
sendStructured: structuredNativeChat.sendWithOutcome,
captureSendOrigin,
clearDraftForSend,
@@ -317,6 +315,7 @@ export function useMobileNativeChatController(args: {
: handleNativeChatSendWithOutcome,
readSeededLaunchDraft,
nativeChatSessionOptions,
nativeChatSessionCommands: nativeChatLane.sessionCommands,
nativeChatAcceptsImages: !activeChatStructured || structuredNativeChat.acceptsImages
}
}
@@ -4,16 +4,16 @@ import type { ConnectionState } from '../transport/types'
import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
import { useMobileStructuredSessionCommands } from './use-mobile-structured-session-commands'
import type { MobileNativeChatResolution } from './native-chat/mobile-native-chat-eligibility'
import type { MobileNativeChatSendErrorReporter } from './native-chat/use-mobile-native-chat-send-error'
// Run both hooks in fixed order and starve the inactive lane’s identity so flips preserve cached transcripts.
export function useMobileNativeChatSessionLane({
client,
hostId,
structured,
agent,
resolvedAgent,
transcriptPath,
sessionId,
resolution,
sourceIdentity,
hostSupport,
appendComposerTextRef,
@@ -23,11 +23,9 @@ export function useMobileNativeChatSessionLane({
onActionResolved
}: {
client: RpcClient | null
hostId: string
structured: boolean
agent: string | null
resolvedAgent: string | null
transcriptPath: string | null
sessionId: string | null
resolution: MobileNativeChatResolution | null
sourceIdentity: Parameters<typeof useMobileNativeChatSession>[0]['sourceIdentity']
hostSupport: StructuredAgentSessionHostSupport | null
// Defer Edit through the composer ref because drafts mount after this lane.
@@ -39,17 +37,27 @@ export function useMobileNativeChatSessionLane({
}): {
structuredSession: ReturnType<typeof useMobileStructuredAgentSession>
session: ReturnType<typeof useMobileNativeChatSession>
sessionCommands: ReturnType<typeof useMobileStructuredSessionCommands>
} {
const appendComposerText = useCallback(
(text: string) => appendComposerTextRef.current(text),
[appendComposerTextRef]
)
const agent = resolution?.agent ?? null
const sessionId = resolution?.sessionId ?? null
// One read serves both send-side ownership and the composer's `/` menu.
const sessionCommands = useMobileStructuredSessionCommands({
client,
hostId,
sessionId: structured ? sessionId : null,
enabled: connState === 'connected'
})
const bridgeSession = useMobileNativeChatSession({
client,
sourceIdentity,
agent: structured ? null : resolvedAgent,
agent: structured ? null : agent,
sessionId: structured ? null : sessionId,
transcriptPath: structured ? null : transcriptPath
transcriptPath: structured ? null : (resolution?.transcriptPath ?? null)
})
const structuredSession = useMobileStructuredAgentSession({
client,
@@ -62,11 +70,13 @@ export function useMobileNativeChatSessionLane({
// reacquire the provider without clearing the cached transcript.
connected: connState === 'connected',
agent: structured ? agent : null,
sessionCommands: sessionCommands.commands,
onSendError,
...(onActionResolved ? { onActionResolved } : {})
})
return {
structuredSession,
session: structured ? structuredSession.session : bridgeSession
session: structured ? structuredSession.session : bridgeSession,
sessionCommands
}
}
@@ -109,11 +109,8 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti
onError: triggerError
})
const nativeChatStructuredComposer = useMobileNativeChatStructuredComposer({
client,
hostId,
sessionId: nativeChatSessionId,
structured: structuredNativeChat,
connected: connState === 'connected',
sessionCommands: nativeChatController.nativeChatSessionCommands,
acceptsFiles: agentSessionHostSupport?.phoneAttachments === true,
conversationCommands:
nativeChatController.nativeChatSessionOptions?.controller.conversationCommands,
@@ -191,6 +191,7 @@ export function useMobileStructuredAgentSession(
hold: sends.hold,
commandPending: commandPendingRef,
controller: sendController,
sessionCommands: args.sessionCommands,
onSendError,
sendOptions: hostSupport?.sendOptions === true ? options.sendSelection : undefined
})
@@ -1,5 +1,6 @@
import { useCallback } from 'react'
import type { StructuredAgentId } from '../../../src/shared/agent-session-provider-handle'
import type { AgentSessionSlashCommand } from '../../../src/shared/agent-session-wire'
import { isStructuredAgentSessionComposerCommand } from '../../../src/shared/structured-agent-session-composer'
import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send'
import type { MobileNativeChatSendOrigin } from './native-chat/mobile-native-chat-pending-echo'
@@ -7,6 +8,7 @@ import type { StructuredMobileSendAttachment } from './use-mobile-structured-sen
export function useMobileStructuredNativeChatSendBridge(args: {
agent: StructuredAgentId | undefined
sessionCommands?: readonly AgentSessionSlashCommand[]
sendStructured: (
text: string,
images?: string[],
@@ -32,7 +34,8 @@ export function useMobileStructuredNativeChatSendBridge(args: {
clearDraftForSend,
onSendError,
restoreRejectedDraft,
sendStructured
sendStructured,
sessionCommands
} = args
const sendWithOutcome = useCallback(
async (
@@ -46,7 +49,7 @@ export function useMobileStructuredNativeChatSendBridge(args: {
onSendError('Message not sent (disconnected)')
return 'rejected'
}
const isHostCommand = isStructuredAgentSessionComposerCommand(text, agent)
const isHostCommand = isStructuredAgentSessionComposerCommand(text, agent, sessionCommands)
clearDraftForSend(origin, text)
const outcome =
attachments !== undefined
@@ -62,7 +65,15 @@ export function useMobileStructuredNativeChatSendBridge(args: {
}
return outcome
},
[agent, captureSendOrigin, clearDraftForSend, onSendError, restoreRejectedDraft, sendStructured]
[
agent,
captureSendOrigin,
clearDraftForSend,
onSendError,
restoreRejectedDraft,
sendStructured,
sessionCommands
]
)
const send = useCallback(
async (
@@ -8,6 +8,7 @@ import {
type StructuredAgentSessionAttachment
} from '../../../src/shared/structured-agent-session-send-mutation'
import type { StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer'
import type { AgentSessionSlashCommand } from '../../../src/shared/agent-session-wire'
import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send'
@@ -53,6 +54,7 @@ export function useMobileStructuredSendWithOutcome(args: {
StructuredAgentSessionComposerOptions,
'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands'
>
sessionCommands?: readonly AgentSessionSlashCommand[]
onSendError: MobileNativeChatSendErrorReporter
// Older hosts accept neither per-chat model nor effort.
sendOptions?: () => Readonly<Record<string, string>> | undefined
@@ -74,6 +76,7 @@ export function useMobileStructuredSendWithOutcome(args: {
onSendError,
queueCapable,
sendOptions,
sessionCommands,
sessionId,
stateRef,
stopping
@@ -107,7 +110,7 @@ export function useMobileStructuredSendWithOutcome(args: {
sessionId,
fence: currentFence,
pending: commandPending,
controller: { agent: agent ?? undefined, ...controller },
controller: { agent: agent ?? undefined, ...controller, sessionCommands },
busy: () =>
actionableStructuredApproval(stateRef.current) ||
actionableStructuredQuestion(stateRef.current)
@@ -180,6 +183,7 @@ export function useMobileStructuredSendWithOutcome(args: {
onSendError,
queueCapable,
sendOptions,
sessionCommands,
sessionId,
stateRef,
stopping
@@ -47,7 +47,7 @@ function isNativeChatStructuredHostCommand(
transport: NativeChatStructuredComposerTransport
): boolean {
return (
isStructuredAgentSessionComposerCommand(text, agent) ||
isStructuredAgentSessionComposerCommand(text, agent, transport.sessionCommands) ||
(transport.threadGoal !== undefined && isStructuredAgentSessionGoalCommand(text))
)
}
@@ -86,6 +86,7 @@ export function useNativeChatStructuredComposerTransport(args: {
return true
},
setOption: controller.setStructuredOption,
sessionCommands: controller.sessionCommands,
conversationCommands: controller.conversationCommands,
runConversationCommand: controller.runConversationCommand,
...(setThreadGoalObjective ? { setThreadGoalObjective } : {})
@@ -264,6 +264,25 @@ describe('agent-implemented commands pass through to the agent', () => {
expect(setThreadGoalObjective).not.toHaveBeenCalled()
})
// OMP's ACP catalog advertises only commands it handles itself, e.g. /fast.
it('passes a session-reported command on to the session that advertised it', async () => {
const sessionCommands = [
{ name: 'fast', kind: 'command' },
{ name: 'compact', kind: 'command' }
] as const
expect(isStructuredAgentSessionComposerCommand('/fast', 'omp')).toBe(true)
expect(isStructuredAgentSessionComposerCommand('/fast', 'omp', sessionCommands)).toBe(false)
expect(
await dispatchStructuredAgentSessionComposerCommand('/fast status', {
...controller,
agent: 'omp',
sessionCommands
})
).toEqual(PASSED_THROUGH)
// Orca still runs its own commands even when the session reports them.
expect(isStructuredAgentSessionComposerCommand('/compact', 'omp', sessionCommands)).toBe(true)
})
it('keeps refusing a Codex command the model cannot carry out', async () => {
expect(isStructuredAgentSessionComposerCommand('/permissions', 'codex')).toBe(true)
expect(
@@ -37,6 +37,8 @@ export type StructuredAgentSessionComposerOptions = {
snapshot: readonly SessionOptionDescriptor[]
invokeAction: (id: string) => Promise<boolean>
setOption: (id: string, value: SessionOptionValue) => Promise<boolean>
/** What the running session reported for its `/` menu; undefined until read or on an older host. */
sessionCommands?: readonly AgentSessionSlashCommand[]
conversationCommands?: readonly AgentSessionConversationCommand[]
runConversationCommand?: (
command: AgentSessionConversationCommand
@@ -106,12 +108,19 @@ export function structuredSessionSlashCommands(
]
}
/** Claim hidden TUI-only commands too, preventing literal prose delivery; leave agent-handled commands unclaimed for passthrough. */
function structuredRecognizedCommands(agent: AgentType): readonly SlashCommandSuggestion[] {
/** Claim hidden TUI-only commands too, preventing literal prose delivery; leave agent-handled
* and session-reported commands unclaimed for passthrough. */
function structuredRecognizedCommands(
agent: AgentType,
reported: readonly AgentSessionSlashCommand[] | undefined
): readonly SlashCommandSuggestion[] {
const sessionHandled = new Set(
reported?.filter((entry) => entry.kind === 'command').map((entry) => entry.name)
)
return [
...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS,
...CONVERSATION_COMMANDS,
...getHostClaimedNativeChatCommands(agent)
...getHostClaimedNativeChatCommands(agent).filter((entry) => !sessionHandled.has(entry.name))
]
}
@@ -120,11 +129,13 @@ function structuredRecognizedCommands(agent: AgentType): readonly SlashCommandSu
* would be silently dropped, whereas a pass-through command is a real send. */
export function isStructuredAgentSessionComposerCommand(
text: string,
agent: AgentType = 'codex'
agent: AgentType = 'codex',
reported?: readonly AgentSessionSlashCommand[]
): boolean {
const command = commandParts(text)
return Boolean(
command && structuredRecognizedCommands(agent).some((entry) => entry.name === command.name)
command &&
structuredRecognizedCommands(agent, reported).some((entry) => entry.name === command.name)
)
}
@@ -189,7 +200,10 @@ export async function dispatchStructuredAgentSessionComposerCommand(
error: null
}
}
if (!command || !isStructuredAgentSessionComposerCommand(text, controller.agent)) {
if (
!command ||
!isStructuredAgentSessionComposerCommand(text, controller.agent, controller.sessionCommands)
) {
return { handled: false, accepted: false, error: null }
}
if (command.name === 'clear' || command.name === 'compact') {