mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor: rename plan to tasks and extract task entries from tool calls
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user