refactor: rename plan to tasks and extract task entries from tool calls

This commit is contained in:
Mohamed Boudra
2026-02-02 22:09:17 +07:00
parent 303a82d1c7
commit 30f1682ca1
10 changed files with 201 additions and 41 deletions
@@ -149,7 +149,7 @@ function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Dat
<View style={stylesheet.planHeaderLeft}> <View style={stylesheet.planHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text> <Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<Text style={stylesheet.planTitle}> <Text style={stylesheet.planTitle}>
📋 Plan ({update.entries.length} tasks) 📋 Tasks ({update.entries.length})
</Text> </Text>
</View> </View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text> <Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
@@ -217,7 +217,7 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
const activity = item as AgentActivity; const activity = item as AgentActivity;
const update = activity.update; const update = activity.update;
// Plan // Tasks
if (update.kind === 'plan') { if (update.kind === 'plan') {
return <PlanItem update={update} timestamp={activity.timestamp} />; return <PlanItem update={update} timestamp={activity.timestamp} />;
} }
@@ -757,7 +757,10 @@ function PermissionRequestCard({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { request } = permission; 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 description = request.description ?? "";
const planMarkdown = useMemo(() => { const planMarkdown = useMemo(() => {
@@ -778,10 +781,12 @@ function PermissionRequestCard({
return undefined; return undefined;
}, [request]); }, [request]);
const toolCallDisplay = useMemo( const toolCallDisplay = useMemo(() => {
() => parseToolCallDisplay(request.name ?? "unknown", request.input, null), if (isPlanRequest) {
[request.name, request.input] return null;
); }
return parseToolCallDisplay(request.name ?? "unknown", request.input, null);
}, [isPlanRequest, request.name, request.input]);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
@@ -978,7 +983,7 @@ function PermissionRequestCard({
{ color: theme.colors.foregroundMuted }, { color: theme.colors.foregroundMuted },
]} ]}
> >
Proposed Plan {isPlanRequest ? "Plan" : "Proposed Plan"}
</Text> </Text>
<View <View
style={[ style={[
@@ -996,7 +1001,9 @@ function PermissionRequestCard({
</View> </View>
) : null} ) : null}
<ToolCallDetailsContent display={toolCallDisplay} maxHeight={200} /> {!isPlanRequest && toolCallDisplay ? (
<ToolCallDetailsContent display={toolCallDisplay} maxHeight={200} />
) : null}
<Text <Text
style={[ style={[
+4 -4
View File
@@ -877,7 +877,7 @@ interface TodoListCardProps {
disableOuterSpacing?: boolean; disableOuterSpacing?: boolean;
} }
function formatPlanTimestamp(timestamp: number): string { function formatTasksTimestamp(timestamp: number): string {
try { try {
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
hour: "numeric", hour: "numeric",
@@ -997,7 +997,7 @@ export const TodoListCard = memo(function TodoListCard({
); );
const timestampLabel = useMemo( const timestampLabel = useMemo(
() => formatPlanTimestamp(timestamp), () => formatTasksTimestamp(timestamp),
[timestamp] [timestamp]
); );
@@ -1013,7 +1013,7 @@ export const TodoListCard = memo(function TodoListCard({
<View style={todoListCardStylesheet.card}> <View style={todoListCardStylesheet.card}>
<View style={todoListCardStylesheet.header}> <View style={todoListCardStylesheet.header}>
<View style={todoListCardStylesheet.headerMeta}> <View style={todoListCardStylesheet.headerMeta}>
<Text style={todoListCardStylesheet.title}>Plan</Text> <Text style={todoListCardStylesheet.title}>Tasks</Text>
<Text style={todoListCardStylesheet.timestamp}> <Text style={todoListCardStylesheet.timestamp}>
{timestampLabel} {timestampLabel}
</Text> </Text>
@@ -1032,7 +1032,7 @@ export const TodoListCard = memo(function TodoListCard({
<View style={todoListCardStylesheet.list}> <View style={todoListCardStylesheet.list}>
{items.length === 0 ? ( {items.length === 0 ? (
<Text style={todoListCardStylesheet.emptyText}> <Text style={todoListCardStylesheet.emptyText}>
No todo items shared yet. No tasks shared yet.
</Text> </Text>
) : ( ) : (
items.map((item, idx) => ( items.map((item, idx) => (
+59
View File
@@ -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 { function todoTimeline(items: { text: string; completed: boolean }[]): AgentStreamEventPayload {
return { return {
type: "timeline", 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() { function testTimelineIdStabilityAfterRemovals() {
const timestamp = new Date('2025-01-01T12:35:00Z'); 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('hydrates user messages and deduplicates optimistic/live entries', testUserMessageHydration);
it('retains hydrated user messages across providers', testHydratedUserMessagesPersist); it('retains hydrated user messages across providers', testHydratedUserMessagesPersist);
it('consolidates todo list updates', testTodoListConsolidation); it('consolidates todo list updates', testTodoListConsolidation);
it('renders TodoWrite as a task list', testTodoWriteToolCallCreatesTodoList);
it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals); it('keeps timeline ids stable after list shrinkage', testTimelineIdStabilityAfterRemovals);
it('deduplicates live tool call entries', testToolCallDeduplicationLive); it('deduplicates live tool call entries', testToolCallDeduplicationLive);
it('deduplicates hydrated tool call entries', testToolCallDeduplicationHydrated); it('deduplicates hydrated tool call entries', testToolCallDeduplicationHydrated);
+27
View File
@@ -4,6 +4,7 @@ import {
extractCommandDetails, extractCommandDetails,
extractEditEntries, extractEditEntries,
extractReadEntries, extractReadEntries,
extractTaskEntriesFromToolCall,
type CommandDetails, type CommandDetails,
type EditEntry, type EditEntry,
type ReadEntry, type ReadEntry,
@@ -754,6 +755,32 @@ export function reduceStreamUpdate(
case "reasoning": case "reasoning":
return appendThought(state, item.text, timestamp); return appendThought(state, item.text, timestamp);
case "tool_call": { 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( nextState = appendAgentToolCall(
state, state,
{ {
@@ -1224,6 +1224,85 @@ export function parseToolCallDisplay(toolName: string, input: unknown, result: u
return ToolCallDisplaySchema.parse({ toolName, input, result }); 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 ---- // ---- Principal Parameter Extraction ----
// Re-export from server to avoid drift // Re-export from server to avoid drift
export { export {
@@ -66,7 +66,7 @@ describe("curateAgentActivity", () => {
expect(result).toBe("[ListFiles]"); expect(result).toBe("[ListFiles]");
}); });
test("serializes todo items as [Plan]", () => { test("serializes todo items as [Tasks]", () => {
const timeline: AgentTimelineItem[] = [ const timeline: AgentTimelineItem[] = [
{ {
type: "todo", type: "todo",
@@ -80,7 +80,7 @@ describe("curateAgentActivity", () => {
const result = curateAgentActivity(timeline); const result = curateAgentActivity(timeline);
expect(result).toContain("[Plan]"); expect(result).toContain("[Tasks]");
expect(result).toContain("- [x] Read the file"); expect(result).toContain("- [x] Read the file");
expect(result).toContain("- [ ] Fix the bug"); expect(result).toContain("- [ ] Fix the bug");
expect(result).toContain("- [ ] Run tests"); expect(result).toContain("- [ ] Run tests");
@@ -137,7 +137,7 @@ export function curateAgentActivity(
} }
case "todo": case "todo":
flushBuffers(lines, buffers); flushBuffers(lines, buffers);
lines.push("[Plan]"); lines.push("[Tasks]");
for (const entry of item.items) { for (const entry of item.items) {
const checkbox = entry.completed ? "[x]" : "[ ]"; const checkbox = entry.completed ? "[x]" : "[ ]";
lines.push(`- ${checkbox} ${entry.text}`); lines.push(`- ${checkbox} ${entry.text}`);
@@ -865,15 +865,19 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
"Devise a plan to create a file named dummy.txt containing the word plan-test. After planning, proceed to execute your plan." "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) { for await (const event of events) {
await autoApprove(session, event); await autoApprove(session, event);
if ( if (event.type === "permission_requested" && event.request.kind === "plan") {
event.type === "timeline" && const planFromMetadata =
event.item.type === "todo" && typeof event.request.metadata?.planText === "string"
event.item.items.some((entry) => entry.text.includes("dummy.txt")) ? event.request.metadata.planText
) { : null;
sawPlan = true; 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") { if (event.type === "turn_completed" || event.type === "turn_failed") {
@@ -881,7 +885,8 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
} }
} }
expect(sawPlan).toBe(true); expect(capturedPlan).not.toBeNull();
expect(capturedPlan?.includes("dummy.txt")).toBe(true);
expect(await session.getCurrentMode()).toBe("acceptEdits"); expect(await session.getCurrentMode()).toBe("acceptEdits");
const filePath = path.join(cwd, "dummy.txt"); const filePath = path.join(cwd, "dummy.txt");
@@ -1159,10 +1159,6 @@ class ClaudeAgentSession implements AgentSession {
input, input,
options options
): Promise<PermissionResult> => { ): Promise<PermissionResult> => {
if (toolName === "ExitPlanMode") {
this.emitPlanTodoItems(input);
}
const requestId = `permission-${randomUUID()}`; const requestId = `permission-${randomUUID()}`;
const metadata: AgentMetadata = {}; const metadata: AgentMetadata = {};
if (options.toolUseID) { 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) { private enqueueTimeline(item: AgentTimelineItem) {
this.pushEvent({ type: "timeline", item, provider: "claude" }); this.pushEvent({ type: "timeline", item, provider: "claude" });
} }