Support codex plan tool

This commit is contained in:
weishu
2026-03-08 12:11:19 +08:00
parent 5f8f33c998
commit 4f06cdfd7c
7 changed files with 318 additions and 127 deletions
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import type { ToolCallBlock } from '@/chat/types'
import { ChecklistList, extractTodoChecklist, extractUpdatePlanChecklist } from '@/components/ToolCard/checklist'
import { getToolPresentation } from '@/components/ToolCard/knownTools'
import { getToolViewComponent } from '@/components/ToolCard/views/_all'
import { UpdatePlanView } from '@/components/ToolCard/views/UpdatePlanView'
function makeUpdatePlanBlock(input: unknown, result?: unknown): ToolCallBlock {
return {
kind: 'tool-call',
id: 'tool-1',
localId: null,
createdAt: 0,
tool: {
id: 'tool-1',
name: 'update_plan',
state: 'completed',
input,
createdAt: 0,
startedAt: 0,
completedAt: 0,
description: null,
result
},
children: []
}
}
describe('extractUpdatePlanChecklist', () => {
it('prefers input.plan over result.plan', () => {
const items = extractUpdatePlanChecklist(
{
plan: [
{ step: 'Patch root cause', status: 'completed' }
]
},
{
plan: [
{ step: 'Result fallback', status: 'pending' }
]
}
)
expect(items).toEqual([
{ text: 'Patch root cause', status: 'completed', id: undefined }
])
})
it('falls back to result.plan when input.plan is absent', () => {
const items = extractUpdatePlanChecklist(
{},
{
plan: [
{ step: 'Re-run build validation', status: 'in_progress' }
]
}
)
expect(items).toEqual([
{ text: 'Re-run build validation', status: 'in_progress', id: undefined }
])
})
it('keeps valid steps and normalizes unknown status to pending', () => {
const items = extractUpdatePlanChecklist(
{
plan: [
{ step: 'Summarize fix', status: 'unknown_status' },
{ step: 123, status: 'completed' },
{ status: 'pending' }
]
},
null
)
expect(items).toEqual([
{ text: 'Summarize fix', status: 'pending', id: undefined }
])
})
})
describe('extractTodoChecklist', () => {
it('uses result.newTodos when input.todos is unavailable', () => {
const items = extractTodoChecklist(
null,
{
newTodos: [
{ id: 'todo-1', content: 'Ship it', status: 'completed' }
]
}
)
expect(items).toEqual([
{ id: 'todo-1', text: 'Ship it', status: 'completed' }
])
})
})
describe('update_plan tool presentation', () => {
it('shows plan title, step count, and expanded body when steps exist', () => {
const presentation = getToolPresentation({
toolName: 'update_plan',
input: {
plan: [
{ step: 'Reproduce web build failure', status: 'completed' },
{ step: 'Trace broken build path', status: 'completed' }
]
},
result: undefined,
childrenCount: 0,
description: null,
metadata: null
})
expect(presentation.title).toBe('Plan')
expect(presentation.subtitle).toBe('2 steps')
expect(presentation.minimal).toBe(false)
})
it('stays minimal when there are no valid steps', () => {
const presentation = getToolPresentation({
toolName: 'update_plan',
input: { plan: [{ status: 'completed' }] },
result: undefined,
childrenCount: 0,
description: null,
metadata: null
})
expect(presentation.subtitle).toBeNull()
expect(presentation.minimal).toBe(true)
})
})
describe('UpdatePlanView', () => {
it('renders checklist rows with status styling', () => {
render(
<UpdatePlanView
block={makeUpdatePlanBlock({
plan: [
{ step: 'Reproduce web build failure', status: 'completed' },
{ step: 'Trace broken build path', status: 'in_progress' },
{ step: 'Summarize fix', status: 'unknown_status' }
]
})}
metadata={null}
/>
)
const completed = screen.getByText(/Reproduce web build failure/)
const inProgress = screen.getByText(/Trace broken build path/)
const pending = screen.getByText(/Summarize fix/)
expect(completed).toBeInTheDocument()
expect(completed.className).toContain('line-through')
expect(inProgress.className).toContain('text-[var(--app-link)]')
expect(pending.className).toContain('text-[var(--app-hint)]')
})
it('is registered as the compact tool view', () => {
expect(getToolViewComponent('update_plan')).toBe(UpdatePlanView)
})
})
describe('ChecklistList', () => {
it('renders blank steps as empty placeholders', () => {
render(
<ChecklistList
items={[
{ text: ' ', status: 'pending' }
]}
/>
)
expect(screen.getByText(/\(empty\)/)).toBeInTheDocument()
})
})
+110
View File
@@ -0,0 +1,110 @@
import type { ReactNode } from 'react'
import { isObject } from '@hapi/protocol'
export type ChecklistStatus = 'pending' | 'in_progress' | 'completed'
export type ChecklistItem = {
id?: string
text: string
status: ChecklistStatus
}
function normalizeChecklistStatus(value: unknown): ChecklistStatus {
if (value === 'completed') return 'completed'
if (value === 'in_progress') return 'in_progress'
return 'pending'
}
function parseChecklistEntries(
entries: unknown,
opts: {
textKey: 'content' | 'step'
idKey?: string
}
): ChecklistItem[] {
if (!Array.isArray(entries)) return []
const items: ChecklistItem[] = []
for (const entry of entries) {
if (!isObject(entry)) continue
const text = entry[opts.textKey]
if (typeof text !== 'string') continue
const idValue = opts.idKey ? entry[opts.idKey] : undefined
items.push({
id: typeof idValue === 'string' ? idValue : undefined,
text,
status: normalizeChecklistStatus(entry.status)
})
}
return items
}
export function extractTodoChecklist(input: unknown, result: unknown): ChecklistItem[] {
if (isObject(input) && Array.isArray(input.todos)) {
const items = parseChecklistEntries(input.todos, {
textKey: 'content',
idKey: 'id'
})
if (items.length > 0) return items
}
if (isObject(result) && Array.isArray(result.newTodos)) {
return parseChecklistEntries(result.newTodos, {
textKey: 'content',
idKey: 'id'
})
}
return []
}
export function extractUpdatePlanChecklist(input: unknown, result: unknown): ChecklistItem[] {
if (isObject(input) && Object.prototype.hasOwnProperty.call(input, 'plan')) {
return parseChecklistEntries(input.plan, {
textKey: 'step'
})
}
if (isObject(result)) {
return parseChecklistEntries(result.plan, {
textKey: 'step'
})
}
return []
}
function checklistTone(item: ChecklistItem): string {
if (item.status === 'completed') return 'text-emerald-600 line-through'
if (item.status === 'in_progress') return 'text-[var(--app-link)]'
return 'text-[var(--app-hint)]'
}
function checklistIcon(item: ChecklistItem): ReactNode {
if (item.status === 'completed') return '☑'
return '☐'
}
export function ChecklistList(props: { items: ChecklistItem[]; emptyLabel?: string | null }) {
if (props.items.length === 0) {
return props.emptyLabel ? (
<div className="text-sm text-[var(--app-hint)]">{props.emptyLabel}</div>
) : null
}
return (
<div className="flex flex-col gap-1">
{props.items.map((item, idx) => {
const text = item.text.trim().length > 0 ? item.text.trim() : '(empty)'
return (
<div key={item.id ?? String(idx)} className={`text-sm ${checklistTone(item)}`}>
{checklistIcon(item)} {text}
</div>
)
})}
</div>
)
}
+15 -14
View File
@@ -2,6 +2,8 @@ import type { ReactNode } from 'react'
import type { SessionMetadataSummary } from '@/types/api'
import { isObject } from '@hapi/protocol'
import { BulbIcon, ClipboardIcon, EyeIcon, FileDiffIcon, GlobeIcon, MessageSquareIcon, PuzzleIcon, QuestionIcon, RocketIcon, SearchIcon, TerminalIcon, UsersIcon, WrenchIcon } from '@/components/ToolCard/icons'
import type { ChecklistItem } from '@/components/ToolCard/checklist'
import { extractTodoChecklist, extractUpdatePlanChecklist } from '@/components/ToolCard/checklist'
import { basename, resolveDisplayPath } from '@/utils/path'
import { getInputStringAny, truncate } from '@/lib/toolInputUtils'
@@ -19,6 +21,11 @@ function countLines(text: string): number {
return text.split('\n').length
}
function formatChecklistCount(items: ChecklistItem[], noun: string): string | null {
if (items.length === 0) return null
return `${items.length} ${noun}${items.length === 1 ? '' : 's'}`
}
function snakeToTitleWithSpaces(value: string): string {
return value
.split('_')
@@ -261,20 +268,14 @@ export const knownTools: Record<string, {
TodoWrite: {
icon: () => <BulbIcon className={DEFAULT_ICON_CLASS} />,
title: () => 'Todo list',
subtitle: (opts) => {
const todos = isObject(opts.input) && Array.isArray(opts.input.todos) ? opts.input.todos : null
if (todos && todos.length > 0) return `${todos.length} items`
const newTodos = isObject(opts.result) && Array.isArray(opts.result.newTodos) ? opts.result.newTodos : null
if (newTodos && newTodos.length > 0) return `${newTodos.length} items`
return null
},
minimal: (opts) => {
const todos = isObject(opts.input) && Array.isArray(opts.input.todos) ? opts.input.todos : null
if (todos && todos.length > 0) return false
const newTodos = isObject(opts.result) && Array.isArray(opts.result.newTodos) ? opts.result.newTodos : null
if (newTodos && newTodos.length > 0) return false
return true
}
subtitle: (opts) => formatChecklistCount(extractTodoChecklist(opts.input, opts.result), 'item'),
minimal: (opts) => extractTodoChecklist(opts.input, opts.result).length === 0
},
update_plan: {
icon: () => <ClipboardIcon className={DEFAULT_ICON_CLASS} />,
title: () => 'Plan',
subtitle: (opts) => formatChecklistCount(extractUpdatePlanChecklist(opts.input, opts.result), 'step'),
minimal: (opts) => extractUpdatePlanChecklist(opts.input, opts.result).length === 0
},
CodexReasoning: {
icon: () => <BulbIcon className={DEFAULT_ICON_CLASS} />,
@@ -1,62 +1,7 @@
import type { ToolViewProps } from '@/components/ToolCard/views/_all'
import { isObject } from '@hapi/protocol'
type TodoItem = {
id?: string
content?: string
status?: 'pending' | 'in_progress' | 'completed'
priority?: 'high' | 'medium' | 'low'
}
function extractTodos(input: unknown, result: unknown): TodoItem[] {
const todosFromInput = isObject(input) && Array.isArray(input.todos)
? input.todos.filter(isObject)
: []
if (todosFromInput.length > 0) {
return todosFromInput.map((t) => ({
id: typeof t.id === 'string' ? t.id : undefined,
content: typeof t.content === 'string' ? t.content : undefined,
status: t.status === 'pending' || t.status === 'in_progress' || t.status === 'completed' ? t.status : undefined,
priority: t.priority === 'high' || t.priority === 'medium' || t.priority === 'low' ? t.priority : undefined
}))
}
const newTodos = isObject(result) && Array.isArray(result.newTodos)
? result.newTodos.filter(isObject)
: []
return newTodos.map((t) => ({
id: typeof t.id === 'string' ? t.id : undefined,
content: typeof t.content === 'string' ? t.content : undefined,
status: t.status === 'pending' || t.status === 'in_progress' || t.status === 'completed' ? t.status : undefined,
priority: t.priority === 'high' || t.priority === 'medium' || t.priority === 'low' ? t.priority : undefined
}))
}
function todoTone(todo: TodoItem): string {
if (todo.status === 'completed') return 'text-emerald-600 line-through'
if (todo.status === 'in_progress') return 'text-[var(--app-link)]'
return 'text-[var(--app-hint)]'
}
function todoIcon(todo: TodoItem): string {
if (todo.status === 'completed') return '☑'
return '☐'
}
import { ChecklistList, extractTodoChecklist } from '@/components/ToolCard/checklist'
export function TodoWriteView(props: ToolViewProps) {
const todos = extractTodos(props.block.tool.input, props.block.tool.result)
if (todos.length === 0) return null
return (
<div className="flex flex-col gap-1">
{todos.map((todo, idx) => {
const text = todo.content?.trim() ? todo.content.trim() : '(empty)'
return (
<div key={todo.id ?? String(idx)} className={`text-sm ${todoTone(todo)}`}>
{todoIcon(todo)} {text}
</div>
)
})}
</div>
)
const todos = extractTodoChecklist(props.block.tool.input, props.block.tool.result)
return <ChecklistList items={todos} />
}
@@ -0,0 +1,7 @@
import type { ToolViewProps } from '@/components/ToolCard/views/_all'
import { ChecklistList, extractUpdatePlanChecklist } from '@/components/ToolCard/checklist'
export function UpdatePlanView(props: ToolViewProps) {
const steps = extractUpdatePlanChecklist(props.block.tool.input, props.block.tool.result)
return <ChecklistList items={steps} />
}
@@ -9,6 +9,7 @@ import { RequestUserInputView } from '@/components/ToolCard/views/RequestUserInp
import { ExitPlanModeView } from '@/components/ToolCard/views/ExitPlanModeView'
import { MultiEditFullView, MultiEditView } from '@/components/ToolCard/views/MultiEditView'
import { TodoWriteView } from '@/components/ToolCard/views/TodoWriteView'
import { UpdatePlanView } from '@/components/ToolCard/views/UpdatePlanView'
import { WriteView } from '@/components/ToolCard/views/WriteView'
export type ToolViewProps = {
@@ -23,6 +24,7 @@ export const toolViewRegistry: Record<string, ToolViewComponent> = {
MultiEdit: MultiEditView,
Write: WriteView,
TodoWrite: TodoWriteView,
update_plan: UpdatePlanView,
CodexDiff: CodexDiffCompactView,
AskUserQuestion: AskUserQuestionView,
ExitPlanMode: ExitPlanModeView,
+3 -55
View File
@@ -2,6 +2,7 @@ import type { ToolViewComponent, ToolViewProps } from '@/components/ToolCard/vie
import { isObject, safeStringify } from '@hapi/protocol'
import { CodeBlock } from '@/components/CodeBlock'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
import { ChecklistList, extractTodoChecklist } from '@/components/ToolCard/checklist'
import { basename, resolveDisplayPath } from '@/utils/path'
function parseToolUseError(message: string): { isToolUseError: boolean; errorMessage: string | null } {
@@ -489,66 +490,13 @@ const CodexDiffResultView: ToolViewComponent = (props: ToolViewProps) => {
)
}
type TodoItem = {
id?: string
content?: string
status?: 'pending' | 'in_progress' | 'completed'
priority?: 'high' | 'medium' | 'low'
}
function extractTodos(input: unknown, result: unknown): TodoItem[] {
const todosFromInput = isObject(input) && Array.isArray(input.todos)
? input.todos.filter(isObject)
: []
if (todosFromInput.length > 0) {
return todosFromInput.map((t) => ({
id: typeof t.id === 'string' ? t.id : undefined,
content: typeof t.content === 'string' ? t.content : undefined,
status: t.status === 'pending' || t.status === 'in_progress' || t.status === 'completed' ? t.status : undefined,
priority: t.priority === 'high' || t.priority === 'medium' || t.priority === 'low' ? t.priority : undefined
}))
}
const newTodos = isObject(result) && Array.isArray(result.newTodos)
? result.newTodos.filter(isObject)
: []
return newTodos.map((t) => ({
id: typeof t.id === 'string' ? t.id : undefined,
content: typeof t.content === 'string' ? t.content : undefined,
status: t.status === 'pending' || t.status === 'in_progress' || t.status === 'completed' ? t.status : undefined,
priority: t.priority === 'high' || t.priority === 'medium' || t.priority === 'low' ? t.priority : undefined
}))
}
function todoTone(todo: TodoItem): string {
if (todo.status === 'completed') return 'text-emerald-600 line-through'
if (todo.status === 'in_progress') return 'text-[var(--app-link)]'
return 'text-[var(--app-hint)]'
}
function todoIcon(todo: TodoItem): string {
if (todo.status === 'completed') return '☑'
return '☐'
}
const TodoWriteResultView: ToolViewComponent = (props: ToolViewProps) => {
const todos = extractTodos(props.block.tool.input, props.block.tool.result)
const todos = extractTodoChecklist(props.block.tool.input, props.block.tool.result)
if (todos.length === 0) {
return <div className="text-sm text-[var(--app-hint)]">{placeholderForState(props.block.tool.state)}</div>
}
return (
<div className="flex flex-col gap-1">
{todos.map((todo, idx) => {
const text = todo.content?.trim() ? todo.content.trim() : '(empty)'
return (
<div key={todo.id ?? String(idx)} className={`text-sm ${todoTone(todo)}`}>
{todoIcon(todo)} {text}
</div>
)
})}
</div>
)
return <ChecklistList items={todos} />
}
const GenericResultView: ToolViewComponent = (props: ToolViewProps) => {