feat(codex): preserve native exploration actions (#1139)

This commit is contained in:
SSU-WEI HUANG
2026-07-24 10:52:24 +08:00
committed by GitHub
parent 7ca4e71fa0
commit aa5beb3af2
11 changed files with 400 additions and 25 deletions
@@ -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)
})
})
+63
View File
@@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
: 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'
))
}
+64
View File
@@ -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' }),
+20 -4
View File
@@ -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
@@ -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({
+72 -7
View File
@@ -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, string | number>) => 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 (
<button
key={`${tool.id}:${index}`}
type="button"
className="flex min-w-0 items-start gap-2 rounded-lg px-2 py-1 text-left hover:bg-[var(--app-subtle-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
onClick={() => props.onSelect(tool.id)}
>
<span className="mt-1 text-xs text-[var(--app-hint)]"></span>
<span className="shrink-0 text-sm font-medium text-[var(--app-tool-card-accent)]">
{label.title}
</span>
{label.detail ? (
<span className="min-w-0 truncate text-sm text-[var(--app-fg)]">
{label.detail}
</span>
) : null}
</button>
)
})
))
}
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: {
</div>
<div className="flex shrink-0 items-center gap-2 self-center text-[var(--app-hint)]">
<SummaryBadge
className="bg-[var(--app-subtle-bg)] text-[var(--app-hint)]"
text={t('toolGroup.toolCount', { n: props.block.tools.length })}
/>
{props.block.presentationMode !== 'codex-exploration' ? (
<SummaryBadge
className="bg-[var(--app-subtle-bg)] text-[var(--app-hint)]"
text={t('toolGroup.toolCount', { n: props.block.tools.length })}
/>
) : null}
{props.block.summary.runningCount > 0 ? (
<SummaryBadge
className="bg-sky-500/10 text-sky-600"
@@ -285,7 +348,9 @@ export function ToolGroupCard(props: {
{open ? (
<CardContent className="px-3 pb-3 pt-1">
<div className="flex flex-col gap-2">
{props.block.tools.map((tool) => {
{props.block.presentationMode === 'codex-exploration' ? (
<CodexExplorationRows tools={props.block.tools} onSelect={setSelectedToolId} />
) : props.block.tools.map((tool) => {
return (
<button
key={tool.id}
@@ -1,5 +1,6 @@
import type { ToolGroupBlock } from '@/chat/toolGroups'
import type { ToolCallBlock } from '@/chat/types'
import { isCodexExplorationTool } from '@/chat/codexCommandPresentation'
import { getInputStringAny } from '@/lib/toolInputUtils'
type Translator = (key: string, params?: Record<string, string | number>) => string
@@ -30,7 +31,7 @@ function truncateLabel(value: string): string {
: normalized
}
function safeLabelValue(value: string | null): string | null {
export function safeGroupedLabelValue(value: string | null): string | null {
if (!value || SENSITIVE_TEXT_RE.test(value)) return null
return truncateLabel(value)
}
@@ -50,7 +51,7 @@ function getInspectionCommandTarget(command: string): string | null {
if (!parts || parts.length < 2) return null
const target = [...parts].reverse().find((part) => !part.startsWith('-') && !/^['"]?\d+(?:,\d+)?p['"]?$/.test(part))
if (!target || target === parts[0]) return null
return safeLabelValue(target.replace(/^['"]|['"]$/g, ''))
return safeGroupedLabelValue(target.replace(/^['"]|['"]$/g, ''))
}
function getSearchCommandPattern(command: string): string | null {
@@ -60,7 +61,7 @@ function getSearchCommandPattern(command: string): string | null {
if (executableIndex < 0) return null
for (let index = executableIndex + 1; index < parts.length; index += 1) {
const part = parts[index]
if (!part.startsWith('-')) return safeLabelValue(part.replace(/^['"]|['"]$/g, ''))
if (!part.startsWith('-')) return safeGroupedLabelValue(part.replace(/^['"]|['"]$/g, ''))
if (SEARCH_OPTIONS_WITH_VALUE.has(part)) index += 1
}
return null
@@ -156,13 +157,13 @@ function getPrimaryIntent(block: ToolGroupBlock): GroupedSummaryIntent {
function formatSpecificIntentTitle(block: ToolGroupBlock, intent: GroupedSummaryIntent, t: Translator): string | null {
const matching = block.tools.filter((tool) => inferGroupedSummaryIntent(tool) === intent)
const described = matching
.map((tool) => safeLabelValue(tool.tool.description))
.map((tool) => safeGroupedLabelValue(tool.tool.description))
.find((value): value is string => value !== null)
if (described) return described
if (intent === 'inspect-files' || intent === 'modify-files') {
for (const tool of matching) {
const target = safeLabelValue(getInputStringAny(tool.tool.input, ['file_path', 'path', 'file', 'filePath', 'notebook_path']))
const target = safeGroupedLabelValue(getInputStringAny(tool.tool.input, ['file_path', 'path', 'file', 'filePath', 'notebook_path']))
if (target) {
return t(intent === 'modify-files' ? 'toolGroup.friendly.editTarget' : 'toolGroup.friendly.inspectTarget', {
target: basename(target)
@@ -178,7 +179,7 @@ function formatSpecificIntentTitle(block: ToolGroupBlock, intent: GroupedSummary
if (intent === 'search-content') {
for (const tool of matching) {
const pattern = safeLabelValue(getInputStringAny(tool.tool.input, ['pattern', 'query']))
const pattern = safeGroupedLabelValue(getInputStringAny(tool.tool.input, ['pattern', 'query']))
if (pattern) return t('toolGroup.friendly.searchTarget', { target: pattern })
const command = getCommandText(tool.tool.input)
const commandPattern = command ? getSearchCommandPattern(command) : null
@@ -188,7 +189,7 @@ function formatSpecificIntentTitle(block: ToolGroupBlock, intent: GroupedSummary
if (intent === 'run-project-command') {
for (const tool of matching) {
const command = safeLabelValue(getCommandText(tool.tool.input))
const command = safeGroupedLabelValue(getCommandText(tool.tool.input))
if (command && SAFE_PROJECT_COMMAND_RE.test(command)) {
return t('toolGroup.friendly.runTarget', { target: command })
}
@@ -199,7 +200,12 @@ function formatSpecificIntentTitle(block: ToolGroupBlock, intent: GroupedSummary
}
export function formatGroupedHeaderTitle(block: ToolGroupBlock, t: Translator): string {
const activityTitle = safeLabelValue(block.activityTitle ?? null)
if (block.presentationMode === 'codex-exploration') {
return block.tools.some((tool) => tool.tool.state === 'running' || tool.tool.state === 'pending')
? t('toolGroup.codex.exploring')
: t('toolGroup.codex.explored')
}
const activityTitle = safeGroupedLabelValue(block.activityTitle ?? null)
if (activityTitle) return activityTitle
const primaryIntent = getPrimaryIntent(block)
const specificTitle = formatSpecificIntentTitle(block, primaryIntent, t)
@@ -211,6 +217,7 @@ export function formatGroupedHeaderTitle(block: ToolGroupBlock, t: Translator):
}
export function formatGroupedHeaderSubtitle(block: ToolGroupBlock, t: Translator): string | null {
if (block.presentationMode === 'codex-exploration') return null
const parts: string[] = []
if (block.summary.countsByKind.command > 0) {
@@ -236,5 +243,6 @@ export function formatGroupedHeaderSubtitle(block: ToolGroupBlock, t: Translator
}
export function formatGroupedRowLabel(tool: ToolCallBlock, t: Translator): string {
if (isCodexExplorationTool(tool)) return t('toolGroup.codex.explored')
return getIntentLabel(inferGroupedSummaryIntent(tool), t)
}
+6
View File
@@ -474,6 +474,12 @@ export default {
'toolGroup.rowStatus.running': 'Running',
'toolGroup.rowStatus.pending': 'Pending',
'toolGroup.rowStatus.error': 'Error',
'toolGroup.codex.exploring': 'Exploring',
'toolGroup.codex.explored': 'Explored',
'toolGroup.codex.read': 'Read',
'toolGroup.codex.list': 'List',
'toolGroup.codex.search': 'Search',
'toolGroup.codex.searchIn': '{query} in {path}',
// Composer buttons
'composer.settings': 'Settings',
+6
View File
@@ -478,6 +478,12 @@ export default {
'toolGroup.rowStatus.running': '运行中',
'toolGroup.rowStatus.pending': '等待中',
'toolGroup.rowStatus.error': '错误',
'toolGroup.codex.exploring': '正在探索',
'toolGroup.codex.explored': '已探索',
'toolGroup.codex.read': '读取',
'toolGroup.codex.list': '列出',
'toolGroup.codex.search': '搜索',
'toolGroup.codex.searchIn': '在 {path} 中搜索 {query}',
// Composer buttons
'composer.settings': '设置',