mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
fix(native-chat): render Claude structured chat through the same UI as Codex
Structured native chat is one shared, agent-agnostic component tree, but three Codex-hardcoded terms on that path made a Claude session render differently. - `showTurnStatus` was `agent === 'codex'`, which gated the whole structured presentation: the live tool-progress row, the completion check and activity grouping (`structuredActivityUi`), and the Thinking / Working for N / Worked for N status rows. Claude fell back to the legacy compact chrome. - `runtimeContext` was likewise Codex-only, so a Claude transcript rendered images as filename chips instead of previews. - The composer's structured slash menu always served the Codex catalog, ignoring `agent`. That disagreed with the dispatcher, which does branch per agent: Codex-only tokens offered to Claude missed the command guard and were sent to the model as literal prompt text, where Codex shows an error. The first two gates landed Codex-first (#17597, #18266) before the Claude structured lane existed; they were rollout scoping, not capability limits. Neither `useNativeChatTurnStatus` nor `useNativeChatImageRuntimeContext` has any agent-specific logic. The menu now reads `structuredSlashCommands(agent)`, the function the dispatcher already used, so both read one list. No new rendering logic: two gates removed and one existing shared function reused. There are now zero `agent === 'codex' | 'claude'` branches in any native-chat component.
This commit is contained in:
@@ -6,7 +6,6 @@ import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionsSurface
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles'
|
||||
import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache'
|
||||
import { clearNativeChatModelEnrichmentForTests } from './native-chat-session-option-enrichment'
|
||||
|
||||
@@ -27,6 +26,7 @@ const mocks = vi.hoisted(() => ({
|
||||
sessionOptionsSnapshot?: SessionOptionDescriptor[]
|
||||
attachDisabled?: boolean
|
||||
sendButtonDisabled?: boolean
|
||||
autocomplete?: { mode: string; items?: { kind: string; name: string }[] }
|
||||
} | null,
|
||||
modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'unknown',
|
||||
confirmationObserver: null as {
|
||||
@@ -90,10 +90,6 @@ vi.mock('./claude-model-switch-confirmation', () => ({
|
||||
createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) =>
|
||||
mocks.createClaudeModelSwitchConfirmationObserver(...args)
|
||||
}))
|
||||
vi.mock('../../../../shared/native-chat-agent-profiles', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof nativeChatAgentProfiles>()),
|
||||
getVerifiedNativeChatCommands: () => []
|
||||
}))
|
||||
vi.mock('@/lib/native-chat-telemetry', () => ({
|
||||
emitNativeChatMessageSent: vi.fn(),
|
||||
emitNativeChatPickerItemAccepted: vi.fn(),
|
||||
@@ -308,6 +304,43 @@ describe('NativeChatComposer', () => {
|
||||
expect(mocks.setDraft).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
// The structured slash menu must offer the running agent's own catalog. Offering
|
||||
// another agent's tokens sends them past the command guard as literal prompt text.
|
||||
it.each([
|
||||
['claude', 'compact', 'vim'],
|
||||
['codex', 'vim', 'help']
|
||||
] as const)('offers %s its own structured slash commands', (agent, offered, withheld) => {
|
||||
mocks.draft = '/'
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey={`tab-1:structured-${agent}`}
|
||||
targetPtyId={null}
|
||||
agent={agent}
|
||||
structuredTransport={{
|
||||
send: vi.fn(() => true),
|
||||
dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
|
||||
optionsSurface: {
|
||||
getSnapshot: () => [],
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
subscribe: () => () => {}
|
||||
},
|
||||
optionSnapshot: [],
|
||||
onError: vi.fn(),
|
||||
runtime: 'local'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const names = (mocks.fieldProps?.autocomplete?.items ?? [])
|
||||
.filter((item) => item.kind === 'command')
|
||||
.map((item) => item.name)
|
||||
expect(names).toContain(offered)
|
||||
expect(names).toContain('effort')
|
||||
expect(names).not.toContain(withheld)
|
||||
})
|
||||
|
||||
it('sends structured image attachments through the durable transport', async () => {
|
||||
mocks.draft = ''
|
||||
mocks.imageAttachments = [{ id: 'image-1', path: '/tmp/image.png' }]
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useAppStore } from '../../store'
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
|
||||
import { STRUCTURED_AGENT_SESSION_SLASH_COMMANDS } from '../../../../shared/structured-agent-session-composer'
|
||||
import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer'
|
||||
import {
|
||||
applyMentionSuggestion,
|
||||
EMPTY_HISTORY,
|
||||
@@ -111,9 +111,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
|
||||
const agentCommands = useMemo(
|
||||
() =>
|
||||
structuredTransport
|
||||
? STRUCTURED_AGENT_SESSION_SLASH_COMMANDS
|
||||
: getVerifiedNativeChatCommands(agent),
|
||||
structuredTransport ? structuredSlashCommands(agent) : getVerifiedNativeChatCommands(agent),
|
||||
[agent, structuredTransport]
|
||||
)
|
||||
const picker = useNativeChatPickerState({
|
||||
|
||||
@@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => ({
|
||||
messageListProps: null as null | {
|
||||
allowFileUriLinks?: boolean
|
||||
onLinkClick?: (...args: unknown[]) => void
|
||||
showTurnStatus?: boolean
|
||||
runtimeContext?: unknown
|
||||
},
|
||||
composerProps: null as null | { structuredTransport?: Record<string, unknown> },
|
||||
questionCardProps: null as NativeChatQuestionCardProps | null,
|
||||
@@ -190,6 +192,27 @@ describe('NativeChatStructuredSession', () => {
|
||||
expect(mocks.messageListProps?.onLinkClick).toBe(mocks.fileLinkClick)
|
||||
})
|
||||
|
||||
// Turn status and transcript image previews shipped Codex-first. Every
|
||||
// structured session renders through the same list, so neither is agent-gated.
|
||||
it.each(['codex', 'claude'] as const)(
|
||||
'renders the same structured transcript chrome for %s',
|
||||
(agent) => {
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
tabId="structured-tab-parity"
|
||||
sessionId="session-parity"
|
||||
target={{ kind: 'local' }}
|
||||
agent={agent}
|
||||
allowFileUriLinks
|
||||
/>
|
||||
)
|
||||
|
||||
expect(mocks.messageListProps?.showTurnStatus).toBe(true)
|
||||
expect(mocks.messageListProps?.runtimeContext).not.toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('routes a bare model command to the native option picker', async () => {
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
|
||||
@@ -166,10 +166,10 @@ export function NativeChatStructuredSession(
|
||||
expandSignal={false}
|
||||
fontScale={fontScale.scale}
|
||||
workingStartedAt={null}
|
||||
showTurnStatus={props.agent === 'codex'}
|
||||
showTurnStatus
|
||||
onLinkClick={fileLinkClick}
|
||||
allowFileUriLinks={fileLinkClick !== undefined}
|
||||
runtimeContext={props.agent === 'codex' ? imageRuntimeContext : undefined}
|
||||
runtimeContext={imageRuntimeContext}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isStructuredAgentSessionComposerCommand,
|
||||
structuredSlashCommands
|
||||
} from './structured-agent-session-composer'
|
||||
|
||||
describe('structuredSlashCommands', () => {
|
||||
// The composer menu and the dispatcher read this one list. When they disagreed,
|
||||
// a Claude session was offered Codex-only tokens that missed the command guard
|
||||
// and reached the model as literal prompt text instead of erroring.
|
||||
it.each(['codex', 'claude'] as const)('offers %s only commands it also accepts', (agent) => {
|
||||
const offered = structuredSlashCommands(agent)
|
||||
expect(offered.length).toBeGreaterThan(0)
|
||||
for (const command of offered) {
|
||||
expect(isStructuredAgentSessionComposerCommand(`/${command.name}`, agent)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('offers each agent its own catalog', () => {
|
||||
const claude = structuredSlashCommands('claude').map((command) => command.name)
|
||||
expect(claude).toContain('compact')
|
||||
expect(claude).not.toContain('vim')
|
||||
expect(structuredSlashCommands('codex').map((command) => command.name)).toContain('vim')
|
||||
})
|
||||
|
||||
it('offers effort to every structured agent', () => {
|
||||
for (const agent of ['codex', 'claude'] as const) {
|
||||
expect(structuredSlashCommands(agent).map((command) => command.name)).toContain('effort')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -35,7 +35,10 @@ function commandParts(text: string): { name: string; argument: string } | null {
|
||||
return match ? { name: match[1]!.toLowerCase(), argument: match[2]?.trim() ?? '' } : null
|
||||
}
|
||||
|
||||
function structuredSlashCommands(agent: AgentType): readonly SlashCommandSuggestion[] {
|
||||
/** The command catalog a structured session offers and accepts. The composer menu
|
||||
* and the dispatcher must read the same list, or a menu pick falls through the
|
||||
* command guard and reaches the model as literal prompt text. */
|
||||
export function structuredSlashCommands(agent: AgentType): readonly SlashCommandSuggestion[] {
|
||||
if (agent === 'codex') {
|
||||
return STRUCTURED_AGENT_SESSION_SLASH_COMMANDS
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user