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}>
|
<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={[
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user