mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Support codex plan tool
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user