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

View File

@@ -149,7 +149,7 @@ function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Dat
<View style={stylesheet.planHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<Text style={stylesheet.planTitle}>
📋 Plan ({update.entries.length} tasks)
📋 Tasks ({update.entries.length})
</Text>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
@@ -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 <PlanItem update={update} timestamp={activity.timestamp} />;
}

View File

@@ -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"}
</Text>
<View
style={[
@@ -996,7 +1001,9 @@ function PermissionRequestCard({
</View>
) : null}
<ToolCallDetailsContent display={toolCallDisplay} maxHeight={200} />
{!isPlanRequest && toolCallDisplay ? (
<ToolCallDetailsContent display={toolCallDisplay} maxHeight={200} />
) : null}
<Text
style={[

View File

@@ -877,7 +877,7 @@ interface TodoListCardProps {
disableOuterSpacing?: boolean;
}
function formatPlanTimestamp(timestamp: number): string {
function formatTasksTimestamp(timestamp: number): string {
try {
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
@@ -997,7 +997,7 @@ export const TodoListCard = memo(function TodoListCard({
);
const timestampLabel = useMemo(
() => formatPlanTimestamp(timestamp),
() => formatTasksTimestamp(timestamp),
[timestamp]
);
@@ -1013,7 +1013,7 @@ export const TodoListCard = memo(function TodoListCard({
<View style={todoListCardStylesheet.card}>
<View style={todoListCardStylesheet.header}>
<View style={todoListCardStylesheet.headerMeta}>
<Text style={todoListCardStylesheet.title}>Plan</Text>
<Text style={todoListCardStylesheet.title}>Tasks</Text>
<Text style={todoListCardStylesheet.timestamp}>
{timestampLabel}
</Text>
@@ -1032,7 +1032,7 @@ export const TodoListCard = memo(function TodoListCard({
<View style={todoListCardStylesheet.list}>
{items.length === 0 ? (
<Text style={todoListCardStylesheet.emptyText}>
No todo items shared yet.
No tasks shared yet.
</Text>
) : (
items.map((item, idx) => (

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 {
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);

View File

@@ -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,
{

View File

@@ -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 {

View File

@@ -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");

View File

@@ -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}`);

View File

@@ -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."
);
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<AgentMcpServerHandle> {
}
}
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");

View File

@@ -1159,10 +1159,6 @@ class ClaudeAgentSession implements AgentSession {
input,
options
): Promise<PermissionResult> => {
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" });
}