From aa5beb3af2c659f477be6f9ccd45c8fa203b35e6 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Fri, 24 Jul 2026 10:52:24 +0800 Subject: [PATCH] feat(codex): preserve native exploration actions (#1139) --- .../utils/appServerEventConverter.test.ts | 32 ++++++-- .../codex/utils/appServerEventConverter.ts | 13 ++- web/src/chat/codexCommandPresentation.test.ts | 67 ++++++++++++++++ web/src/chat/codexCommandPresentation.ts | 63 +++++++++++++++ web/src/chat/toolGroups.test.ts | 64 +++++++++++++++ web/src/chat/toolGroups.ts | 24 +++++- .../ToolCard/ToolGroupCard.test.tsx | 47 +++++++++++ web/src/components/ToolCard/ToolGroupCard.tsx | 79 +++++++++++++++++-- .../ToolCard/groupedPresentation.ts | 24 ++++-- web/src/lib/locales/en.ts | 6 ++ web/src/lib/locales/zh-CN.ts | 6 ++ 11 files changed, 400 insertions(+), 25 deletions(-) create mode 100644 web/src/chat/codexCommandPresentation.test.ts create mode 100644 web/src/chat/codexCommandPresentation.ts diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index a3c0bc48..06c9b2d2 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -132,27 +132,49 @@ describe('AppServerEventConverter', () => { it('maps command execution items and output deltas', () => { const converter = new AppServerEventConverter(); + const commandActions = [{ + type: 'listFiles', + command: 'ls', + path: '.' + }]; const started = converter.handleNotification('item/started', { - item: { id: 'cmd-1', type: 'commandExecution', command: 'ls' } + item: { + id: 'cmd-1', + type: 'commandExecution', + command: 'ls', + source: 'agent', + commandActions + } }); expect(started).toEqual([{ type: 'exec_command_begin', call_id: 'cmd-1', - command: 'ls' + command: 'ls', + command_actions: commandActions, + command_source: 'agent' }]); converter.handleNotification('item/commandExecution/outputDelta', { itemId: 'cmd-1', delta: 'ok' }); const completed = converter.handleNotification('item/completed', { - item: { id: 'cmd-1', type: 'commandExecution', exitCode: 0 } + item: { + id: 'cmd-1', + type: 'commandExecution', + aggregatedOutput: 'final output', + exitCode: 0, + durationMs: 42 + } }); expect(completed).toEqual([{ type: 'exec_command_end', call_id: 'cmd-1', command: 'ls', - output: 'ok', - exit_code: 0 + command_actions: commandActions, + command_source: 'agent', + output: 'final output', + exit_code: 0, + duration_ms: 42 }]); }); diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index ea8abe95..8ff7c4a8 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -1022,10 +1022,18 @@ export class AppServerEventConverter { const command = extractCommand(item.command ?? item.cmd ?? item.args); const cwd = asString(item.cwd ?? item.workingDirectory ?? item.working_directory); const autoApproved = asBoolean(item.autoApproved ?? item.auto_approved); + const commandActions = Array.isArray(item.commandActions) + ? item.commandActions + : Array.isArray(item.command_actions) + ? item.command_actions + : null; + const source = asString(item.source); const meta: Record = {}; if (command) meta.command = command; if (cwd) meta.cwd = cwd; if (autoApproved !== null) meta.auto_approved = autoApproved; + if (commandActions) meta.command_actions = commandActions; + if (source) meta.command_source = source; this.commandMeta.set(itemId, meta); events.push(scoped({ @@ -1037,10 +1045,12 @@ export class AppServerEventConverter { if (method === 'item/completed') { const meta = this.commandMeta.get(itemId) ?? {}; - const output = asString(item.output ?? item.result ?? item.stdout) ?? this.commandOutputBuffers.get(itemId); + const output = asString(item.aggregatedOutput ?? item.aggregated_output ?? item.output ?? item.result ?? item.stdout) + ?? this.commandOutputBuffers.get(itemId); const stderr = asString(item.stderr); const error = asString(item.error); const exitCode = asNumber(item.exitCode ?? item.exit_code ?? item.exitcode); + const durationMs = asNumber(item.durationMs ?? item.duration_ms); const status = asString(item.status); events.push(scoped({ @@ -1051,6 +1061,7 @@ export class AppServerEventConverter { ...(stderr ? { stderr } : {}), ...(error ? { error } : {}), ...(exitCode !== null ? { exit_code: exitCode } : {}), + ...(durationMs !== null ? { duration_ms: durationMs } : {}), ...(status ? { status } : {}) })); diff --git a/web/src/chat/codexCommandPresentation.test.ts b/web/src/chat/codexCommandPresentation.test.ts new file mode 100644 index 00000000..103fe536 --- /dev/null +++ b/web/src/chat/codexCommandPresentation.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { getCodexCommandActions, isCodexExplorationTool } from '@/chat/codexCommandPresentation' +import type { ToolCallBlock } from '@/chat/types' + +function block(input: unknown): ToolCallBlock { + return { + kind: 'tool-call', + id: 'tool-1', + localId: null, + createdAt: 1, + invokedAt: null, + tool: { + id: 'tool-1', + name: 'CodexBash', + state: 'completed', + input, + createdAt: 1, + startedAt: 1, + completedAt: 2, + execStartedAt: null, + execCompletedAt: null, + description: null + }, + children: [] + } +} + +describe('Codex command presentation metadata', () => { + it('accepts canonical app-server command actions', () => { + const tool = block({ + command_actions: [ + { type: 'read', command: 'cat a.ts', name: 'a.ts', path: '/repo/a.ts' }, + { type: 'search', command: 'rg token', query: 'token', path: 'src' } + ] + }) + + expect(getCodexCommandActions(tool)).toHaveLength(2) + expect(isCodexExplorationTool(tool)).toBe(true) + }) + + it('rejects malformed actions and does not classify unknown commands as exploration', () => { + const tool = block({ + command_actions: [ + { type: 'read', command: 'cat' }, + { type: 'unknown', command: 'bun test' } + ] + }) + + expect(getCodexCommandActions(tool)).toEqual([{ type: 'unknown', command: 'bun test' }]) + expect(isCodexExplorationTool(tool)).toBe(false) + }) + + it('keeps user shell commands out of agent exploration groups', () => { + const tool = block({ + command_source: 'userShell', + command_actions: [{ + type: 'read', + command: 'cat a.ts', + name: 'a.ts', + path: '/repo/a.ts' + }] + }) + + expect(getCodexCommandActions(tool)).toHaveLength(1) + expect(isCodexExplorationTool(tool)).toBe(false) + }) +}) diff --git a/web/src/chat/codexCommandPresentation.ts b/web/src/chat/codexCommandPresentation.ts new file mode 100644 index 00000000..2932b343 --- /dev/null +++ b/web/src/chat/codexCommandPresentation.ts @@ -0,0 +1,63 @@ +import type { ToolCallBlock } from '@/chat/types' + +export type CodexCommandAction = + | { type: 'read'; command: string; name: string; path: string } + | { type: 'listFiles'; command: string; path: string | null } + | { type: 'search'; command: string; query: string | null; path: string | null } + | { type: 'unknown'; command: string } + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function parseAction(value: unknown): CodexCommandAction | null { + if (!value || typeof value !== 'object') return null + const action = value as Record + const type = asString(action.type) + const command = asString(action.command) + if (!type || !command) return null + + if (type === 'read') { + const name = asString(action.name) + const path = asString(action.path) + return name && path ? { type, command, name, path } : null + } + if (type === 'listFiles') { + return { type, command, path: asString(action.path) } + } + if (type === 'search') { + return { + type, + command, + query: asString(action.query), + path: asString(action.path) + } + } + if (type === 'unknown') { + return { type, command } + } + return null +} + +export function getCodexCommandActions(block: ToolCallBlock): CodexCommandAction[] { + if (block.tool.name !== 'CodexBash' || !block.tool.input || typeof block.tool.input !== 'object') { + return [] + } + const input = block.tool.input as Record + const raw = input.command_actions ?? input.commandActions + if (!Array.isArray(raw)) return [] + return raw.map(parseAction).filter((action): action is CodexCommandAction => action !== null) +} + +export function isCodexExplorationTool(block: ToolCallBlock): boolean { + const input = block.tool.input && typeof block.tool.input === 'object' + ? block.tool.input as Record + : null + const source = asString(input?.command_source ?? input?.commandSource) + if (source?.toLowerCase() === 'usershell') return false + + const actions = getCodexCommandActions(block) + return actions.length > 0 && actions.every((action) => ( + action.type === 'read' || action.type === 'listFiles' || action.type === 'search' + )) +} diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts index 3e47b96a..19e5a7ce 100644 --- a/web/src/chat/toolGroups.test.ts +++ b/web/src/chat/toolGroups.test.ts @@ -169,6 +169,70 @@ describe('Codex activity headings', () => { }) describe('buildVisibleChatBlocks', () => { + it('renders one or more structured Codex exploration commands as an open exploration group', () => { + const read = makeToolBlock('codex-read', 'CodexBash', { + command: 'cat package.json', + command_source: 'agent', + command_actions: [{ + type: 'read', + command: 'cat package.json', + name: 'package.json', + path: '/repo/package.json' + }] + }) + const search = makeToolBlock('codex-search', 'CodexBash', { + command: 'rg nativeTitle web/src', + command_source: 'agent', + command_actions: [{ + type: 'search', + command: 'rg nativeTitle web/src', + query: 'nativeTitle', + path: 'web/src' + }] + }) + + const visible = buildVisibleChatBlocks([read, search], { hasMoreMessages: false }) + + expect(visible).toHaveLength(1) + expect(isToolGroupBlock(visible[0])).toBe(true) + if (!isToolGroupBlock(visible[0])) throw new Error('expected exploration group') + expect(visible[0].presentationMode).toBe('codex-exploration') + expect(visible[0].defaultOpen).toBe(true) + expect(visible[0].tools.map((tool) => tool.id)).toEqual(['codex-read', 'codex-search']) + }) + + it('keeps structured general Codex commands separate from exploration groups', () => { + const read = makeToolBlock('codex-read', 'CodexBash', { + command: 'cat package.json', + command_actions: [{ + type: 'read', + command: 'cat package.json', + name: 'package.json', + path: '/repo/package.json' + }] + }) + const test = makeToolBlock('codex-test', 'CodexBash', { + command: 'bun test', + command_actions: [{ type: 'unknown', command: 'bun test' }] + }) + const nextRead = makeToolBlock('codex-read-2', 'CodexBash', { + command: 'cat README.md', + command_actions: [{ + type: 'read', + command: 'cat README.md', + name: 'README.md', + path: '/repo/README.md' + }] + }) + + const visible = buildVisibleChatBlocks([read, test, nextRead], { hasMoreMessages: false }) + + expect(visible).toHaveLength(3) + expect(isToolGroupBlock(visible[0]) && visible[0].presentationMode).toBe('codex-exploration') + expect(visible[1]).toBe(test) + expect(isToolGroupBlock(visible[2]) && visible[2].presentationMode).toBe('codex-exploration') + }) + it('groups contiguous eligible root tool cards', () => { const visible = buildVisibleChatBlocks([ makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts index 56f3de2e..7268aa8c 100644 --- a/web/src/chat/toolGroups.ts +++ b/web/src/chat/toolGroups.ts @@ -1,4 +1,5 @@ import type { ChatBlock, ToolCallBlock } from '@/chat/types' +import { getCodexCommandActions, isCodexExplorationTool } from '@/chat/codexCommandPresentation' import { isSubagentToolName } from '@/chat/subagentTool' import { isAskUserQuestionToolName } from '@/components/ToolCard/askUserQuestion' import { isRequestUserInputToolName } from '@/components/ToolCard/requestUserInput' @@ -31,6 +32,7 @@ export type ToolGroupBlock = { historyState: 'complete' | 'needs-older-history' needsOlderHistory: boolean activityTitle?: string | null + presentationMode?: 'default' | 'codex-exploration' summary: ToolGroupSummary } @@ -206,9 +208,17 @@ export function isEligibleForToolGrouping(block: ToolCallBlock): boolean { if (PLAN_TOOL_NAMES.has(block.tool.name)) return false if (MILESTONE_TOOL_NAMES.has(block.tool.name)) return false if (isInteractiveToolBlock(block)) return false + if (block.tool.name === 'CodexBash' && getCodexCommandActions(block).length > 0) { + return isCodexExplorationTool(block) + } return true } +function getGroupingFamily(block: ToolCallBlock): 'default' | 'codex-exploration' | null { + if (!isEligibleForToolGrouping(block)) return null + return isCodexExplorationTool(block) ? 'codex-exploration' : 'default' +} + function createToolGroupId( tools: ToolCallBlock[], needsOlderHistory: boolean, @@ -240,7 +250,12 @@ export function buildVisibleChatBlocks( for (let index = 0; index < blocks.length; index += 1) { const block = blocks[index] - if (block.kind !== 'tool-call' || !isEligibleForToolGrouping(block)) { + if (block.kind !== 'tool-call') { + visibleBlocks.push(block) + continue + } + const groupingFamily = getGroupingFamily(block) + if (!groupingFamily) { visibleBlocks.push(block) continue } @@ -249,14 +264,14 @@ export function buildVisibleChatBlocks( let cursor = index + 1 while (cursor < blocks.length) { const candidate = blocks[cursor] - if (candidate.kind !== 'tool-call' || !isEligibleForToolGrouping(candidate)) { + if (candidate.kind !== 'tool-call' || getGroupingFamily(candidate) !== groupingFamily) { break } tools.push(candidate) cursor += 1 } - if (tools.length < 2) { + if (tools.length < 2 && groupingFamily !== 'codex-exploration') { visibleBlocks.push(block) continue } @@ -276,10 +291,11 @@ export function buildVisibleChatBlocks( firstToolId: tools[0].id, lastToolId: tools[tools.length - 1].id, tools, - defaultOpen: false, + defaultOpen: groupingFamily === 'codex-exploration', historyState: needsOlderHistory ? 'needs-older-history' : 'complete', needsOlderHistory, activityTitle, + presentationMode: groupingFamily, summary: summarizeToolGroup(tools) }) index = cursor - 1 diff --git a/web/src/components/ToolCard/ToolGroupCard.test.tsx b/web/src/components/ToolCard/ToolGroupCard.test.tsx index 32e2f188..3e94e0bd 100644 --- a/web/src/components/ToolCard/ToolGroupCard.test.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.test.tsx @@ -138,6 +138,53 @@ describe('ToolGroupCard', () => { expect(within(dialog).getAllByText('Result').length).toBeGreaterThan(0) }) + it('shows structured Codex exploration actions by default without a generic action count', () => { + const tools = [ + makeToolBlock('codex-read', 'CodexBash', { + command: 'cat package.json', + command_actions: [{ + type: 'read', + command: 'cat package.json', + name: 'package.json', + path: '/repo/package.json' + }] + }), + makeToolBlock('codex-search', 'CodexBash', { + command: 'rg nativeTitle web/src', + command_actions: [{ + type: 'search', + command: 'rg nativeTitle web/src', + query: 'nativeTitle', + path: 'web/src' + }] + }) + ] + const view = renderCard(makeGroup({ + tools, + defaultOpen: true, + presentationMode: 'codex-exploration', + summary: { + totalTools: 2, + countsByKind: { read: 0, search: 0, command: 2, mutation: 0, web: 0, other: 0 }, + fileTargets: [], + commandTargets: ['cat package.json', 'rg nativeTitle web/src'], + searchTargets: [], + urlTargets: [], + otherTargets: [], + errorCount: 0, + runningCount: 0, + pendingCount: 0, + } + })) + + expect(within(view.container).getByRole('button', { name: /^explored$/i })).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Read')).toBeInTheDocument() + expect(screen.getByText('package.json')).toBeInTheDocument() + expect(screen.getByText('Search')).toBeInTheDocument() + expect(screen.getByText('nativeTitle in web/src')).toBeInTheDocument() + expect(screen.queryByText('2 actions')).not.toBeInTheDocument() + }) + it('uses a neutral header for all-generic tool groups without duplicate counters', () => { const tools = Array.from({ length: 25 }, (_, index) => makeToolBlock(`tool-${index + 1}`, 'Tool', { name: `Tool ${index + 1}` })) const view = renderCard(makeGroup({ diff --git a/web/src/components/ToolCard/ToolGroupCard.tsx b/web/src/components/ToolCard/ToolGroupCard.tsx index d3818887..f65af51c 100644 --- a/web/src/components/ToolCard/ToolGroupCard.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ToolGroupBlock } from '@/chat/toolGroups' import type { ToolCallBlock } from '@/chat/types' +import { getCodexCommandActions, type CodexCommandAction } from '@/chat/codexCommandPresentation' import type { SessionMetadataSummary } from '@/types/api' import { useHappyChatContext } from '@/components/AssistantChat/context' import { ToolDetailDialogContent, ToolStatusIcon, toolStatusColorClass } from '@/components/ToolCard/ToolCard' import { getToolPresentation } from '@/components/ToolCard/knownTools' -import { formatGroupedHeaderSubtitle, formatGroupedHeaderTitle } from '@/components/ToolCard/groupedPresentation' +import { formatGroupedHeaderSubtitle, formatGroupedHeaderTitle, safeGroupedLabelValue } from '@/components/ToolCard/groupedPresentation' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { cn } from '@/lib/utils' @@ -99,6 +100,64 @@ function RowLabel(props: { block: ToolCallBlock; metadata: SessionMetadataSummar ) } +function basename(value: string): string { + return value.replace(/\\/g, '/').split('/').filter(Boolean).at(-1) ?? value +} + +function codexActionLabel( + action: CodexCommandAction, + t: (key: string, params?: Record) => string +): { title: string; detail: string | null } { + if (action.type === 'read') { + const detail = safeGroupedLabelValue(action.name) ?? safeGroupedLabelValue(action.path) + return { title: t('toolGroup.codex.read'), detail: detail ? basename(detail) : null } + } + if (action.type === 'listFiles') { + return { title: t('toolGroup.codex.list'), detail: safeGroupedLabelValue(action.path) } + } + if (action.type === 'search') { + const query = safeGroupedLabelValue(action.query) + const path = safeGroupedLabelValue(action.path) + return { + title: t('toolGroup.codex.search'), + detail: query && path + ? t('toolGroup.codex.searchIn', { query, path }) + : query ?? path + } + } + return { title: t('toolGroup.friendly.genericCommand'), detail: null } +} + +function CodexExplorationRows(props: { + tools: ToolCallBlock[] + onSelect: (toolId: string) => void +}) { + const { t } = useTranslation() + return props.tools.flatMap((tool) => ( + getCodexCommandActions(tool).map((action, index) => { + const label = codexActionLabel(action, t) + return ( + + ) + }) + )) +} + export function ToolGroupCard(props: { block: ToolGroupBlock metadata: SessionMetadataSummary | null @@ -219,7 +278,9 @@ export function ToolGroupCard(props: { }, [selectedTool, props.metadata, t]) const primaryTitle = formatGroupedHeaderTitle(props.block, t) - const subtitle = formatGroupedHeaderSubtitle(props.block, t) ?? formatActionSummary(props.block, t) + const subtitle = props.block.presentationMode === 'codex-exploration' + ? null + : formatGroupedHeaderSubtitle(props.block, t) ?? formatActionSummary(props.block, t) const fileCount = props.block.summary.fileTargets.length return ( @@ -249,10 +310,12 @@ export function ToolGroupCard(props: {
- + {props.block.presentationMode !== 'codex-exploration' ? ( + + ) : null} {props.block.summary.runningCount > 0 ? (
- {props.block.tools.map((tool) => { + {props.block.presentationMode === 'codex-exploration' ? ( + + ) : props.block.tools.map((tool) => { return (