From 30f1682ca1dad86eb658126117b998435ccf4893 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Feb 2026 22:09:17 +0700 Subject: [PATCH] refactor: rename plan to tasks and extract task entries from tool calls --- .../app/src/components/agent-activity.tsx | 4 +- .../app/src/components/agent-stream-view.tsx | 21 +++-- packages/app/src/components/message.tsx | 8 +- packages/app/src/types/stream.test.ts | 59 ++++++++++++++ packages/app/src/types/stream.ts | 27 +++++++ packages/app/src/utils/tool-call-parsers.ts | 79 +++++++++++++++++++ .../src/server/agent/activity-curator.test.ts | 4 +- .../src/server/agent/activity-curator.ts | 2 +- .../agent/providers/claude-agent.test.ts | 21 +++-- .../server/agent/providers/claude-agent.ts | 17 ---- 10 files changed, 201 insertions(+), 41 deletions(-) diff --git a/packages/app/src/components/agent-activity.tsx b/packages/app/src/components/agent-activity.tsx index 635790af9..f8ffb165e 100644 --- a/packages/app/src/components/agent-activity.tsx +++ b/packages/app/src/components/agent-activity.tsx @@ -149,7 +149,7 @@ function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Dat {formatTimestamp(timestamp)} - 📋 Plan ({update.entries.length} tasks) + 📋 Tasks ({update.entries.length}) {isExpanded ? '▼' : '▶'} @@ -217,7 +217,7 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) { const activity = item as AgentActivity; const update = activity.update; - // Plan + // Tasks if (update.kind === 'plan') { return ; } diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index f2c4565ec..f9c388a7d 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -757,7 +757,10 @@ function PermissionRequestCard({ const { theme } = useUnistyles(); const { request } = permission; - const title = request.title ?? request.name ?? "Permission Required"; + const isPlanRequest = request.kind === "plan"; + const title = isPlanRequest + ? "Plan" + : request.title ?? request.name ?? "Permission Required"; const description = request.description ?? ""; const planMarkdown = useMemo(() => { @@ -778,10 +781,12 @@ function PermissionRequestCard({ return undefined; }, [request]); - const toolCallDisplay = useMemo( - () => parseToolCallDisplay(request.name ?? "unknown", request.input, null), - [request.name, request.input] - ); + const toolCallDisplay = useMemo(() => { + if (isPlanRequest) { + return null; + } + return parseToolCallDisplay(request.name ?? "unknown", request.input, null); + }, [isPlanRequest, request.name, request.input]); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); @@ -978,7 +983,7 @@ function PermissionRequestCard({ { color: theme.colors.foregroundMuted }, ]} > - Proposed Plan + {isPlanRequest ? "Plan" : "Proposed Plan"} ) : null} - + {!isPlanRequest && toolCallDisplay ? ( + + ) : null} formatPlanTimestamp(timestamp), + () => formatTasksTimestamp(timestamp), [timestamp] ); @@ -1013,7 +1013,7 @@ export const TodoListCard = memo(function TodoListCard({ - Plan + Tasks {timestampLabel} @@ -1032,7 +1032,7 @@ export const TodoListCard = memo(function TodoListCard({ {items.length === 0 ? ( - No todo items shared yet. + No tasks shared yet. ) : ( items.map((item, idx) => ( diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index a91084aaa..5e69a3828 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -65,6 +65,26 @@ function toolTimeline( }; } +function toolTimelineWithInput(options: { + provider: TestAgentProvider; + name: string; + status: string; + callId?: string; + input: unknown; +}): AgentStreamEventPayload { + return { + type: "timeline", + provider: options.provider, + item: { + type: "tool_call", + name: options.name, + status: options.status, + callId: options.callId ?? options.name, + input: options.input, + }, + }; +} + function todoTimeline(items: { text: string; completed: boolean }[]): AgentStreamEventPayload { return { type: "timeline", @@ -664,6 +684,44 @@ function testTodoListConsolidation() { ); } +function testTodoWriteToolCallCreatesTodoList() { + const timestamp = new Date("2025-01-01T12:32:00Z"); + const event = toolTimelineWithInput({ + provider: "claude", + name: "TodoWrite", + status: "completed", + input: { + todos: [ + { content: "First task", status: "pending" }, + { content: "Second task", status: "completed" }, + ], + }, + }); + + const state = reduceStreamUpdate([], event, timestamp); + const todoEntries = state.filter( + (item): item is TodoListItem => item.kind === "todo_list" + ); + const toolCalls = state.filter((item) => item.kind === "tool_call"); + + assert.strictEqual(todoEntries.length, 1); + assert.strictEqual( + toolCalls.length, + 0, + "TodoWrite should render as a task list, not a tool call" + ); + assert.ok( + todoEntries[0].items.some( + (entry) => entry.text === "First task" && !entry.completed + ) + ); + assert.ok( + todoEntries[0].items.some( + (entry) => entry.text === "Second task" && entry.completed + ) + ); +} + function testTimelineIdStabilityAfterRemovals() { const timestamp = new Date('2025-01-01T12:35:00Z'); @@ -1038,6 +1096,7 @@ describe('stream timeline reducers', () => { it('hydrates user messages and deduplicates optimistic/live entries', testUserMessageHydration); it('retains hydrated user messages across providers', testHydratedUserMessagesPersist); it('consolidates todo list updates', testTodoListConsolidation); + it('renders TodoWrite as a task list', testTodoWriteToolCallCreatesTodoList); it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals); it('deduplicates live tool call entries', testToolCallDeduplicationLive); it('deduplicates hydrated tool call entries', testToolCallDeduplicationHydrated); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index a9f0b8b93..4c340f0c1 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -4,6 +4,7 @@ import { extractCommandDetails, extractEditEntries, extractReadEntries, + extractTaskEntriesFromToolCall, type CommandDetails, type EditEntry, type ReadEntry, @@ -754,6 +755,32 @@ export function reduceStreamUpdate( case "reasoning": return appendThought(state, item.text, timestamp); case "tool_call": { + const normalizedToolName = item.name + .trim() + .replace(/[.\s-]+/g, "_") + .toLowerCase(); + if (event.provider === "claude" && normalizedToolName === "exitplanmode") { + // ExitPlanMode is rendered via the plan permission prompt; avoid duplicating it in the timeline. + break; + } + + const tasks = extractTaskEntriesFromToolCall( + item.name, + item.input + ); + if (tasks) { + nextState = appendTodoList( + state, + event.provider, + tasks.map((entry) => ({ + text: entry.text, + completed: entry.completed, + })), + timestamp + ); + break; + } + nextState = appendAgentToolCall( state, { diff --git a/packages/app/src/utils/tool-call-parsers.ts b/packages/app/src/utils/tool-call-parsers.ts index 07a832851..05146598b 100644 --- a/packages/app/src/utils/tool-call-parsers.ts +++ b/packages/app/src/utils/tool-call-parsers.ts @@ -1224,6 +1224,85 @@ export function parseToolCallDisplay(toolName: string, input: unknown, result: u return ToolCallDisplaySchema.parse({ toolName, input, result }); } +// ---- Task Extraction (cross-provider) ---- + +export type TaskStatus = "pending" | "in_progress" | "completed"; + +export type TaskEntry = { + text: string; + status: TaskStatus; + completed: boolean; +}; + +const TaskStatusSchema = z.enum(["pending", "in_progress", "completed"]); + +const ClaudeTodoWriteSchema = z.object({ + todos: z.array( + z.object({ + content: z.string(), + status: TaskStatusSchema, + activeForm: z.string().optional(), + }) + ), +}); + +const UpdatePlanSchema = z.object({ + plan: z.array( + z.object({ + step: z.string(), + status: TaskStatusSchema.catch("pending"), + }) + ), +}); + +function normalizeToolName(toolName: string): string { + return toolName.trim().replace(/[.\s-]+/g, "_").toLowerCase(); +} + +export function extractTaskEntriesFromToolCall( + toolName: string, + input: unknown +): TaskEntry[] | null { + const normalized = normalizeToolName(toolName); + + // Claude's plan mode uses ExitPlanMode for the approval prompt; it is not a task list. + if (normalized === "exitplanmode") { + return null; + } + + if (normalized === "todowrite" || normalized === "todo_write") { + const parsed = ClaudeTodoWriteSchema.safeParse(input); + if (!parsed.success) { + return null; + } + return parsed.data.todos.map((todo) => { + const status = todo.status; + const text = todo.activeForm?.trim() || todo.content.trim(); + return { + text: text.length ? text : todo.content, + status, + completed: status === "completed", + }; + }); + } + + if (normalized === "update_plan") { + const parsed = UpdatePlanSchema.safeParse(input); + if (!parsed.success) { + return null; + } + return parsed.data.plan + .map((entry) => ({ + text: entry.step.trim(), + status: entry.status, + completed: entry.status === "completed", + })) + .filter((entry) => entry.text.length > 0); + } + + return null; +} + // ---- Principal Parameter Extraction ---- // Re-export from server to avoid drift export { diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 01d0b66b1..a0e10b615 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -66,7 +66,7 @@ describe("curateAgentActivity", () => { expect(result).toBe("[ListFiles]"); }); - test("serializes todo items as [Plan]", () => { + test("serializes todo items as [Tasks]", () => { const timeline: AgentTimelineItem[] = [ { type: "todo", @@ -80,7 +80,7 @@ describe("curateAgentActivity", () => { const result = curateAgentActivity(timeline); - expect(result).toContain("[Plan]"); + expect(result).toContain("[Tasks]"); expect(result).toContain("- [x] Read the file"); expect(result).toContain("- [ ] Fix the bug"); expect(result).toContain("- [ ] Run tests"); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index bfa29bce9..806ad6e2e 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -137,7 +137,7 @@ export function curateAgentActivity( } case "todo": flushBuffers(lines, buffers); - lines.push("[Plan]"); + lines.push("[Tasks]"); for (const entry of item.items) { const checkbox = entry.completed ? "[x]" : "[ ]"; lines.push(`- ${checkbox} ${entry.text}`); diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index f2dbf64b6..88238a1ce 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -865,15 +865,19 @@ async function startAgentMcpServer(): Promise { "Devise a plan to create a file named dummy.txt containing the word plan-test. After planning, proceed to execute your plan." ); - let sawPlan = false; + let capturedPlan: string | null = null; for await (const event of events) { await autoApprove(session, event); - if ( - event.type === "timeline" && - event.item.type === "todo" && - event.item.items.some((entry) => entry.text.includes("dummy.txt")) - ) { - sawPlan = true; + if (event.type === "permission_requested" && event.request.kind === "plan") { + const planFromMetadata = + typeof event.request.metadata?.planText === "string" + ? event.request.metadata.planText + : null; + const planFromInput = + typeof (event.request.input as any)?.plan === "string" + ? ((event.request.input as any)?.plan as string) + : null; + capturedPlan = planFromMetadata ?? planFromInput ?? capturedPlan; } if (event.type === "turn_completed" || event.type === "turn_failed") { @@ -881,7 +885,8 @@ async function startAgentMcpServer(): Promise { } } - expect(sawPlan).toBe(true); + expect(capturedPlan).not.toBeNull(); + expect(capturedPlan?.includes("dummy.txt")).toBe(true); expect(await session.getCurrentMode()).toBe("acceptEdits"); const filePath = path.join(cwd, "dummy.txt"); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 91b81720a..5eb63ce98 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1159,10 +1159,6 @@ class ClaudeAgentSession implements AgentSession { input, options ): Promise => { - if (toolName === "ExitPlanMode") { - this.emitPlanTodoItems(input); - } - const requestId = `permission-${randomUUID()}`; const metadata: AgentMetadata = {}; if (options.toolUseID) { @@ -1234,19 +1230,6 @@ class ClaudeAgentSession implements AgentSession { }); }; - private emitPlanTodoItems(input: AgentMetadata) { - const planText = typeof input.plan === "string" ? input.plan : JSON.stringify(input); - const todoItems = planText - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((text) => ({ text, completed: false })); - this.enqueueTimeline({ - type: "todo", - items: todoItems.length > 0 ? todoItems : [{ text: planText, completed: false }], - }); - } - private enqueueTimeline(item: AgentTimelineItem) { this.pushEvent({ type: "timeline", item, provider: "claude" }); }