From 0283880bb1ce1d28e651f8604d356014db4552e0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:48:34 -0700 Subject: [PATCH] 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. --- .../__mocks__/mobile-prompt-controller.ts | 5 +-- .../mobile-native-chat-controller-contract.ts | 2 ++ ...obile-structured-agent-session-contract.ts | 3 ++ ...mobile-structured-composer-command.test.ts | 13 ++++++++ .../mobile-structured-composer-command.ts | 8 ++++- ...-mobile-native-chat-structured-composer.ts | 17 +++------- .../use-mobile-native-chat-controller.test.ts | 3 ++ .../use-mobile-native-chat-controller.ts | 33 +++++++++---------- .../use-mobile-native-chat-session-lane.ts | 32 +++++++++++------- .../session/use-mobile-session-attachments.ts | 5 +-- .../use-mobile-structured-agent-session.ts | 1 + ...bile-structured-native-chat-send-bridge.ts | 17 ++++++++-- ...use-mobile-structured-send-with-outcome.ts | 6 +++- ...se-native-chat-structured-composer-send.ts | 2 +- ...tive-chat-structured-composer-transport.ts | 1 + .../structured-agent-session-composer.test.ts | 19 +++++++++++ .../structured-agent-session-composer.ts | 26 +++++++++++---- 17 files changed, 134 insertions(+), 59 deletions(-) diff --git a/mobile/src/session/__mocks__/mobile-prompt-controller.ts b/mobile/src/session/__mocks__/mobile-prompt-controller.ts index 20fb46add65..87f21239cac 100644 --- a/mobile/src/session/__mocks__/mobile-prompt-controller.ts +++ b/mobile/src/session/__mocks__/mobile-prompt-controller.ts @@ -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: () => ({ diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 4bbcc47e646..ad4d117397d 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -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 nativeChatAcceptsImages: boolean } diff --git a/mobile/src/session/mobile-structured-agent-session-contract.ts b/mobile/src/session/mobile-structured-agent-session-contract.ts index 005723bcc44..236e48420bc 100644 --- a/mobile/src/session/mobile-structured-agent-session-contract.ts +++ b/mobile/src/session/mobile-structured-agent-session-contract.ts @@ -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. diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts index bf41efc55c6..76fffa44af6 100644 --- a/mobile/src/session/mobile-structured-composer-command.test.ts +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -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() diff --git a/mobile/src/session/mobile-structured-composer-command.ts b/mobile/src/session/mobile-structured-composer-command.ts index ead086c5045..c90d091dcb9 100644 --- a/mobile/src/session/mobile-structured-composer-command.ts +++ b/mobile/src/session/mobile-structured-composer-command.ts @@ -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) { diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-structured-composer.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-structured-composer.ts index 89b71d40c80..a538f8106ac 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-structured-composer.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-structured-composer.ts @@ -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 /** 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( diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 5473ebc672f..2d5ea171644 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -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 })) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 158ec1a0e6b..8a1af933a95 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -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 } } diff --git a/mobile/src/session/use-mobile-native-chat-session-lane.ts b/mobile/src/session/use-mobile-native-chat-session-lane.ts index f3b320d4dcf..b1fb1138f2f 100644 --- a/mobile/src/session/use-mobile-native-chat-session-lane.ts +++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts @@ -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[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 session: ReturnType + sessionCommands: ReturnType } { 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 } } diff --git a/mobile/src/session/use-mobile-session-attachments.ts b/mobile/src/session/use-mobile-session-attachments.ts index 2e5bb595243..4f9814cdc84 100644 --- a/mobile/src/session/use-mobile-session-attachments.ts +++ b/mobile/src/session/use-mobile-session-attachments.ts @@ -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, diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index adc55580d06..791915909bb 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -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 }) diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts index 6609fdfa9af..834f997c2a3 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -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 ( diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index 01f3df3663e..d6fe941dbeb 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -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> | 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 diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index 8e7ad36f2f3..a3b315023df 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -47,7 +47,7 @@ function isNativeChatStructuredHostCommand( transport: NativeChatStructuredComposerTransport ): boolean { return ( - isStructuredAgentSessionComposerCommand(text, agent) || + isStructuredAgentSessionComposerCommand(text, agent, transport.sessionCommands) || (transport.threadGoal !== undefined && isStructuredAgentSessionGoalCommand(text)) ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts index 7d31bf3aee6..aad904a609e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -86,6 +86,7 @@ export function useNativeChatStructuredComposerTransport(args: { return true }, setOption: controller.setStructuredOption, + sessionCommands: controller.sessionCommands, conversationCommands: controller.conversationCommands, runConversationCommand: controller.runConversationCommand, ...(setThreadGoalObjective ? { setThreadGoalObjective } : {}) diff --git a/src/shared/structured-agent-session-composer.test.ts b/src/shared/structured-agent-session-composer.test.ts index d809a59bc1d..d3585d6bbf4 100644 --- a/src/shared/structured-agent-session-composer.test.ts +++ b/src/shared/structured-agent-session-composer.test.ts @@ -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( diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index b0d4b6eb841..98bfa427cf4 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -37,6 +37,8 @@ export type StructuredAgentSessionComposerOptions = { snapshot: readonly SessionOptionDescriptor[] invokeAction: (id: string) => Promise setOption: (id: string, value: SessionOptionValue) => Promise + /** 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') {