From 774a8f947105bea3e2a5e3c2f961323613c79392 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Thu, 15 Jan 2026 22:35:05 -0500 Subject: [PATCH 01/17] feat: display per-subtask cost breakdown in todos list - Add SubtaskDetail interface with id, name, tokens, cost, status fields - Add buildSubtaskDetails() function to aggregate task costs - Pass childDetails in taskWithAggregatedCosts message - Integrate subtask cost display into TodoListDisplay component - Add fuzzy matching to match todo items with subtask details - Display token count and cost next to matching todo items - Add 16 tests for TodoListDisplay with subtask cost functionality Related to: #5376 --- packages/types/src/vscode-extension-host.ts | 8 + src/core/webview/ClineProvider.ts | 26 +- .../__tests__/aggregateTaskCosts.spec.ts | 211 +++++++++++- src/core/webview/aggregateTaskCosts.ts | 49 +++ webview-ui/src/components/chat/ChatView.tsx | 19 ++ .../src/components/chat/SubtaskCostList.tsx | 123 +++++++ webview-ui/src/components/chat/TaskHeader.tsx | 13 +- .../src/components/chat/TodoListDisplay.tsx | 79 ++++- .../chat/__tests__/TodoListDisplay.spec.tsx | 308 ++++++++++++++++++ 9 files changed, 830 insertions(+), 6 deletions(-) create mode 100644 webview-ui/src/components/chat/SubtaskCostList.tsx create mode 100644 webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f3116141f04..1448c08c809 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -188,6 +188,14 @@ export interface ExtensionMessage { totalCost: number ownCost: number childrenCost: number + childDetails?: { + id: string + name: string + tokens: number + cost: number + status: "active" | "completed" | "delegated" + hasNestedChildren: boolean + }[] } historyItem?: HistoryItem } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 33fa12ca78c..4f09371cb47 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -47,7 +47,12 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, } from "@roo-code/types" -import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" +import { + aggregateTaskCostsRecursive, + buildSubtaskDetails, + type AggregatedCosts, + type SubtaskDetail, +} from "./aggregateTaskCosts" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud" @@ -1717,7 +1722,24 @@ export class ClineProvider return result.historyItem }) - return { historyItem, aggregatedCosts } + // Build subtask details if there are children + let childDetails: SubtaskDetail[] | undefined + if (aggregatedCosts.childBreakdown && Object.keys(aggregatedCosts.childBreakdown).length > 0) { + childDetails = await buildSubtaskDetails(aggregatedCosts.childBreakdown, async (id: string) => { + const result = await this.getTaskWithId(id) + return result.historyItem + }) + } + + return { + historyItem, + aggregatedCosts: { + totalCost: aggregatedCosts.totalCost, + ownCost: aggregatedCosts.ownCost, + childrenCost: aggregatedCosts.childrenCost, + childDetails, + }, + } } async showTaskWithId(id: string) { diff --git a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts index ffb35f5e48c..eeffcebf477 100644 --- a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts +++ b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { aggregateTaskCostsRecursive } from "../aggregateTaskCosts.js" +import { aggregateTaskCostsRecursive, buildSubtaskDetails } from "../aggregateTaskCosts.js" import type { HistoryItem } from "@roo-code/types" +import type { AggregatedCosts } from "../aggregateTaskCosts.js" describe("aggregateTaskCostsRecursive", () => { let consoleWarnSpy: ReturnType @@ -324,3 +325,211 @@ describe("aggregateTaskCostsRecursive", () => { expect(result.totalCost).toBe(2.0) }) }) + +describe("buildSubtaskDetails", () => { + it("should build subtask details from child breakdown", async () => { + const childBreakdown: { [childId: string]: AggregatedCosts } = { + "child-1": { + ownCost: 0.5, + childrenCost: 0, + totalCost: 0.5, + }, + "child-2": { + ownCost: 0.3, + childrenCost: 0.2, + totalCost: 0.5, + }, + } + + const mockHistory: Record = { + "child-1": { + id: "child-1", + task: "First subtask", + tokensIn: 100, + tokensOut: 50, + status: "completed", + } as unknown as HistoryItem, + "child-2": { + id: "child-2", + task: "Second subtask with nested children", + tokensIn: 200, + tokensOut: 100, + status: "active", + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await buildSubtaskDetails(childBreakdown, getTaskHistory) + + expect(result).toHaveLength(2) + + const child1 = result.find((d) => d.id === "child-1") + expect(child1).toBeDefined() + expect(child1!.name).toBe("First subtask") + expect(child1!.tokens).toBe(150) // 100 + 50 + expect(child1!.cost).toBe(0.5) + expect(child1!.status).toBe("completed") + expect(child1!.hasNestedChildren).toBe(false) + + const child2 = result.find((d) => d.id === "child-2") + expect(child2).toBeDefined() + expect(child2!.name).toBe("Second subtask with nested children") + expect(child2!.tokens).toBe(300) // 200 + 100 + expect(child2!.cost).toBe(0.5) + expect(child2!.status).toBe("active") + expect(child2!.hasNestedChildren).toBe(true) // childrenCost > 0 + }) + + it("should truncate long task names to 50 characters", async () => { + const longTaskName = + "This is a very long task name that exceeds fifty characters and should be truncated with ellipsis" + const childBreakdown: { [childId: string]: AggregatedCosts } = { + "child-1": { + ownCost: 1.0, + childrenCost: 0, + totalCost: 1.0, + }, + } + + const mockHistory: Record = { + "child-1": { + id: "child-1", + task: longTaskName, + tokensIn: 100, + tokensOut: 50, + status: "completed", + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await buildSubtaskDetails(childBreakdown, getTaskHistory) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe("This is a very long task name that exceeds fift...") + expect(result[0].name.length).toBe(50) + }) + + it("should not truncate task names at or under 50 characters", async () => { + const exactlyFiftyChars = "12345678901234567890123456789012345678901234567890" // exactly 50 chars + const childBreakdown: { [childId: string]: AggregatedCosts } = { + "child-1": { + ownCost: 1.0, + childrenCost: 0, + totalCost: 1.0, + }, + } + + const mockHistory: Record = { + "child-1": { + id: "child-1", + task: exactlyFiftyChars, + tokensIn: 100, + tokensOut: 50, + status: "completed", + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await buildSubtaskDetails(childBreakdown, getTaskHistory) + + expect(result[0].name).toBe(exactlyFiftyChars) + expect(result[0].name.length).toBe(50) + }) + + it("should skip children with missing history", async () => { + const childBreakdown: { [childId: string]: AggregatedCosts } = { + "child-1": { + ownCost: 0.5, + childrenCost: 0, + totalCost: 0.5, + }, + "missing-child": { + ownCost: 0.3, + childrenCost: 0, + totalCost: 0.3, + }, + } + + const mockHistory: Record = { + "child-1": { + id: "child-1", + task: "Existing subtask", + tokensIn: 100, + tokensOut: 50, + status: "completed", + } as unknown as HistoryItem, + // missing-child has no history + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await buildSubtaskDetails(childBreakdown, getTaskHistory) + + expect(result).toHaveLength(1) + expect(result[0].id).toBe("child-1") + }) + + it("should handle empty child breakdown", async () => { + const childBreakdown: { [childId: string]: AggregatedCosts } = {} + + const getTaskHistory = vi.fn(async () => undefined) + + const result = await buildSubtaskDetails(childBreakdown, getTaskHistory) + + expect(result).toHaveLength(0) + }) + + it("should default status to completed when undefined", async () => { + const childBreakdown: { [childId: string]: AggregatedCosts } = { + "child-1": { + ownCost: 0.5, + childrenCost: 0, + totalCost: 0.5, + }, + } + + const mockHistory: Record = { + "child-1": { + id: "child-1", + task: "Subtask without status", + tokensIn: 100, + tokensOut: 50, + // status is undefined + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await buildSubtaskDetails(childBreakdown, getTaskHistory) + + expect(result[0].status).toBe("completed") + }) + + it("should handle undefined token values", async () => { + const childBreakdown: { [childId: string]: AggregatedCosts } = { + "child-1": { + ownCost: 0.5, + childrenCost: 0, + totalCost: 0.5, + }, + } + + const mockHistory: Record = { + "child-1": { + id: "child-1", + task: "Subtask without tokens", + // tokensIn and tokensOut are undefined + status: "completed", + } as unknown as HistoryItem, + } + + const getTaskHistory = vi.fn(async (id: string) => mockHistory[id]) + + const result = await buildSubtaskDetails(childBreakdown, getTaskHistory) + + expect(result[0].tokens).toBe(0) + }) +}) diff --git a/src/core/webview/aggregateTaskCosts.ts b/src/core/webview/aggregateTaskCosts.ts index 3100b2a65e7..f85d7176a95 100644 --- a/src/core/webview/aggregateTaskCosts.ts +++ b/src/core/webview/aggregateTaskCosts.ts @@ -1,5 +1,17 @@ import type { HistoryItem } from "@roo-code/types" +/** + * Detailed information about a subtask for UI display + */ +export interface SubtaskDetail { + id: string // Task ID + name: string // First 50 chars of task description + tokens: number // tokensIn + tokensOut + cost: number // Aggregated total cost + status: "active" | "completed" | "delegated" + hasNestedChildren: boolean // Has its own subtasks +} + export interface AggregatedCosts { ownCost: number // This task's own API costs childrenCost: number // Sum of all direct children costs (recursive) @@ -8,6 +20,7 @@ export interface AggregatedCosts { // Optional detailed breakdown [childId: string]: AggregatedCosts } + childDetails?: SubtaskDetail[] // Detailed subtask info for UI display } /** @@ -63,3 +76,39 @@ export async function aggregateTaskCostsRecursive( return result } + +/** + * Truncate a task name to a maximum length, adding ellipsis if needed + */ +function truncateTaskName(task: string, maxLength: number): string { + if (task.length <= maxLength) return task + return task.substring(0, maxLength - 3) + "..." +} + +/** + * Build subtask details from child breakdown and history items + * for displaying in the UI's expandable subtask list + */ +export async function buildSubtaskDetails( + childBreakdown: { [childId: string]: AggregatedCosts }, + getTaskHistory: (id: string) => Promise, +): Promise { + const details: SubtaskDetail[] = [] + + for (const [childId, costs] of Object.entries(childBreakdown)) { + const history = await getTaskHistory(childId) + + if (history) { + details.push({ + id: childId, + name: truncateTaskName(history.task, 50), + tokens: (history.tokensIn || 0) + (history.tokensOut || 0), + cost: costs.totalCost, + status: history.status || "completed", + hasNestedChildren: costs.childrenCost > 0, + }) + } + } + + return details +} diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 81f6cbebf66..ab232a072d2 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -49,6 +49,19 @@ import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" import { Cloud } from "lucide-react" +/** + * Detailed information about a subtask for UI display. + * Matches the SubtaskDetail interface from backend aggregateTaskCosts.ts + */ +interface SubtaskDetail { + id: string // Task ID + name: string // First 50 chars of task description + tokens: number // tokensIn + tokensOut + cost: number // Aggregated total cost + status: "active" | "completed" | "delegated" + hasNestedChildren: boolean // Has its own subtasks +} + export interface ChatViewProps { isHidden: boolean showAnnouncement: boolean @@ -174,6 +187,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction >(new Map()) @@ -1490,6 +1504,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction void +} + +interface SubtaskRowProps { + subtask: SubtaskDetail + isLast: boolean + onClick?: () => void + t: (key: string, options?: Record) => string +} + +const statusColors: Record = { + active: "bg-vscode-testing-iconQueued", + completed: "bg-vscode-testing-iconPassed", + delegated: "bg-vscode-testing-iconSkipped", +} + +const SubtaskRow = memo(({ subtask, isLast, onClick, t }: SubtaskRowProps) => { + return ( + + ) +}) + +SubtaskRow.displayName = "SubtaskRow" + +export const SubtaskCostList = memo(({ subtasks, onSubtaskClick }: SubtaskCostListProps) => { + const { t } = useTranslation("chat") + const [isExpanded, setIsExpanded] = useState(false) + + if (!subtasks || subtasks.length === 0) { + return null + } + + return ( +
+ {/* Collapsible Header */} + + + {/* Expanded Subtask List */} + {isExpanded && ( +
+ {subtasks.map((subtask, index) => ( + onSubtaskClick?.(subtask.id)} + t={t} + /> + ))} +
+ )} +
+ ) +}) + +SubtaskCostList.displayName = "SubtaskCostList" + +export default SubtaskCostList diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 5dca11b9634..133e6ff4ef2 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -34,6 +34,7 @@ import { ContextWindowProgress } from "./ContextWindowProgress" import { Mention } from "./Mention" import { TodoListDisplay } from "./TodoListDisplay" import { LucideIconButton } from "./LucideIconButton" +import type { SubtaskDetail } from "./SubtaskCostList" export interface TaskHeaderProps { task: ClineMessage @@ -45,6 +46,7 @@ export interface TaskHeaderProps { aggregatedCost?: number hasSubtasks?: boolean costBreakdown?: string + subtaskDetails?: SubtaskDetail[] contextTokens: number buttonsDisabled: boolean handleCondenseContext: (taskId: string) => void @@ -61,6 +63,7 @@ const TaskHeader = ({ aggregatedCost, hasSubtasks, costBreakdown, + subtaskDetails, contextTokens, buttonsDisabled, handleCondenseContext, @@ -472,7 +475,15 @@ const TaskHeader = ({ )} {/* Todo list - always shown at bottom when todos exist */} - {hasTodos && } + {hasTodos && ( + { + vscode.postMessage({ type: "showTaskWithId", text: subtaskId }) + }} + /> + )} diff --git a/webview-ui/src/components/chat/TodoListDisplay.tsx b/webview-ui/src/components/chat/TodoListDisplay.tsx index f2dbbc4d80d..06d8185ef89 100644 --- a/webview-ui/src/components/chat/TodoListDisplay.tsx +++ b/webview-ui/src/components/chat/TodoListDisplay.tsx @@ -3,6 +3,10 @@ import { t } from "i18next" import { ArrowRight, Check, ListChecks, SquareDashed } from "lucide-react" import { useState, useRef, useMemo, useEffect } from "react" +import { formatLargeNumber } from "@src/utils/format" + +import type { SubtaskDetail } from "./SubtaskCostList" + type TodoStatus = "completed" | "in_progress" | "pending" function getTodoIcon(status: TodoStatus | null) { @@ -16,7 +20,58 @@ function getTodoIcon(status: TodoStatus | null) { } } -export function TodoListDisplay({ todos }: { todos: any[] }) { +/** + * Normalizes a string for comparison by: + * - Converting to lowercase + * - Removing extra whitespace + * - Trimming quotes + * - Stripping common task prefixes (Subtask N:, ## Task:, Task N:) + * - Removing trailing ellipsis from truncated strings + */ +function normalizeForComparison(str: string): string { + return ( + str + .toLowerCase() + .replace(/\s+/g, " ") + .trim() + .replace(/^["']|["']$/g, "") + // Strip common task prefixes: "Subtask N:", "## Task:", "Task N:", etc. + .replace(/^(subtask\s*\d*\s*:|##\s*task\s*:|task\s*\d*\s*:)\s*/i, "") + // Remove trailing ellipsis from truncated strings + .replace(/\.{3}$/, "") + .trim() + ) +} + +/** + * Match a todo content string to a subtask detail using fuzzy matching. + * Returns the matching SubtaskDetail if found, undefined otherwise. + */ +function findMatchingSubtask(todoContent: string, subtaskDetails: SubtaskDetail[]): SubtaskDetail | undefined { + const normalizedTodo = normalizeForComparison(todoContent) + + // Try exact match first + const exactMatch = subtaskDetails.find((s) => normalizeForComparison(s.name) === normalizedTodo) + if (exactMatch) { + return exactMatch + } + + // Try partial match - check if one contains the other + const partialMatch = subtaskDetails.find((s) => { + const normalizedSubtask = normalizeForComparison(s.name) + return normalizedTodo.includes(normalizedSubtask) || normalizedSubtask.includes(normalizedTodo) + }) + + return partialMatch +} + +export interface TodoListDisplayProps { + todos: any[] + subtaskDetails?: SubtaskDetail[] + onSubtaskClick?: (subtaskId: string) => void +} + +export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoListDisplayProps) { const [isCollapsed, setIsCollapsed] = useState(true) const ulRef = useRef(null) const itemRefs = useRef<(HTMLLIElement | null)[]>([]) @@ -82,6 +137,11 @@ export function TodoListDisplay({ todos }: { todos: any[] }) {
    {todos.map((todo: any, idx: number) => { const icon = getTodoIcon(todo.status as TodoStatus) + const matchingSubtask = subtaskDetails + ? findMatchingSubtask(todo.content, subtaskDetails) + : undefined + const isClickable = matchingSubtask && onSubtaskClick + return (
  • {icon} - {todo.content} + onSubtaskClick(matchingSubtask.id) : undefined}> + {todo.content} + + {/* Token count and cost display */} + {matchingSubtask && ( + + + {formatLargeNumber(matchingSubtask.tokens)} + + + ${matchingSubtask.cost.toFixed(2)} + + + )}
  • ) })} diff --git a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx new file mode 100644 index 00000000000..be84b9d28fc --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx @@ -0,0 +1,308 @@ +import { describe, it, expect, vi } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" + +import { TodoListDisplay } from "../TodoListDisplay" +import type { SubtaskDetail } from "../SubtaskCostList" + +// Mock i18next +vi.mock("i18next", () => ({ + t: (key: string, options?: Record) => { + if (key === "chat:todo.complete") return `${options?.total} to-dos done` + if (key === "chat:todo.partial") return `${options?.completed} of ${options?.total} to-dos done` + return key + }, +})) + +// Mock format utility +vi.mock("@src/utils/format", () => ({ + formatLargeNumber: (num: number) => { + if (num >= 1e3) return `${(num / 1e3).toFixed(1)}k` + return num.toString() + }, +})) + +describe("TodoListDisplay", () => { + const baseTodos = [ + { id: "1", content: "Task 1: Change background colour", status: "completed" }, + { id: "2", content: "Task 2: Add timestamp to bottom", status: "completed" }, + { id: "3", content: "Task 3: Pending task", status: "pending" }, + ] + + const subtaskDetails: SubtaskDetail[] = [ + { + id: "subtask-1", + name: "Task 1: Change background colour", + tokens: 95400, + cost: 0.22, + status: "completed", + hasNestedChildren: false, + }, + { + id: "subtask-2", + name: "Task 2: Add timestamp to bottom", + tokens: 95000, + cost: 0.24, + status: "completed", + hasNestedChildren: false, + }, + ] + + describe("basic rendering", () => { + it("should render nothing when todos is empty", () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it("should render collapsed view by default", () => { + render() + // Should show the first incomplete task in collapsed view + expect(screen.getByText("Task 3: Pending task")).toBeInTheDocument() + }) + + it("should expand when header is clicked", () => { + render() + const header = screen.getByText("Task 3: Pending task") + fireEvent.click(header) + + // After expanding, should show all tasks + expect(screen.getByText("Task 1: Change background colour")).toBeInTheDocument() + expect(screen.getByText("Task 2: Add timestamp to bottom")).toBeInTheDocument() + expect(screen.getByText("Task 3: Pending task")).toBeInTheDocument() + }) + + it("should show completion count when all tasks are complete", () => { + const completedTodos = [ + { id: "1", content: "Task 1", status: "completed" }, + { id: "2", content: "Task 2", status: "completed" }, + ] + render() + expect(screen.getByText("2 to-dos done")).toBeInTheDocument() + }) + }) + + describe("subtask cost display", () => { + it("should display tokens and cost when subtaskDetails are provided and match", () => { + render() + + // Expand to see the items + const header = screen.getByText("Task 3: Pending task") + fireEvent.click(header) + + // Check for formatted token counts + expect(screen.getByText("95.4k")).toBeInTheDocument() + expect(screen.getByText("95.0k")).toBeInTheDocument() + + // Check for costs + expect(screen.getByText("$0.22")).toBeInTheDocument() + expect(screen.getByText("$0.24")).toBeInTheDocument() + }) + + it("should not display tokens/cost for unmatched todos", () => { + render() + + // Expand to see the items + const header = screen.getByText("Task 3: Pending task") + fireEvent.click(header) + + // The pending task has no matching subtask, should not show cost + const listItems = screen.getAllByRole("listitem") + const pendingItem = listItems.find((item) => item.textContent?.includes("Task 3: Pending task")) + expect(pendingItem).toBeDefined() + expect(pendingItem?.textContent).not.toContain("$") + }) + + it("should not display tokens/cost when subtaskDetails is undefined", () => { + render() + + // Expand to see the items + const header = screen.getByText("Task 3: Pending task") + fireEvent.click(header) + + // No cost should be displayed + expect(screen.queryByText("$0.22")).not.toBeInTheDocument() + expect(screen.queryByText("$0.24")).not.toBeInTheDocument() + }) + + it("should not display tokens/cost when subtaskDetails is empty array", () => { + render() + + // Expand to see the items + const header = screen.getByText("Task 3: Pending task") + fireEvent.click(header) + + // No cost should be displayed + expect(screen.queryByText("$0.22")).not.toBeInTheDocument() + }) + }) + + describe("fuzzy matching", () => { + it("should match todos with slightly different names (partial match)", () => { + const todosWithSlightlyDifferentNames = [ + { id: "1", content: "Change background colour", status: "completed" }, // Missing "Task 1:" prefix + ] + const subtaskWithFullName: SubtaskDetail[] = [ + { + id: "subtask-1", + name: "Change background colour", // Exact partial match + tokens: 50000, + cost: 0.15, + status: "completed", + hasNestedChildren: false, + }, + ] + + render() + + // Expand + const header = screen.getByText("1 to-dos done") + fireEvent.click(header) + + // Should find the match + expect(screen.getByText("$0.15")).toBeInTheDocument() + }) + + it("should handle case-insensitive matching", () => { + const todosLowercase = [{ id: "1", content: "change background colour", status: "completed" }] + const subtaskUppercase: SubtaskDetail[] = [ + { + id: "subtask-1", + name: "Change Background Colour", + tokens: 50000, + cost: 0.15, + status: "completed", + hasNestedChildren: false, + }, + ] + + render() + + // Expand + const header = screen.getByText("1 to-dos done") + fireEvent.click(header) + + // Should find the match despite case difference + expect(screen.getByText("$0.15")).toBeInTheDocument() + }) + + it("should match when todo has 'Subtask N:' prefix and subtask has '## Task:' prefix", () => { + const todosWithSubtaskPrefix = [ + { id: "1", content: "Subtask 1: Change background colour to light purple", status: "completed" }, + ] + const subtaskWithMarkdownPrefix: SubtaskDetail[] = [ + { + id: "subtask-1", + name: "## Task: Change Background Colour to Light Purp...", + tokens: 95400, + cost: 0.22, + status: "completed", + hasNestedChildren: false, + }, + ] + + render() + + // Expand + const header = screen.getByText("1 to-dos done") + fireEvent.click(header) + + // Should find the match despite different prefixes + expect(screen.getByText("$0.22")).toBeInTheDocument() + }) + + it("should match when subtask name is truncated with ellipsis", () => { + const todos = [{ id: "1", content: "Task 1: Add timestamp to the bottom of the page", status: "completed" }] + const subtaskWithTruncation: SubtaskDetail[] = [ + { + id: "subtask-1", + name: "## Task: Add Timestamp to the Bottom of the Pag...", + tokens: 95000, + cost: 0.24, + status: "completed", + hasNestedChildren: false, + }, + ] + + render() + + // Expand + const header = screen.getByText("1 to-dos done") + fireEvent.click(header) + + // Should find the match despite truncation + expect(screen.getByText("$0.24")).toBeInTheDocument() + }) + + it("should strip 'Subtask N:' prefix from todo content", () => { + const todosWithNumberedPrefix = [ + { id: "1", content: "Subtask 2: Do something important", status: "completed" }, + ] + const subtaskWithoutPrefix: SubtaskDetail[] = [ + { + id: "subtask-1", + name: "Do something important", + tokens: 50000, + cost: 0.15, + status: "completed", + hasNestedChildren: false, + }, + ] + + render() + + // Expand + const header = screen.getByText("1 to-dos done") + fireEvent.click(header) + + // Should find the match after stripping prefix + expect(screen.getByText("$0.15")).toBeInTheDocument() + }) + }) + + describe("click handler", () => { + it("should call onSubtaskClick when a matched todo is clicked", () => { + const onSubtaskClick = vi.fn() + render( + , + ) + + // Expand + const header = screen.getByText("Task 3: Pending task") + fireEvent.click(header) + + // Click on first matched todo + const task1 = screen.getByText("Task 1: Change background colour") + fireEvent.click(task1) + + expect(onSubtaskClick).toHaveBeenCalledWith("subtask-1") + }) + + it("should not call onSubtaskClick when an unmatched todo is clicked", () => { + const onSubtaskClick = vi.fn() + render( + , + ) + + // Expand + const header = screen.getByText("Task 3: Pending task") + fireEvent.click(header) + + // Click on unmatched todo + const task3 = screen.getByText("Task 3: Pending task") + fireEvent.click(task3) + + expect(onSubtaskClick).not.toHaveBeenCalled() + }) + + it("should not be clickable when onSubtaskClick is not provided", () => { + render() + + // Expand + const header = screen.getByText("Task 3: Pending task") + fireEvent.click(header) + + // Task should be present but not have hover:underline class behavior + const task1 = screen.getByText("Task 1: Change background colour") + expect(task1.className).not.toContain("cursor-pointer") + }) + }) +}) From e18382d9ffa20bd429102529220d3e386a534b34 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Fri, 16 Jan 2026 10:04:19 -0500 Subject: [PATCH 02/17] feat: link delegated subtasks to todos for cost breakdown --- packages/types/src/todo.ts | 4 + src/core/tools/UpdateTodoListTool.ts | 47 ++++++- src/core/webview/ClineProvider.ts | 113 +++++++++++++++- .../src/components/chat/TodoListDisplay.tsx | 93 +++++-------- .../chat/__tests__/TodoListDisplay.spec.tsx | 127 ++++-------------- 5 files changed, 218 insertions(+), 166 deletions(-) diff --git a/packages/types/src/todo.ts b/packages/types/src/todo.ts index 4e874e17505..0530f920542 100644 --- a/packages/types/src/todo.ts +++ b/packages/types/src/todo.ts @@ -14,6 +14,10 @@ export const todoItemSchema = z.object({ id: z.string(), content: z.string(), status: todoStatusSchema, + // Optional fields for subtask tracking + subtaskId: z.string().optional(), // ID of the linked subtask (child task) for direct cost/token attribution + tokens: z.number().optional(), // Total tokens (in + out) for linked subtask + cost: z.number().optional(), // Total cost for linked subtask }) export type TodoItem = z.infer diff --git a/src/core/tools/UpdateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts index f8b3653b9a3..cb2c37cb217 100644 --- a/src/core/tools/UpdateTodoListTool.ts +++ b/src/core/tools/UpdateTodoListTool.ts @@ -26,6 +26,12 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { const { pushToolResult, handleError, askApproval, toolProtocol } = callbacks try { + // Pull the previous todo list so we can preserve metadata fields across update_todo_list calls. + // Prefer the in-memory task.todoList when available; otherwise fall back to the latest todo list + // stored in the conversation history. + const previousTodos = + getTodoListForTask(task) ?? (getLatestTodo(task.clineMessages) as unknown as TodoItem[]) + const todosRaw = params.todos let todos: TodoItem[] @@ -39,6 +45,10 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { return } + // Preserve metadata (subtaskId/tokens/cost) for todos whose content matches an existing todo. + // Matching is by exact content string; duplicates are matched in order. + const todosWithPreservedMetadata = preserveTodoMetadata(todos, previousTodos) + const { valid, error } = validateTodos(todos) if (!valid) { task.consecutiveMistakeCount++ @@ -48,10 +58,13 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { return } - let normalizedTodos: TodoItem[] = todos.map((t) => ({ + let normalizedTodos: TodoItem[] = todosWithPreservedMetadata.map((t) => ({ id: t.id, content: t.content, status: normalizeStatus(t.status), + subtaskId: t.subtaskId, + tokens: t.tokens, + cost: t.cost, })) const approvalMsg = JSON.stringify({ @@ -70,6 +83,11 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { approvedTodoList !== undefined && JSON.stringify(normalizedTodos) !== JSON.stringify(approvedTodoList) if (isTodoListChanged) { normalizedTodos = approvedTodoList ?? [] + + // If the user-edited todo list dropped metadata fields, re-apply metadata preservation against + // the previous list (and keep any explicitly provided metadata in the edited list). + normalizedTodos = preserveTodoMetadata(normalizedTodos, previousTodos) + task.say( "user_edit_todos", JSON.stringify({ @@ -94,6 +112,7 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { override async handlePartial(task: Task, block: ToolUse<"update_todo_list">): Promise { const todosRaw = block.params.todos + const previousTodos = getTodoListForTask(task) ?? (getLatestTodo(task.clineMessages) as unknown as TodoItem[]) // Parse the markdown checklist to maintain consistent format with execute() let todos: TodoItem[] @@ -104,6 +123,8 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { todos = [] } + todos = preserveTodoMetadata(todos, previousTodos) + const approvalMsg = JSON.stringify({ tool: "updateTodoList", todos: todos, @@ -181,6 +202,30 @@ function normalizeStatus(status: string | undefined): TodoStatus { return "pending" } +function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): TodoItem[] { + // Build content -> queue mapping so duplicates are matched in order. + const previousByContent = new Map() + for (const prev of previousTodos ?? []) { + if (!prev || typeof prev.content !== "string") continue + const list = previousByContent.get(prev.content) + if (list) list.push(prev) + else previousByContent.set(prev.content, [prev]) + } + + return (nextTodos ?? []).map((next) => { + const candidates = previousByContent.get(next.content) + const matchedPrev = candidates?.shift() + if (!matchedPrev) return next + + return { + ...next, + subtaskId: next.subtaskId ?? matchedPrev.subtaskId, + tokens: next.tokens ?? matchedPrev.tokens, + cost: next.cost ?? matchedPrev.cost, + } + }) +} + export function parseMarkdownChecklist(md: string): TodoItem[] { if (typeof md !== "string") return [] const lines = md diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4f09371cb47..9acb26211aa 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -104,6 +104,7 @@ import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem } from "@roo-code/types" import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" +import { getLatestTodo } from "../../shared/todo" import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" @@ -3199,6 +3200,69 @@ export class ClineProvider initialStatus: "active", }) + // 4.5) Direct todo-subtask linking: set todo.subtaskId = childTaskId at delegation-time + // Persist by appending an updateTodoList message to the parent's message history. + try { + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + const parentMessages = await readTaskMessages({ taskId: parentTaskId, globalStoragePath }) + const todos = getLatestTodo(parentMessages) as unknown as TodoItem[] + + const inProgress = todos.filter((t) => t?.status === "in_progress") + const pending = todos.filter((t) => t?.status === "pending") + + // Deterministic selection rule (in_progress > pending): pick the first matching item + // in the list order, even if multiple candidates exist. + const chosen: TodoItem | undefined = inProgress[0] ?? pending[0] + if (!chosen) { + this.log( + `[delegateParentAndOpenChild] Not linking subtask ${child.taskId}: no in_progress or pending todos found`, + ) + } else { + // Log ambiguity (but still link deterministically). + if (inProgress.length > 1) { + this.log( + `[delegateParentAndOpenChild] Multiple in_progress todos (${inProgress.length}); linking first to subtask ${child.taskId}`, + ) + } else if (pending.length > 1 && inProgress.length === 0) { + this.log( + `[delegateParentAndOpenChild] Multiple pending todos (${pending.length}); linking first to subtask ${child.taskId}`, + ) + } + } + + if (chosen) { + if (chosen.subtaskId && chosen.subtaskId !== child.taskId) { + this.log( + `[delegateParentAndOpenChild] Overwriting existing todo.subtaskId '${chosen.subtaskId}' -> '${child.taskId}'`, + ) + } + chosen.subtaskId = child.taskId + + await saveTaskMessages({ + messages: [ + ...parentMessages, + { + ts: Date.now(), + type: "say", + say: "user_edit_todos", + text: JSON.stringify({ + tool: "updateTodoList", + todos, + }), + }, + ], + taskId: parentTaskId, + globalStoragePath, + }) + } + } catch (error) { + this.log( + `[delegateParentAndOpenChild] Failed to persist delegation-time todo link (non-fatal): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + // 5) Persist parent delegation metadata try { const { historyItem } = await this.getTaskWithId(parentTaskId) @@ -3240,6 +3304,19 @@ export class ClineProvider const { parentTaskId, childTaskId, completionResultSummary } = params const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + // 0) Load child task history to capture tokens/cost for write-back. + let childHistoryItem: HistoryItem | undefined + try { + const { historyItem } = await this.getTaskWithId(childTaskId) + childHistoryItem = historyItem + } catch (error) { + this.log( + `[reopenParentFromDelegation] Failed to load child history for ${childTaskId} (non-fatal): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + // 1) Load parent from history and current persisted messages const { historyItem } = await this.getTaskWithId(parentTaskId) @@ -3277,6 +3354,40 @@ export class ClineProvider ts, } parentClineMessages.push(subtaskUiMessage) + + // 2.5) Persist provider completion write-back: update parent's todo item with tokens/cost. + try { + const todos = getLatestTodo(parentClineMessages) as unknown as TodoItem[] + if (Array.isArray(todos) && todos.length > 0) { + const linkedTodo = todos.find((t) => t?.subtaskId === childTaskId) + if (!linkedTodo) { + this.log( + `[reopenParentFromDelegation] No todo found with subtaskId === ${childTaskId}; skipping cost write-back`, + ) + } else { + linkedTodo.tokens = (childHistoryItem?.tokensIn || 0) + (childHistoryItem?.tokensOut || 0) + linkedTodo.cost = childHistoryItem?.totalCost || 0 + + parentClineMessages.push({ + ts: Date.now(), + type: "say", + say: "user_edit_todos", + text: JSON.stringify({ + tool: "updateTodoList", + todos, + }), + }) + } + } + } catch (error) { + this.log( + `[reopenParentFromDelegation] Failed to write back todo cost/tokens (non-fatal): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + // Persist injected UI records (subtask_result + optional todo write-back) await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath }) // Find the tool_use_id from the last assistant message's new_task tool_use @@ -3355,7 +3466,7 @@ export class ClineProvider // 3) Update child metadata to "completed" status try { - const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) + const childHistory = childHistoryItem ?? (await this.getTaskWithId(childTaskId)).historyItem await this.updateTaskHistory({ ...childHistory, status: "completed", diff --git a/webview-ui/src/components/chat/TodoListDisplay.tsx b/webview-ui/src/components/chat/TodoListDisplay.tsx index 06d8185ef89..89eb1151e86 100644 --- a/webview-ui/src/components/chat/TodoListDisplay.tsx +++ b/webview-ui/src/components/chat/TodoListDisplay.tsx @@ -9,6 +9,18 @@ import type { SubtaskDetail } from "./SubtaskCostList" type TodoStatus = "completed" | "in_progress" | "pending" +interface TodoItem { + // Legacy fields + id?: string + content: string + status?: TodoStatus | string | null + + // Direct-linking/cost fields (optional for backward compatibility) + subtaskId?: string + tokens?: number + cost?: number +} + function getTodoIcon(status: TodoStatus | null) { switch (status) { case "completed": @@ -20,53 +32,8 @@ function getTodoIcon(status: TodoStatus | null) { } } -/** - * Normalizes a string for comparison by: - * - Converting to lowercase - * - Removing extra whitespace - * - Trimming quotes - * - Stripping common task prefixes (Subtask N:, ## Task:, Task N:) - * - Removing trailing ellipsis from truncated strings - */ -function normalizeForComparison(str: string): string { - return ( - str - .toLowerCase() - .replace(/\s+/g, " ") - .trim() - .replace(/^["']|["']$/g, "") - // Strip common task prefixes: "Subtask N:", "## Task:", "Task N:", etc. - .replace(/^(subtask\s*\d*\s*:|##\s*task\s*:|task\s*\d*\s*:)\s*/i, "") - // Remove trailing ellipsis from truncated strings - .replace(/\.{3}$/, "") - .trim() - ) -} - -/** - * Match a todo content string to a subtask detail using fuzzy matching. - * Returns the matching SubtaskDetail if found, undefined otherwise. - */ -function findMatchingSubtask(todoContent: string, subtaskDetails: SubtaskDetail[]): SubtaskDetail | undefined { - const normalizedTodo = normalizeForComparison(todoContent) - - // Try exact match first - const exactMatch = subtaskDetails.find((s) => normalizeForComparison(s.name) === normalizedTodo) - if (exactMatch) { - return exactMatch - } - - // Try partial match - check if one contains the other - const partialMatch = subtaskDetails.find((s) => { - const normalizedSubtask = normalizeForComparison(s.name) - return normalizedTodo.includes(normalizedSubtask) || normalizedSubtask.includes(normalizedTodo) - }) - - return partialMatch -} - export interface TodoListDisplayProps { - todos: any[] + todos: TodoItem[] subtaskDetails?: SubtaskDetail[] onSubtaskClick?: (subtaskId: string) => void } @@ -76,16 +43,16 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL const ulRef = useRef(null) const itemRefs = useRef<(HTMLLIElement | null)[]>([]) const scrollIndex = useMemo(() => { - const inProgressIdx = todos.findIndex((todo: any) => todo.status === "in_progress") + const inProgressIdx = todos.findIndex((todo) => todo.status === "in_progress") if (inProgressIdx !== -1) return inProgressIdx - return todos.findIndex((todo: any) => todo.status !== "completed") + return todos.findIndex((todo) => todo.status !== "completed") }, [todos]) // Find the most important todo to display when collapsed const mostImportantTodo = useMemo(() => { - const inProgress = todos.find((todo: any) => todo.status === "in_progress") + const inProgress = todos.find((todo) => todo.status === "in_progress") if (inProgress) return inProgress - return todos.find((todo: any) => todo.status !== "completed") + return todos.find((todo) => todo.status !== "completed") }, [todos]) useEffect(() => { if (isCollapsed) return @@ -104,7 +71,7 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL if (!Array.isArray(todos) || todos.length === 0) return null const totalCount = todos.length - const completedCount = todos.filter((todo: any) => todo.status === "completed").length + const completedCount = todos.filter((todo) => todo.status === "completed").length const allCompleted = completedCount === totalCount && totalCount > 0 @@ -135,12 +102,16 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL {/* Inline expanded list */} {!isCollapsed && (
      - {todos.map((todo: any, idx: number) => { + {todos.map((todo, idx: number) => { const icon = getTodoIcon(todo.status as TodoStatus) - const matchingSubtask = subtaskDetails - ? findMatchingSubtask(todo.content, subtaskDetails) - : undefined - const isClickable = matchingSubtask && onSubtaskClick + const isClickable = Boolean(todo.subtaskId && onSubtaskClick) + const subtaskById = + subtaskDetails && todo.subtaskId + ? subtaskDetails.find((s) => s.id === todo.subtaskId) + : undefined + const displayTokens = todo.tokens ?? subtaskById?.tokens + const displayCost = todo.cost ?? subtaskById?.cost + const shouldShowCost = typeof displayTokens === "number" && typeof displayCost === "number" return (
    • onSubtaskClick(matchingSubtask.id) : undefined}> + onClick={ + isClickable ? () => onSubtaskClick?.(todo.subtaskId as string) : undefined + }> {todo.content} {/* Token count and cost display */} - {matchingSubtask && ( + {shouldShowCost && ( - {formatLargeNumber(matchingSubtask.tokens)} + {formatLargeNumber(displayTokens)} - ${matchingSubtask.cost.toFixed(2)} + ${displayCost.toFixed(2)} )} diff --git a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx index be84b9d28fc..74933bbc95c 100644 --- a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx @@ -23,8 +23,8 @@ vi.mock("@src/utils/format", () => ({ describe("TodoListDisplay", () => { const baseTodos = [ - { id: "1", content: "Task 1: Change background colour", status: "completed" }, - { id: "2", content: "Task 2: Add timestamp to bottom", status: "completed" }, + { id: "1", content: "Task 1: Change background colour", status: "completed", subtaskId: "subtask-1" }, + { id: "2", content: "Task 2: Add timestamp to bottom", status: "completed", subtaskId: "subtask-2" }, { id: "3", content: "Task 3: Pending task", status: "pending" }, ] @@ -81,7 +81,7 @@ describe("TodoListDisplay", () => { }) describe("subtask cost display", () => { - it("should display tokens and cost when subtaskDetails are provided and match", () => { + it("should display tokens and cost when subtaskDetails are provided and todo.subtaskId matches", () => { render() // Expand to see the items @@ -97,14 +97,14 @@ describe("TodoListDisplay", () => { expect(screen.getByText("$0.24")).toBeInTheDocument() }) - it("should not display tokens/cost for unmatched todos", () => { + it("should not display tokens/cost for todos without subtaskId", () => { render() // Expand to see the items const header = screen.getByText("Task 3: Pending task") fireEvent.click(header) - // The pending task has no matching subtask, should not show cost + // The pending task has no subtaskId, should not show cost const listItems = screen.getAllByRole("listitem") const pendingItem = listItems.find((item) => item.textContent?.includes("Task 3: Pending task")) expect(pendingItem).toBeDefined() @@ -135,131 +135,50 @@ describe("TodoListDisplay", () => { }) }) - describe("fuzzy matching", () => { - it("should match todos with slightly different names (partial match)", () => { - const todosWithSlightlyDifferentNames = [ - { id: "1", content: "Change background colour", status: "completed" }, // Missing "Task 1:" prefix - ] - const subtaskWithFullName: SubtaskDetail[] = [ + describe("direct subtask linking", () => { + it("should use todo.tokens and todo.cost when provided (no subtaskDetails required)", () => { + const todosWithDirectCost = [ { - id: "subtask-1", - name: "Change background colour", // Exact partial match - tokens: 50000, - cost: 0.15, + id: "1", + content: "Task 1: Change background colour", status: "completed", - hasNestedChildren: false, - }, - ] - - render() - - // Expand - const header = screen.getByText("1 to-dos done") - fireEvent.click(header) - - // Should find the match - expect(screen.getByText("$0.15")).toBeInTheDocument() - }) - - it("should handle case-insensitive matching", () => { - const todosLowercase = [{ id: "1", content: "change background colour", status: "completed" }] - const subtaskUppercase: SubtaskDetail[] = [ - { - id: "subtask-1", - name: "Change Background Colour", - tokens: 50000, - cost: 0.15, - status: "completed", - hasNestedChildren: false, - }, - ] - - render() - - // Expand - const header = screen.getByText("1 to-dos done") - fireEvent.click(header) - - // Should find the match despite case difference - expect(screen.getByText("$0.15")).toBeInTheDocument() - }) - - it("should match when todo has 'Subtask N:' prefix and subtask has '## Task:' prefix", () => { - const todosWithSubtaskPrefix = [ - { id: "1", content: "Subtask 1: Change background colour to light purple", status: "completed" }, - ] - const subtaskWithMarkdownPrefix: SubtaskDetail[] = [ - { - id: "subtask-1", - name: "## Task: Change Background Colour to Light Purp...", + subtaskId: "subtask-1", tokens: 95400, cost: 0.22, - status: "completed", - hasNestedChildren: false, }, ] - - render() + render() // Expand const header = screen.getByText("1 to-dos done") fireEvent.click(header) - // Should find the match despite different prefixes + expect(screen.getByText("95.4k")).toBeInTheDocument() expect(screen.getByText("$0.22")).toBeInTheDocument() }) - it("should match when subtask name is truncated with ellipsis", () => { - const todos = [{ id: "1", content: "Task 1: Add timestamp to the bottom of the page", status: "completed" }] - const subtaskWithTruncation: SubtaskDetail[] = [ - { - id: "subtask-1", - name: "## Task: Add Timestamp to the Bottom of the Pag...", - tokens: 95000, - cost: 0.24, - status: "completed", - hasNestedChildren: false, - }, - ] - - render() - - // Expand - const header = screen.getByText("1 to-dos done") - fireEvent.click(header) - - // Should find the match despite truncation - expect(screen.getByText("$0.24")).toBeInTheDocument() - }) - - it("should strip 'Subtask N:' prefix from todo content", () => { - const todosWithNumberedPrefix = [ - { id: "1", content: "Subtask 2: Do something important", status: "completed" }, - ] - const subtaskWithoutPrefix: SubtaskDetail[] = [ + it("should fall back to subtaskDetails by ID when todo.tokens/cost are missing", () => { + const todosMissingCostFields = [ { - id: "subtask-1", - name: "Do something important", - tokens: 50000, - cost: 0.15, + id: "1", + content: "Task 1: Change background colour", status: "completed", - hasNestedChildren: false, + subtaskId: "subtask-1", }, ] - - render() + render() // Expand const header = screen.getByText("1 to-dos done") fireEvent.click(header) - // Should find the match after stripping prefix - expect(screen.getByText("$0.15")).toBeInTheDocument() + expect(screen.getByText("95.4k")).toBeInTheDocument() + expect(screen.getByText("$0.22")).toBeInTheDocument() }) }) describe("click handler", () => { - it("should call onSubtaskClick when a matched todo is clicked", () => { + it("should call onSubtaskClick when a todo with subtaskId is clicked", () => { const onSubtaskClick = vi.fn() render( , @@ -276,7 +195,7 @@ describe("TodoListDisplay", () => { expect(onSubtaskClick).toHaveBeenCalledWith("subtask-1") }) - it("should not call onSubtaskClick when an unmatched todo is clicked", () => { + it("should not call onSubtaskClick when a todo does not have subtaskId", () => { const onSubtaskClick = vi.fn() render( , From 6b3ec95ce397b841deed544d1eec50f3a2436c1e Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Fri, 16 Jan 2026 11:21:04 -0500 Subject: [PATCH 03/17] Add task cost breakdown utilities --- webview-ui/src/components/chat/TaskHeader.tsx | 62 ++++++++--- .../utils/__tests__/taskCostBreakdown.spec.ts | 30 +++++ webview-ui/src/utils/taskCostBreakdown.ts | 104 ++++++++++++++++++ 3 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 webview-ui/src/utils/__tests__/taskCostBreakdown.spec.ts create mode 100644 webview-ui/src/utils/taskCostBreakdown.ts diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 133e6ff4ef2..133a38a35f2 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -21,6 +21,7 @@ import { getModelMaxOutputTokens } from "@roo/api" import { findLastIndex } from "@roo/array" import { formatLargeNumber } from "@src/utils/format" +import { getTaskHeaderCostTooltipData } from "@src/utils/taskCostBreakdown" import { cn } from "@src/lib/utils" import { StandardTooltip, Button } from "@src/components/ui" import { useExtensionState } from "@src/context/ExtensionStateContext" @@ -129,6 +130,37 @@ const TaskHeader = ({ const hasTodos = todos && Array.isArray(todos) && todos.length > 0 + const subtaskCosts = useMemo(() => { + if (!subtaskDetails || subtaskDetails.length === 0) { + return [] + } + + return subtaskDetails + .map((subtask) => subtask.cost) + .filter((cost): cost is number => typeof cost === "number" && Number.isFinite(cost)) + }, [subtaskDetails]) + + const tooltipCostData = useMemo( + () => + getTaskHeaderCostTooltipData({ + ownCost: totalCost, + aggregatedCost, + hasSubtasksProp: hasSubtasks, + costBreakdownProp: costBreakdown, + subtaskCosts, + labels: { + own: t("common:costs.own"), + subtasks: t("common:costs.subtasks"), + }, + }), + [totalCost, aggregatedCost, hasSubtasks, costBreakdown, subtaskCosts, t], + ) + + const displayTotalCost = tooltipCostData.displayTotalCost + const displayCostBreakdown = tooltipCostData.displayCostBreakdown + const shouldTreatAsHasSubtasks = tooltipCostData.hasSubtasks + const hasAnyCost = tooltipCostData.hasAnyCost + return (
      {showLongRunningTaskMessage && !isTaskComplete && ( @@ -257,17 +289,19 @@ const TaskHeader = ({ {formatLargeNumber(contextTokens || 0)} / {formatLargeNumber(contextWindow)} - {!!totalCost && ( + {hasAnyCost && (
      {t("chat:costs.totalWithSubtasks", { - cost: (aggregatedCost ?? totalCost).toFixed(2), + cost: displayTotalCost.toFixed(2), })}
      - {costBreakdown &&
      {costBreakdown}
      } + {displayCostBreakdown && ( +
      {displayCostBreakdown}
      + )}
      ) : (
      {t("chat:costs.total", { cost: totalCost.toFixed(2) })}
      @@ -276,8 +310,8 @@ const TaskHeader = ({ side="top" sideOffset={8}> - ${(aggregatedCost ?? totalCost).toFixed(2)} - {hasSubtasks && ( + ${displayTotalCost.toFixed(2)} + {shouldTreatAsHasSubtasks && ( * @@ -416,7 +450,7 @@ const TaskHeader = ({ )} - {!!totalCost && ( + {hasAnyCost && ( {t("chat:task.apiCost")} @@ -424,15 +458,17 @@ const TaskHeader = ({
      {t("chat:costs.totalWithSubtasks", { - cost: (aggregatedCost ?? totalCost).toFixed(2), + cost: displayTotalCost.toFixed(2), })}
      - {costBreakdown && ( -
      {costBreakdown}
      + {displayCostBreakdown && ( +
      + {displayCostBreakdown} +
      )} ) : ( @@ -444,8 +480,8 @@ const TaskHeader = ({ side="top" sideOffset={8}> - ${(aggregatedCost ?? totalCost).toFixed(2)} - {hasSubtasks && ( + ${displayTotalCost.toFixed(2)} + {shouldTreatAsHasSubtasks && ( diff --git a/webview-ui/src/utils/__tests__/taskCostBreakdown.spec.ts b/webview-ui/src/utils/__tests__/taskCostBreakdown.spec.ts new file mode 100644 index 00000000000..fe7f00771fb --- /dev/null +++ b/webview-ui/src/utils/__tests__/taskCostBreakdown.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest" + +import { computeTaskCostsIncludingSubtasks, getTaskHeaderCostTooltipData } from "@src/utils/taskCostBreakdown" + +describe("taskCostBreakdown", () => { + it("sums subtask line-item costs (micros) and produces a stable total", () => { + const result = computeTaskCostsIncludingSubtasks(0.05, [0.16, 0.13]) + + expect(result.ownCostCents).toBe(5) + expect(result.subtasksCostCents).toBe(29) + expect(result.totalCostIncludingSubtasksCents).toBe(34) + expect(result.totalCostIncludingSubtasks).toBeCloseTo(0.34, 10) + }) + + it("prefers derived subtask sum over provided breakdown/aggregatedCost when details are available", () => { + const data = getTaskHeaderCostTooltipData({ + ownCost: 0.05, + aggregatedCost: 0.09, + hasSubtasksProp: true, + costBreakdownProp: "Own: $0.05 + Subtasks: $0.09", + subtaskCosts: [0.16, 0.13], + labels: { own: "Own", subtasks: "Subtasks" }, + }) + + expect(data.displayTotalCost).toBeCloseTo(0.34, 10) + expect(data.displayCostBreakdown).toBe("Own: $0.05 + Subtasks: $0.29") + expect(data.hasSubtasks).toBe(true) + expect(data.hasAnyCost).toBe(true) + }) +}) diff --git a/webview-ui/src/utils/taskCostBreakdown.ts b/webview-ui/src/utils/taskCostBreakdown.ts new file mode 100644 index 00000000000..514246e50eb --- /dev/null +++ b/webview-ui/src/utils/taskCostBreakdown.ts @@ -0,0 +1,104 @@ +import { formatCostBreakdown } from "@src/utils/costFormatting" + +const MICROS_PER_DOLLAR = 1_000_000 +const MICROS_PER_CENT = 10_000 + +function dollarsToMicros(amount: number): number { + if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) { + return 0 + } + return Math.round(amount * MICROS_PER_DOLLAR) +} + +function microsToDollars(micros: number): number { + return micros / MICROS_PER_DOLLAR +} + +export interface TaskCostsIncludingSubtasks { + ownCost: number + ownCostMicros: number + ownCostCents: number + + subtasksCost: number + subtasksCostMicros: number + subtasksCostCents: number + + totalCostIncludingSubtasks: number + totalCostIncludingSubtasksMicros: number + totalCostIncludingSubtasksCents: number +} + +/** + * Computes task costs using integer micros for stable aggregation. + * + * Note: we only have access to floating-dollar amounts in the webview. + * Converting to micros and summing avoids most floating point drift. + */ +export function computeTaskCostsIncludingSubtasks(ownCost: number, subtaskCosts: number[]): TaskCostsIncludingSubtasks { + const ownCostMicros = dollarsToMicros(ownCost) + const subtasksCostMicros = (subtaskCosts ?? []).reduce((sum, cost) => sum + dollarsToMicros(cost), 0) + const totalCostIncludingSubtasksMicros = ownCostMicros + subtasksCostMicros + + const ownCostCents = Math.round(ownCostMicros / MICROS_PER_CENT) + const subtasksCostCents = Math.round(subtasksCostMicros / MICROS_PER_CENT) + const totalCostIncludingSubtasksCents = Math.round(totalCostIncludingSubtasksMicros / MICROS_PER_CENT) + + return { + ownCost: microsToDollars(ownCostMicros), + ownCostMicros, + ownCostCents, + subtasksCost: microsToDollars(subtasksCostMicros), + subtasksCostMicros, + subtasksCostCents, + totalCostIncludingSubtasks: microsToDollars(totalCostIncludingSubtasksMicros), + totalCostIncludingSubtasksMicros, + totalCostIncludingSubtasksCents, + } +} + +export interface TaskHeaderCostTooltipData { + /** Total cost to display (includes subtasks when details provided). */ + displayTotalCost: number + /** Breakdown string to show in tooltip, if subtasks exist. */ + displayCostBreakdown?: string + /** Whether the UI should show the "includes subtasks" marker. */ + hasSubtasks: boolean + /** Whether there is any cost to render. */ + hasAnyCost: boolean +} + +/** + * Cost display logic for TaskHeader tooltip. + * + * When subtask details are available, this derives subtasks cost as the sum of + * subtask line-item totals (same source as the accordion list), rather than + * trusting any derived deltas. + */ +export function getTaskHeaderCostTooltipData(params: { + ownCost: number + aggregatedCost?: number + hasSubtasksProp?: boolean + costBreakdownProp?: string + subtaskCosts?: number[] + labels: { own: string; subtasks: string } +}): TaskHeaderCostTooltipData { + const { ownCost, aggregatedCost, hasSubtasksProp, costBreakdownProp, subtaskCosts, labels } = params + + const computed = computeTaskCostsIncludingSubtasks(ownCost, subtaskCosts ?? []) + const hasComputedSubtasks = computed.subtasksCostCents > 0 + const hasSubtasks = !!hasSubtasksProp || hasComputedSubtasks + + const displayTotalCost = hasComputedSubtasks ? computed.totalCostIncludingSubtasks : (aggregatedCost ?? ownCost) + const displayCostBreakdown = hasComputedSubtasks + ? formatCostBreakdown(computed.ownCost, computed.subtasksCost, labels) + : costBreakdownProp + + const hasAnyCost = typeof displayTotalCost === "number" && Number.isFinite(displayTotalCost) && displayTotalCost > 0 + + return { + displayTotalCost, + displayCostBreakdown, + hasSubtasks, + hasAnyCost, + } +} From 4c9dd28d4031af3e33843ab38c88187420e615d8 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Sat, 17 Jan 2026 15:47:52 -0500 Subject: [PATCH 04/17] fix: improve todo-subtask linking reliability with fallback anchors Enhance the direct todo-subtask linking mechanism to handle edge cases where subtaskId links may be missing during history resume or delegation. Changes: - UpdateTodoListTool: Use dual-strategy metadata matching (ID-first, then content-based fallback) to preserve tokens/cost across updates - ClineProvider: Add deterministic fallback anchor selection (in_progress > pending > last completed > synthetic) for both delegation-time linking and cost write-back on resume - ClineProvider: Create synthetic anchor todo when no todos exist - Clean up excessive logging statements Tests: - Add fallback anchor tests for reopenParentFromDelegation - Add delegation-time linking tests with various todo states --- .../history-resume-delegation.spec.ts | 171 ++++++++++++++++++ src/__tests__/new-task-delegation.spec.ts | 76 ++++++++ src/core/tools/UpdateTodoListTool.ts | 95 ++++++++-- src/core/webview/ClineProvider.ts | 167 ++++++++--------- 4 files changed, 414 insertions(+), 95 deletions(-) diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index 1f95d0f6dde..5934e9fe395 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -491,4 +491,175 @@ describe("History resume delegation - parent metadata transitions", () => { }), ) }) + + it("reopenParentFromDelegation uses fallback anchor when subtaskId link is missing but child is valid", async () => { + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/storage" } }, + getTaskWithId: vi.fn().mockImplementation((taskId: string) => { + if (taskId === "parent-fallback") { + return Promise.resolve({ + historyItem: { + id: "parent-fallback", + status: "delegated", + awaitingChildId: "child-fallback", + childIds: ["child-fallback"], // This validates the parent-child relationship + ts: 100, + task: "Parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }) + } + // Child history item with tokens/cost + return Promise.resolve({ + historyItem: { + id: "child-fallback", + tokensIn: 500, + tokensOut: 300, + totalCost: 0.05, + ts: 200, + task: "Child task", + }, + }) + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "child-fallback" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + taskId: "parent-fallback", + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + }), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + // Parent has all completed todos but NO subtaskId link + const parentMessagesWithCompletedTodos = [ + { + type: "say", + say: "user_edit_todos", + text: JSON.stringify({ + tool: "updateTodoList", + todos: [ + { id: "todo-1", content: "First completed", status: "completed" }, + { id: "todo-2", content: "Second completed", status: "completed" }, + { id: "todo-3", content: "Last completed", status: "completed" }, + // Note: NO subtaskId on any todo - this is the bug scenario + ], + }), + ts: 50, + }, + ] + + vi.mocked(readTaskMessages).mockResolvedValue(parentMessagesWithCompletedTodos as any) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-fallback", + childTaskId: "child-fallback", + completionResultSummary: "Child completed successfully", + }) + + // Verify that saveTaskMessages was called and includes the todo write-back + expect(saveTaskMessages).toHaveBeenCalled() + const savedCall = vi.mocked(saveTaskMessages).mock.calls[0][0] + + // Find the user_edit_todos message that was added for the write-back + const todoEditMessages = savedCall.messages.filter((m: any) => m.type === "say" && m.say === "user_edit_todos") + + // Should have at least 2 todo edit messages (original + write-back) + expect(todoEditMessages.length).toBeGreaterThanOrEqual(1) + + // Parse the last todo edit to verify fallback worked + const lastTodoEdit = todoEditMessages[todoEditMessages.length - 1] + expect(lastTodoEdit.text).toBeDefined() + const parsedTodos = JSON.parse(lastTodoEdit.text as string) + + // The LAST completed todo should have been selected as the fallback anchor + // and should now have subtaskId, tokens, and cost + const anchoredTodo = parsedTodos.todos.find((t: any) => t.subtaskId === "child-fallback") + expect(anchoredTodo).toBeDefined() + expect(anchoredTodo.content).toBe("Last completed") // Fallback picks LAST completed + expect(anchoredTodo.tokens).toBe(800) // 500 + 300 + expect(anchoredTodo.cost).toBe(0.05) + }) + + it("reopenParentFromDelegation does NOT apply fallback when childIds doesn't include the child", async () => { + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/storage" } }, + getTaskWithId: vi.fn().mockImplementation((taskId: string) => { + if (taskId === "parent-no-relation") { + return Promise.resolve({ + historyItem: { + id: "parent-no-relation", + status: "delegated", + awaitingChildId: "some-other-child", + childIds: ["some-other-child"], // Does NOT include child-orphan + ts: 100, + task: "Parent task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + }) + } + return Promise.resolve({ + historyItem: { + id: "child-orphan", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + ts: 200, + task: "Orphan child", + }, + }) + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: "child-orphan" })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + taskId: "parent-no-relation", + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + }), + updateTaskHistory: vi.fn().mockResolvedValue([]), + } as unknown as ClineProvider + + const parentMessagesWithTodos = [ + { + type: "say", + say: "user_edit_todos", + text: JSON.stringify({ + tool: "updateTodoList", + todos: [{ id: "todo-1", content: "Some task", status: "completed" }], + }), + ts: 50, + }, + ] + + vi.mocked(readTaskMessages).mockResolvedValue(parentMessagesWithTodos as any) + vi.mocked(readApiMessages).mockResolvedValue([]) + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId: "parent-no-relation", + childTaskId: "child-orphan", + completionResultSummary: "Orphan child completed", + }) + + // Verify saveTaskMessages was called + expect(saveTaskMessages).toHaveBeenCalled() + const savedCall = vi.mocked(saveTaskMessages).mock.calls[0][0] + + // Find todo edit messages (if any were added beyond the original) + const todoEditMessages = savedCall.messages.filter((m: any) => m.type === "say" && m.say === "user_edit_todos") + + // Should only have the original todo edit, no write-back because child isn't in childIds + // The fallback should NOT be triggered for an unrelated child + if (todoEditMessages.length > 1) { + const lastTodoEdit = todoEditMessages[todoEditMessages.length - 1] + const parsedTodos = JSON.parse(lastTodoEdit.text as string) + // If a write-back happened, it should NOT have linked to child-orphan + const orphanLinked = parsedTodos.todos.find((t: any) => t.subtaskId === "child-orphan") + expect(orphanLinked).toBeUndefined() + } + }) }) diff --git a/src/__tests__/new-task-delegation.spec.ts b/src/__tests__/new-task-delegation.spec.ts index b6f6d4d36cd..85b107fdac9 100644 --- a/src/__tests__/new-task-delegation.spec.ts +++ b/src/__tests__/new-task-delegation.spec.ts @@ -42,3 +42,79 @@ describe("Task.startSubtask() metadata-driven delegation", () => { expect(provider.createTask).not.toHaveBeenCalled() }) }) + +describe("Deterministic todo anchor selection for subtaskId linking", () => { + // Helper to simulate the anchor selection algorithm from delegateParentAndOpenChild + function selectDeterministicAnchor( + todos: Array<{ id: string; content: string; status: string; subtaskId?: string }>, + ): { id: string; content: string; status: string; subtaskId?: string } | undefined { + const inProgress = todos.filter((t) => t?.status === "in_progress") + const pending = todos.filter((t) => t?.status === "pending") + const completed = todos.filter((t) => t?.status === "completed") + + if (inProgress.length > 0) { + return inProgress[0] + } else if (pending.length > 0) { + return pending[0] + } else if (completed.length > 0) { + return completed[completed.length - 1] // Last completed + } + return undefined + } + + it("selects first in_progress todo when available", () => { + const todos = [ + { id: "1", content: "Task A", status: "completed" }, + { id: "2", content: "Task B", status: "in_progress" }, + { id: "3", content: "Task C", status: "pending" }, + ] + + const chosen = selectDeterministicAnchor(todos) + expect(chosen?.id).toBe("2") + expect(chosen?.status).toBe("in_progress") + }) + + it("selects first pending todo when no in_progress", () => { + const todos = [ + { id: "1", content: "Task A", status: "completed" }, + { id: "2", content: "Task B", status: "pending" }, + { id: "3", content: "Task C", status: "pending" }, + ] + + const chosen = selectDeterministicAnchor(todos) + expect(chosen?.id).toBe("2") + expect(chosen?.status).toBe("pending") + }) + + it("selects LAST completed todo when all todos are completed", () => { + const todos = [ + { id: "1", content: "Task A", status: "completed" }, + { id: "2", content: "Task B", status: "completed" }, + { id: "3", content: "Task C", status: "completed" }, + ] + + const chosen = selectDeterministicAnchor(todos) + // Should pick the LAST completed (closest to delegation moment) + expect(chosen?.id).toBe("3") + expect(chosen?.content).toBe("Task C") + }) + + it("returns undefined when no todos exist (triggers synthetic anchor creation)", () => { + const todos: Array<{ id: string; content: string; status: string }> = [] + const chosen = selectDeterministicAnchor(todos) + expect(chosen).toBeUndefined() + }) + + it("handles mixed statuses deterministically", () => { + const todos = [ + { id: "1", content: "Done early", status: "completed" }, + { id: "2", content: "In progress", status: "in_progress" }, + { id: "3", content: "Done late", status: "completed" }, + { id: "4", content: "Still pending", status: "pending" }, + ] + + // Should prefer in_progress over everything + const chosen = selectDeterministicAnchor(todos) + expect(chosen?.id).toBe("2") + }) +}) diff --git a/src/core/tools/UpdateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts index cb2c37cb217..068d473b538 100644 --- a/src/core/tools/UpdateTodoListTool.ts +++ b/src/core/tools/UpdateTodoListTool.ts @@ -202,27 +202,94 @@ function normalizeStatus(status: string | undefined): TodoStatus { return "pending" } +/** + * Preserve metadata (subtaskId, tokens, cost) from previous todos onto next todos. + * + * Matching strategy (in priority order): + * 1. **ID match**: If both todos have an `id` field and they match exactly, preserve metadata. + * This handles the common case where ID is stable across updates. + * 2. **Content match with position awareness**: For todos without matching IDs, fall back to + * content-based matching. Duplicates are matched in order (first unmatched previous with + * same content gets matched to first unmatched next with same content). + * + * This approach ensures metadata survives status changes (which can alter the derived ID) + * and handles duplicates deterministically. + */ function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): TodoItem[] { - // Build content -> queue mapping so duplicates are matched in order. - const previousByContent = new Map() - for (const prev of previousTodos ?? []) { + const safePrevious = previousTodos ?? [] + const safeNext = nextTodos ?? [] + + // Build ID -> todo mapping for O(1) lookup + const previousById = new Map() + for (const prev of safePrevious) { + if (prev?.id && typeof prev.id === "string") { + // Only store the first occurrence for each ID (handle duplicates deterministically) + if (!previousById.has(prev.id)) { + previousById.set(prev.id, prev) + } + } + } + + // Track which previous todos have been used (by their index) to avoid double-matching + const usedPreviousIndices = new Set() + + // Build content -> queue mapping for fallback (content-based matching) + // Each queue entry includes the original index for tracking + const previousByContent = new Map>() + for (let i = 0; i < safePrevious.length; i++) { + const prev = safePrevious[i] if (!prev || typeof prev.content !== "string") continue const list = previousByContent.get(prev.content) - if (list) list.push(prev) - else previousByContent.set(prev.content, [prev]) + if (list) list.push({ todo: prev, index: i }) + else previousByContent.set(prev.content, [{ todo: prev, index: i }]) } - return (nextTodos ?? []).map((next) => { - const candidates = previousByContent.get(next.content) - const matchedPrev = candidates?.shift() - if (!matchedPrev) return next + return safeNext.map((next) => { + if (!next) return next + + let matchedPrev: TodoItem | undefined = undefined + let matchedIndex: number | undefined = undefined + + // Strategy 1: Try ID-based matching first (most reliable) + if (next.id && typeof next.id === "string") { + const byId = previousById.get(next.id) + if (byId) { + // Find the index of this todo in the original array + const idx = safePrevious.findIndex((p) => p === byId) + if (idx !== -1 && !usedPreviousIndices.has(idx)) { + matchedPrev = byId + matchedIndex = idx + } + } + } - return { - ...next, - subtaskId: next.subtaskId ?? matchedPrev.subtaskId, - tokens: next.tokens ?? matchedPrev.tokens, - cost: next.cost ?? matchedPrev.cost, + // Strategy 2: Fall back to content-based matching if ID didn't match + if (!matchedPrev && typeof next.content === "string") { + const candidates = previousByContent.get(next.content) + if (candidates) { + // Find first unused candidate + for (const candidate of candidates) { + if (!usedPreviousIndices.has(candidate.index)) { + matchedPrev = candidate.todo + matchedIndex = candidate.index + break + } + } + } + } + + // Mark as used and apply metadata + if (matchedPrev && matchedIndex !== undefined) { + usedPreviousIndices.add(matchedIndex) + return { + ...next, + subtaskId: next.subtaskId ?? matchedPrev.subtaskId, + tokens: next.tokens ?? matchedPrev.tokens, + cost: next.cost ?? matchedPrev.cost, + } } + + return next }) } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9acb26211aa..77dfeb86d18 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -523,7 +523,6 @@ export class ClineProvider // Create timeout for automatic cleanup const timeoutId = setTimeout(() => { this.clearPendingEditOperation(operationId) - this.log(`[setPendingEditOperation] Automatically cleared stale pending operation: ${operationId}`) }, ClineProvider.PENDING_OPERATION_TIMEOUT_MS) // Store the operation @@ -532,8 +531,6 @@ export class ClineProvider timeoutId, createdAt: Date.now(), }) - - this.log(`[setPendingEditOperation] Set pending operation: ${operationId}`) } /** @@ -551,7 +548,6 @@ export class ClineProvider if (operation) { clearTimeout(operation.timeoutId) this.pendingOperations.delete(operationId) - this.log(`[clearPendingEditOperation] Cleared pending operation: ${operationId}`) return true } return false @@ -565,7 +561,6 @@ export class ClineProvider clearTimeout(operation.timeoutId) } this.pendingOperations.clear() - this.log(`[clearAllPendingEditOperations] Cleared all pending operations`) } /* @@ -583,22 +578,16 @@ export class ClineProvider } async dispose() { - this.log("Disposing ClineProvider...") - // Clear all tasks from the stack. while (this.clineStack.length > 0) { await this.removeClineFromStack() } - this.log("Cleared all tasks") - // Clear all pending edit operations to prevent memory leaks this.clearAllPendingEditOperations() - this.log("Cleared pending operations") if (this.view && "dispose" in this.view) { this.view.dispose() - this.log("Disposed webview") } this.clearWebviewResources() @@ -624,7 +613,6 @@ export class ClineProvider this.skillsManager = undefined this.marketplaceManager?.cleanup() this.customModesManager?.dispose() - this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) // Clean up any event listeners attached to this provider @@ -844,10 +832,8 @@ export class ClineProvider webviewView.onDidDispose( async () => { if (inTabMode) { - this.log("Disposing ClineProvider instance for tab view") await this.dispose() } else { - this.log("Clearing webview resources for sidebar view") this.clearWebviewResources() // Reset current workspace manager reference when view is disposed this.codeIndexManager = undefined @@ -1032,16 +1018,8 @@ export class ClineProvider // Perform preparation tasks and set up event listeners await this.performPreparationTasks(task) - - this.log( - `[createTaskWithHistoryItem] rehydrated task ${task.taskId}.${task.instanceId} in-place (flicker-free)`, - ) } else { await this.addClineToStack(task) - - this.log( - `[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, - ) } // Check if there's a pending edit after checkpoint restoration @@ -1050,8 +1028,6 @@ export class ClineProvider if (pendingEdit) { this.clearPendingEditOperation(operationId) // Clear the pending edit - this.log(`[createTaskWithHistoryItem] Processing pending edit after checkpoint restoration`) - // Process the pending edit after a short delay to ensure the task is fully initialized setTimeout(async () => { try { @@ -2887,10 +2863,6 @@ export class ClineProvider await this.addClineToStack(task) - this.log( - `[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, - ) - return task } @@ -2901,8 +2873,6 @@ export class ClineProvider return } - console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`) - const { historyItem, uiMessagesFilePath } = await this.getTaskWithId(task.taskId) // Preserve parent and root task information for history item. @@ -2969,8 +2939,6 @@ export class ClineProvider // This is used when the user cancels a task that is not a subtask. public async clearTask(): Promise { if (this.clineStack.length > 0) { - const task = this.clineStack[this.clineStack.length - 1] - console.log(`[clearTask] clearing task ${task.taskId}.${task.instanceId}`) await this.removeClineFromStack() } } @@ -3202,59 +3170,69 @@ export class ClineProvider // 4.5) Direct todo-subtask linking: set todo.subtaskId = childTaskId at delegation-time // Persist by appending an updateTodoList message to the parent's message history. + // Uses deterministic anchor selection: in_progress > pending > last completed > synthetic anchor. try { const globalStoragePath = this.contextProxy.globalStorageUri.fsPath const parentMessages = await readTaskMessages({ taskId: parentTaskId, globalStoragePath }) - const todos = getLatestTodo(parentMessages) as unknown as TodoItem[] + let todos = (getLatestTodo(parentMessages) as unknown as TodoItem[]) ?? [] + + // Ensure todos is a valid array + if (!Array.isArray(todos)) { + todos = [] + } + + // Deterministic selection algorithm: + // 1. First in_progress todo + // 2. Else first pending todo + // 3. Else last completed todo (closest to delegation moment) + // 4. Else create a synthetic anchor todo + let chosen: TodoItem | undefined = undefined const inProgress = todos.filter((t) => t?.status === "in_progress") const pending = todos.filter((t) => t?.status === "pending") - - // Deterministic selection rule (in_progress > pending): pick the first matching item - // in the list order, even if multiple candidates exist. - const chosen: TodoItem | undefined = inProgress[0] ?? pending[0] - if (!chosen) { - this.log( - `[delegateParentAndOpenChild] Not linking subtask ${child.taskId}: no in_progress or pending todos found`, - ) + const completed = todos.filter((t) => t?.status === "completed") + + if (inProgress.length > 0) { + chosen = inProgress[0] + } else if (pending.length > 0) { + chosen = pending[0] + } else if (completed.length > 0) { + // Pick the LAST completed todo (closest stable anchor to delegation moment) + chosen = completed[completed.length - 1] } else { - // Log ambiguity (but still link deterministically). - if (inProgress.length > 1) { - this.log( - `[delegateParentAndOpenChild] Multiple in_progress todos (${inProgress.length}); linking first to subtask ${child.taskId}`, - ) - } else if (pending.length > 1 && inProgress.length === 0) { - this.log( - `[delegateParentAndOpenChild] Multiple pending todos (${pending.length}); linking first to subtask ${child.taskId}`, - ) + // No todos exist: append a synthetic anchor todo + const syntheticTodo: TodoItem = { + id: `synthetic-${child.taskId}`, + content: "Delegated to subtask", + status: "completed", + subtaskId: child.taskId, } + todos.push(syntheticTodo) + chosen = syntheticTodo } - if (chosen) { - if (chosen.subtaskId && chosen.subtaskId !== child.taskId) { - this.log( - `[delegateParentAndOpenChild] Overwriting existing todo.subtaskId '${chosen.subtaskId}' -> '${child.taskId}'`, - ) - } + // Set the subtaskId on the chosen todo (unless it's the synthetic one we just created) + if (chosen && !chosen.subtaskId) { chosen.subtaskId = child.taskId - - await saveTaskMessages({ - messages: [ - ...parentMessages, - { - ts: Date.now(), - type: "say", - say: "user_edit_todos", - text: JSON.stringify({ - tool: "updateTodoList", - todos, - }), - }, - ], - taskId: parentTaskId, - globalStoragePath, - }) } + + // Always persist the updated todo list + await saveTaskMessages({ + messages: [ + ...parentMessages, + { + ts: Date.now(), + type: "say", + say: "user_edit_todos", + text: JSON.stringify({ + tool: "updateTodoList", + todos, + }), + }, + ], + taskId: parentTaskId, + globalStoragePath, + }) } catch (error) { this.log( `[delegateParentAndOpenChild] Failed to persist delegation-time todo link (non-fatal): ${ @@ -3356,15 +3334,42 @@ export class ClineProvider parentClineMessages.push(subtaskUiMessage) // 2.5) Persist provider completion write-back: update parent's todo item with tokens/cost. + // Primary: find todo where t.subtaskId === childTaskId. + // Fallback: if not found BUT this parent/child relationship is valid (from historyItem), + // pick the same deterministic anchor todo and set its subtaskId = childTaskId before writing tokens/cost. try { - const todos = getLatestTodo(parentClineMessages) as unknown as TodoItem[] - if (Array.isArray(todos) && todos.length > 0) { - const linkedTodo = todos.find((t) => t?.subtaskId === childTaskId) - if (!linkedTodo) { - this.log( - `[reopenParentFromDelegation] No todo found with subtaskId === ${childTaskId}; skipping cost write-back`, - ) - } else { + let todos = (getLatestTodo(parentClineMessages) as unknown as TodoItem[]) ?? [] + if (!Array.isArray(todos)) { + todos = [] + } + + if (todos.length > 0) { + // Primary lookup by subtaskId + let linkedTodo = todos.find((t) => t?.subtaskId === childTaskId) + + // Fallback: if subtaskId link wasn't found but parent history confirms this child belongs to it, + // use the deterministic anchor selection to establish the link now. + if (!linkedTodo && historyItem.childIds?.includes(childTaskId)) { + const inProgress = todos.filter((t) => t?.status === "in_progress") + const pending = todos.filter((t) => t?.status === "pending") + const completed = todos.filter((t) => t?.status === "completed") + + if (inProgress.length > 0) { + linkedTodo = inProgress[0] + } else if (pending.length > 0) { + linkedTodo = pending[0] + } else if (completed.length > 0) { + // Pick the LAST completed todo (same as delegation-time logic) + linkedTodo = completed[completed.length - 1] + } + + // Set the subtaskId on the fallback anchor if found + if (linkedTodo) { + linkedTodo.subtaskId = childTaskId + } + } + + if (linkedTodo) { linkedTodo.tokens = (childHistoryItem?.tokensIn || 0) + (childHistoryItem?.tokensOut || 0) linkedTodo.cost = childHistoryItem?.totalCost || 0 From 0a245c49a7dfdf9a339358847dafe4da4d5992b1 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Sat, 17 Jan 2026 17:01:24 -0500 Subject: [PATCH 05/17] fix: eliminate spurious user edits and remove edit button from notifications - Add system_update_todos message type to distinguish system-generated todo updates from actual user edits - Remove unnecessary edit button from todo list update notifications - Update UI rendering logic to handle system updates appropriately - Update tests to reflect new message type behavior --- packages/types/src/message.ts | 1 + .../history-resume-delegation.spec.ts | 14 +++++++----- src/core/webview/ClineProvider.ts | 4 ++-- src/shared/todo.ts | 3 ++- webview-ui/src/components/chat/ChatRow.tsx | 2 ++ .../chat/UpdateTodoListToolBlock.tsx | 22 ------------------- 6 files changed, 16 insertions(+), 30 deletions(-) diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 109cd842bac..dc7d8f23448 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -180,6 +180,7 @@ export const clineSays = [ "sliding_window_truncation", "codebase_search_result", "user_edit_todos", + "system_update_todos", ] as const export const clineSaySchema = z.enum(clineSays) diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index 5934e9fe395..93bff631f62 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -537,7 +537,7 @@ describe("History resume delegation - parent metadata transitions", () => { const parentMessagesWithCompletedTodos = [ { type: "say", - say: "user_edit_todos", + say: "system_update_todos", text: JSON.stringify({ tool: "updateTodoList", todos: [ @@ -564,8 +564,10 @@ describe("History resume delegation - parent metadata transitions", () => { expect(saveTaskMessages).toHaveBeenCalled() const savedCall = vi.mocked(saveTaskMessages).mock.calls[0][0] - // Find the user_edit_todos message that was added for the write-back - const todoEditMessages = savedCall.messages.filter((m: any) => m.type === "say" && m.say === "user_edit_todos") + // Find the system_update_todos message that was added for the write-back + const todoEditMessages = savedCall.messages.filter( + (m: any) => m.type === "say" && m.say === "system_update_todos", + ) // Should have at least 2 todo edit messages (original + write-back) expect(todoEditMessages.length).toBeGreaterThanOrEqual(1) @@ -627,7 +629,7 @@ describe("History resume delegation - parent metadata transitions", () => { const parentMessagesWithTodos = [ { type: "say", - say: "user_edit_todos", + say: "system_update_todos", text: JSON.stringify({ tool: "updateTodoList", todos: [{ id: "todo-1", content: "Some task", status: "completed" }], @@ -650,7 +652,9 @@ describe("History resume delegation - parent metadata transitions", () => { const savedCall = vi.mocked(saveTaskMessages).mock.calls[0][0] // Find todo edit messages (if any were added beyond the original) - const todoEditMessages = savedCall.messages.filter((m: any) => m.type === "say" && m.say === "user_edit_todos") + const todoEditMessages = savedCall.messages.filter( + (m: any) => m.type === "say" && m.say === "system_update_todos", + ) // Should only have the original todo edit, no write-back because child isn't in childIds // The fallback should NOT be triggered for an unrelated child diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 77dfeb86d18..d6aab711ef9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3223,7 +3223,7 @@ export class ClineProvider { ts: Date.now(), type: "say", - say: "user_edit_todos", + say: "system_update_todos", text: JSON.stringify({ tool: "updateTodoList", todos, @@ -3376,7 +3376,7 @@ export class ClineProvider parentClineMessages.push({ ts: Date.now(), type: "say", - say: "user_edit_todos", + say: "system_update_todos", text: JSON.stringify({ tool: "updateTodoList", todos, diff --git a/src/shared/todo.ts b/src/shared/todo.ts index d20539049b0..81e4559a93e 100644 --- a/src/shared/todo.ts +++ b/src/shared/todo.ts @@ -4,7 +4,8 @@ export function getLatestTodo(clineMessages: ClineMessage[]) { const todos = clineMessages .filter( (msg) => - (msg.type === "ask" && msg.ask === "tool") || (msg.type === "say" && msg.say === "user_edit_todos"), + (msg.type === "ask" && msg.ask === "tool") || + (msg.type === "say" && (msg.say === "user_edit_todos" || msg.say === "system_update_todos")), ) .map((msg) => { try { diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 24749bb4191..9d9964d43d9 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1418,6 +1418,8 @@ export const ChatRowContent = ({ return case "user_edit_todos": return {}} /> + case "system_update_todos": + return {}} /> case "tool" as any: // Handle say tool messages const sayTool = safeJsonParse(message.text) diff --git a/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx b/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx index e8512845054..310b8cd518a 100644 --- a/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx +++ b/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx @@ -179,28 +179,6 @@ const UpdateTodoListToolBlock: React.FC = ({ Todo List Updated
      - {editable && ( - - )}
      From 25848e6df6966591529671fa494b2009c08fdc75 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Sat, 17 Jan 2026 17:52:59 -0500 Subject: [PATCH 06/17] fix: compute subtask tooltip total from todos Use todo list costs (source of truth) to derive subtasks total in TaskHeader tooltip, de-duping by subtaskId and falling back to history-derived subtaskDetails when needed. --- webview-ui/src/components/chat/TaskHeader.tsx | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 133a38a35f2..56dfb77cd5a 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -131,14 +131,34 @@ const TaskHeader = ({ const hasTodos = todos && Array.isArray(todos) && todos.length > 0 const subtaskCosts = useMemo(() => { - if (!subtaskDetails || subtaskDetails.length === 0) { - return [] + const processedSubtasks = new Set() + const costs: number[] = [] + + const maybeAddCost = (subtaskId: unknown, cost: unknown) => { + if (typeof subtaskId !== "string" || subtaskId.length === 0) return + if (processedSubtasks.has(subtaskId)) return + if (typeof cost !== "number" || !Number.isFinite(cost) || cost <= 0) return + + processedSubtasks.add(subtaskId) + costs.push(cost) + } + + // Primary source of truth: visible todos. + if (Array.isArray(todos)) { + for (const todo of todos) { + maybeAddCost((todo as any)?.subtaskId, (todo as any)?.cost) + } + } + + // Fallback: any remaining subtasks from history-derived details. + if (Array.isArray(subtaskDetails)) { + for (const subtask of subtaskDetails) { + maybeAddCost(subtask.id, subtask.cost) + } } - return subtaskDetails - .map((subtask) => subtask.cost) - .filter((cost): cost is number => typeof cost === "number" && Number.isFinite(cost)) - }, [subtaskDetails]) + return costs + }, [todos, subtaskDetails]) const tooltipCostData = useMemo( () => From 5bead50cedea8950fcd77fe525832fe3271fbf88 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Sat, 17 Jan 2026 18:34:38 -0500 Subject: [PATCH 07/17] fix: hide system_update_todos messages from chat UI System-generated todo updates (e.g. cost syncs) will now be processed silently without showing a 'Todo List Updated' notification, reducing chat noise. User edits remain visible. --- webview-ui/src/components/chat/ChatRow.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 9d9964d43d9..89d817e4aa9 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -127,13 +127,19 @@ interface ChatRowContentProps extends Omit {} const ChatRow = memo( (props: ChatRowProps) => { const { isLast, onHeightChange, message } = props + + // Some message types update state but should not be visible in the chat history. + // We still render a row for Virtuoso compatibility, but hide it so it takes up no space. + const shouldHideRow = message.type === "say" && message.say === "system_update_todos" // Store the previous height to compare with the current height // This allows us to detect changes without causing re-renders const prevHeightRef = useRef(0) const [chatrow, { height }] = useSize( -
      - +
      + {shouldHideRow ? null : }
      , ) @@ -1419,7 +1425,7 @@ export const ChatRowContent = ({ case "user_edit_todos": return {}} /> case "system_update_todos": - return {}} /> + return null case "tool" as any: // Handle say tool messages const sayTool = safeJsonParse(message.text) From 188a1c6e8ebd897f272ecda11702aacf9aec33da Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Sun, 18 Jan 2026 00:31:41 -0500 Subject: [PATCH 08/17] fix: preserve todo metadata on updateTodoList Prefer history todos when they contain subtaskId/tokens/cost so updateTodoList preserves injected metadata. Adds regression test covering history-vs-memory selection. --- src/core/tools/UpdateTodoListTool.ts | 22 ++++++-- .../__tests__/updateTodoListTool.spec.ts | 54 ++++++++++++++++++- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/core/tools/UpdateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts index 068d473b538..a895f08ca76 100644 --- a/src/core/tools/UpdateTodoListTool.ts +++ b/src/core/tools/UpdateTodoListTool.ts @@ -26,11 +26,23 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { const { pushToolResult, handleError, askApproval, toolProtocol } = callbacks try { - // Pull the previous todo list so we can preserve metadata fields across update_todo_list calls. - // Prefer the in-memory task.todoList when available; otherwise fall back to the latest todo list - // stored in the conversation history. - const previousTodos = - getTodoListForTask(task) ?? (getLatestTodo(task.clineMessages) as unknown as TodoItem[]) + const previousFromMemory = getTodoListForTask(task) + const previousFromHistory = getLatestTodo(task.clineMessages) as unknown as TodoItem[] | undefined + + const historyHasMetadata = + Array.isArray(previousFromHistory) && + previousFromHistory.some( + (t) => t?.subtaskId !== undefined || t?.tokens !== undefined || t?.cost !== undefined, + ) + + const previousTodos: TodoItem[] = + (previousFromMemory?.length ?? 0) === 0 + ? (previousFromHistory ?? []) + : (previousFromHistory?.length ?? 0) === 0 + ? (previousFromMemory ?? []) + : historyHasMetadata + ? (previousFromHistory ?? []) + : (previousFromMemory ?? []) const todosRaw = params.todos diff --git a/src/core/tools/__tests__/updateTodoListTool.spec.ts b/src/core/tools/__tests__/updateTodoListTool.spec.ts index ebe0500d665..8ed33c10e57 100644 --- a/src/core/tools/__tests__/updateTodoListTool.spec.ts +++ b/src/core/tools/__tests__/updateTodoListTool.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest" -import { parseMarkdownChecklist } from "../UpdateTodoListTool" +import { parseMarkdownChecklist, UpdateTodoListTool, setPendingTodoList } from "../UpdateTodoListTool" import { TodoItem } from "@roo-code/types" describe("parseMarkdownChecklist", () => { @@ -241,3 +241,55 @@ Just some text }) }) }) + +describe("UpdateTodoListTool.execute", () => { + beforeEach(() => { + setPendingTodoList([]) + }) + + it("should prefer history todos when they contain metadata (subtaskId/tokens/cost)", async () => { + const md = "[ ] Task 1" + + const previousFromMemory = parseMarkdownChecklist(md) + const previousFromHistory: TodoItem[] = previousFromMemory.map((t) => ({ + ...t, + subtaskId: "subtask-1", + tokens: 123, + cost: 0.01, + })) + + const task = { + todoList: previousFromMemory, + clineMessages: [ + { + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "updateTodoList", todos: previousFromHistory }), + }, + ], + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + say: vi.fn(), + } as any + + const tool = new UpdateTodoListTool() + await tool.execute({ todos: md }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockResolvedValue(true), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + }) + + expect(task.todoList).toHaveLength(1) + expect(task.todoList[0]).toEqual( + expect.objectContaining({ + content: "Task 1", + subtaskId: "subtask-1", + tokens: 123, + cost: 0.01, + }), + ) + }) +}) From 788a21a192b190882762178a2695f9a1239a7c15 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Sun, 18 Jan 2026 03:20:07 -0500 Subject: [PATCH 09/17] feat(webview): add edit toggle for todo updates Extract SubtaskDetail into a shared type and remove the unused SubtaskCostList component. --- webview-ui/src/components/chat/ChatView.tsx | 14 +- .../src/components/chat/SubtaskCostList.tsx | 123 ------------------ webview-ui/src/components/chat/TaskHeader.tsx | 2 +- .../src/components/chat/TodoListDisplay.tsx | 2 +- .../chat/UpdateTodoListToolBlock.tsx | 22 ++++ .../chat/__tests__/TodoListDisplay.spec.tsx | 2 +- webview-ui/src/types/subtasks.ts | 13 ++ 7 files changed, 39 insertions(+), 139 deletions(-) delete mode 100644 webview-ui/src/components/chat/SubtaskCostList.tsx create mode 100644 webview-ui/src/types/subtasks.ts diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index ab232a072d2..d222c8c5046 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -48,19 +48,7 @@ import { QueuedMessages } from "./QueuedMessages" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" import { Cloud } from "lucide-react" - -/** - * Detailed information about a subtask for UI display. - * Matches the SubtaskDetail interface from backend aggregateTaskCosts.ts - */ -interface SubtaskDetail { - id: string // Task ID - name: string // First 50 chars of task description - tokens: number // tokensIn + tokensOut - cost: number // Aggregated total cost - status: "active" | "completed" | "delegated" - hasNestedChildren: boolean // Has its own subtasks -} +import type { SubtaskDetail } from "@src/types/subtasks" export interface ChatViewProps { isHidden: boolean diff --git a/webview-ui/src/components/chat/SubtaskCostList.tsx b/webview-ui/src/components/chat/SubtaskCostList.tsx deleted file mode 100644 index 7062f1b36af..00000000000 --- a/webview-ui/src/components/chat/SubtaskCostList.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { memo, useState } from "react" -import { useTranslation } from "react-i18next" -import { ChevronRight, ChevronDown } from "lucide-react" - -import { cn } from "@/lib/utils" -import { formatLargeNumber } from "@/utils/format" - -export interface SubtaskDetail { - id: string - name: string - tokens: number - cost: number - status: "active" | "completed" | "delegated" - hasNestedChildren: boolean -} - -export interface SubtaskCostListProps { - subtasks: SubtaskDetail[] - onSubtaskClick?: (subtaskId: string) => void -} - -interface SubtaskRowProps { - subtask: SubtaskDetail - isLast: boolean - onClick?: () => void - t: (key: string, options?: Record) => string -} - -const statusColors: Record = { - active: "bg-vscode-testing-iconQueued", - completed: "bg-vscode-testing-iconPassed", - delegated: "bg-vscode-testing-iconSkipped", -} - -const SubtaskRow = memo(({ subtask, isLast, onClick, t }: SubtaskRowProps) => { - return ( - - ) -}) - -SubtaskRow.displayName = "SubtaskRow" - -export const SubtaskCostList = memo(({ subtasks, onSubtaskClick }: SubtaskCostListProps) => { - const { t } = useTranslation("chat") - const [isExpanded, setIsExpanded] = useState(false) - - if (!subtasks || subtasks.length === 0) { - return null - } - - return ( -
      - {/* Collapsible Header */} - - - {/* Expanded Subtask List */} - {isExpanded && ( -
      - {subtasks.map((subtask, index) => ( - onSubtaskClick?.(subtask.id)} - t={t} - /> - ))} -
      - )} -
      - ) -}) - -SubtaskCostList.displayName = "SubtaskCostList" - -export default SubtaskCostList diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 56dfb77cd5a..c6890e884e9 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -35,7 +35,7 @@ import { ContextWindowProgress } from "./ContextWindowProgress" import { Mention } from "./Mention" import { TodoListDisplay } from "./TodoListDisplay" import { LucideIconButton } from "./LucideIconButton" -import type { SubtaskDetail } from "./SubtaskCostList" +import type { SubtaskDetail } from "@src/types/subtasks" export interface TaskHeaderProps { task: ClineMessage diff --git a/webview-ui/src/components/chat/TodoListDisplay.tsx b/webview-ui/src/components/chat/TodoListDisplay.tsx index 89eb1151e86..684ff82feb7 100644 --- a/webview-ui/src/components/chat/TodoListDisplay.tsx +++ b/webview-ui/src/components/chat/TodoListDisplay.tsx @@ -5,7 +5,7 @@ import { useState, useRef, useMemo, useEffect } from "react" import { formatLargeNumber } from "@src/utils/format" -import type { SubtaskDetail } from "./SubtaskCostList" +import type { SubtaskDetail } from "@src/types/subtasks" type TodoStatus = "completed" | "in_progress" | "pending" diff --git a/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx b/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx index 310b8cd518a..e8512845054 100644 --- a/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx +++ b/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx @@ -179,6 +179,28 @@ const UpdateTodoListToolBlock: React.FC = ({ Todo List Updated
      + {editable && ( + + )}
      diff --git a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx index 74933bbc95c..f5babe983c5 100644 --- a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest" import { render, screen, fireEvent } from "@testing-library/react" import { TodoListDisplay } from "../TodoListDisplay" -import type { SubtaskDetail } from "../SubtaskCostList" +import type { SubtaskDetail } from "@src/types/subtasks" // Mock i18next vi.mock("i18next", () => ({ diff --git a/webview-ui/src/types/subtasks.ts b/webview-ui/src/types/subtasks.ts new file mode 100644 index 00000000000..a4cd97ed4f9 --- /dev/null +++ b/webview-ui/src/types/subtasks.ts @@ -0,0 +1,13 @@ +export type SubtaskDetail = { + /** Task ID */ + id: string + /** First 50 chars of task description */ + name: string + /** tokensIn + tokensOut */ + tokens: number + /** Aggregated total cost */ + cost: number + status: "active" | "completed" | "delegated" + /** Has its own subtasks */ + hasNestedChildren: boolean +} From 3445ca1ecdb196de14a3073fec78df3bee93ee40 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Sun, 18 Jan 2026 19:03:12 -0500 Subject: [PATCH 10/17] fix: await subtask usage persistence before roll-up Ensure delegation roll-up reads final persisted cost/tokens by awaiting background usage collection before reopening parent. Add regression test covering late usage persistence ordering. --- .../history-resume-delegation.spec.ts | 116 +++++++++++++++++- src/core/task/Task.ts | 41 ++++++- src/core/tools/AttemptCompletionTool.ts | 3 + 3 files changed, 152 insertions(+), 8 deletions(-) diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index 93bff631f62..233e7ece158 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -3,6 +3,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { RooCodeEventName } from "@roo-code/types" +// Keep AttemptCompletionTool tests deterministic (TelemetryService can be undefined in unit test env) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { captureTaskCompleted: vi.fn() }, + }, +})) + /* vscode mock for Task/Provider imports */ vi.mock("vscode", () => { const window = { @@ -374,13 +381,15 @@ describe("History resume delegation - parent metadata transitions", () => { }) // Verify both events emitted - const eventNames = emitSpy.mock.calls.map((c) => c[0]) + const eventNames = emitSpy.mock.calls.map((c: any[]) => c[0]) expect(eventNames).toContain(RooCodeEventName.TaskDelegationCompleted) expect(eventNames).toContain(RooCodeEventName.TaskDelegationResumed) // CRITICAL: verify ordering (TaskDelegationCompleted before TaskDelegationResumed) - const completedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationCompleted) - const resumedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationResumed) + const completedIdx = emitSpy.mock.calls.findIndex( + (c: any[]) => c[0] === RooCodeEventName.TaskDelegationCompleted, + ) + const resumedIdx = emitSpy.mock.calls.findIndex((c: any[]) => c[0] === RooCodeEventName.TaskDelegationResumed) expect(completedIdx).toBeGreaterThanOrEqual(0) expect(resumedIdx).toBeGreaterThan(completedIdx) }) @@ -424,7 +433,7 @@ describe("History resume delegation - parent metadata transitions", () => { }) // CRITICAL: verify legacy pause/unpause events NOT emitted - const eventNames = emitSpy.mock.calls.map((c) => c[0]) + const eventNames = emitSpy.mock.calls.map((c: any[]) => c[0]) expect(eventNames).not.toContain(RooCodeEventName.TaskPaused) expect(eventNames).not.toContain(RooCodeEventName.TaskUnpaused) expect(eventNames).not.toContain(RooCodeEventName.TaskSpawned) @@ -666,4 +675,103 @@ describe("History resume delegation - parent metadata transitions", () => { expect(orphanLinked).toBeUndefined() } }) + + it("subtask completion awaits late usage persistence before delegating (parent sees final cost)", async () => { + const parentTaskId = "p-late-cost" + const childTaskId = "c-late-cost" + + // Seed parent messages with a todo linked to the child + const todos = [{ id: "t1", content: "do subtask", status: "in_progress", subtaskId: childTaskId }] + const seededParentMessages = [ + { type: "say", say: "system_update_todos", text: JSON.stringify({ tool: "updateTodoList", todos }), ts: 1 }, + ] as any + vi.mocked(readTaskMessages).mockResolvedValue(seededParentMessages) + vi.mocked(readApiMessages).mockResolvedValue([] as any) + + // Parent history confirms relationship + const parentHistory = { + id: parentTaskId, + status: "delegated", + awaitingChildId: childTaskId, + childIds: [childTaskId], + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } as any + + // Child history initially stale cost + let childHistory = { + id: childTaskId, + status: "active", + tokensIn: 10, + tokensOut: 5, + totalCost: 0, + ts: 2, + task: "Child", + } as any + + const reopenSpy = vi.fn().mockResolvedValue(undefined) + const provider: any = { + contextProxy: { globalStorageUri: { fsPath: "/storage" } }, + getTaskWithId: vi.fn(async (id: string) => { + if (id === parentTaskId) return { historyItem: parentHistory } + if (id === childTaskId) return { historyItem: childHistory } + throw new Error("unknown") + }), + reopenParentFromDelegation: reopenSpy, + } + + const childTask: any = { + parentTaskId, + taskId: childTaskId, + providerRef: { deref: () => provider }, + didToolFailInCurrentTurn: false, + todoList: undefined, + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + say: vi.fn(), + emitFinalTokenUsageUpdate: vi.fn(), + getTokenUsage: () => ({}) as any, + toolUsage: {}, + emit: vi.fn(), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + waitForPendingUsageCollection: vi.fn(async () => { + childHistory = { ...childHistory, totalCost: 1.23 } + }), + } + + const { attemptCompletionTool } = await import("../core/tools/AttemptCompletionTool") + const askFinishSubTaskApproval = vi.fn().mockResolvedValue(true) + const handleError = vi.fn((_context: string, error: Error) => { + // Fail loudly if AttemptCompletionTool hits its catch block + throw error + }) + await attemptCompletionTool.execute({ result: "done" }, childTask, { + askApproval: vi.fn(), + handleError, + pushToolResult: vi.fn(), + removeClosingTag: vi.fn((_: any, s: any) => s), + askFinishSubTaskApproval, + toolDescription: vi.fn(), + toolProtocol: "native", + } as any) + + // Ensure we actually awaited and hit the delegation decision point + expect(childTask.waitForPendingUsageCollection).toHaveBeenCalled() + expect(provider.getTaskWithId).toHaveBeenCalledWith(childTaskId) + expect(askFinishSubTaskApproval).toHaveBeenCalled() + expect(reopenSpy).toHaveBeenCalledOnce() + + // Critical ordering: waitForPendingUsageCollection must run before delegation. + const waitCall = childTask.waitForPendingUsageCollection.mock.invocationCallOrder[0] + const reopenCall = reopenSpy.mock.invocationCallOrder[0] + expect(waitCall).toBeLessThan(reopenCall) + + // Parent roll-up reads the child's persisted history; this ensures cost was finalized + // before delegation begins (the bug fix). + expect(childHistory.totalCost).toBe(1.23) + expect(childHistory.tokensIn + childHistory.tokensOut).toBe(15) + }) }) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index af7aed86d58..0c1d71b2a4d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -384,6 +384,15 @@ export class Task extends EventEmitter implements TaskLike { presentAssistantMessageHasPendingUpdates = false userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = [] userMessageContentReady = false + /** + * When an LLM stream ends, some providers may emit usage/cost information in trailing chunks. + * We drain the iterator in the background to capture those, which can complete *after* a + * subtask calls attempt_completion. + * + * This promise tracks the currently-running background usage collection so callers (notably + * delegation/roll-up logic) can await final persisted cost/tokens before reading history. + */ + private pendingUsageCollectionPromise?: Promise /** * Push a tool_result block to userMessageContent, preventing duplicates. @@ -1224,6 +1233,23 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Best-effort wait for any in-flight background usage collection to finish. + * + * This is critical when completing subtasks: the parent roll-up reads cost/tokens + * from the child's persisted history item, which is updated by `saveClineMessages()`. + */ + public async waitForPendingUsageCollection(timeoutMs: number = DEFAULT_USAGE_COLLECTION_TIMEOUT_MS): Promise { + const pending = this.pendingUsageCollectionPromise + if (!pending) return + + try { + await Promise.race([pending, delay(timeoutMs)]) + } catch { + // Non-fatal: background usage collection already logs errors. + } + } + private findMessageByTimestamp(ts: number): ClineMessage | undefined { for (let i = this.clineMessages.length - 1; i >= 0; i--) { if (this.clineMessages[i].ts === ts) { @@ -3256,10 +3282,17 @@ export class Task extends EventEmitter implements TaskLike { } } - // Start the background task and handle any errors - drainStreamInBackgroundToFindAllUsage(lastApiReqIndex).catch((error) => { - console.error("Background usage collection failed:", error) - }) + // Start the background task and handle any errors. + // IMPORTANT: keep a reference so completion/delegation can await final persisted usage/cost. + this.pendingUsageCollectionPromise = drainStreamInBackgroundToFindAllUsage(lastApiReqIndex) + .catch((error) => { + console.error("Background usage collection failed:", error) + }) + .finally(() => { + if (this.pendingUsageCollectionPromise) { + this.pendingUsageCollectionPromise = undefined + } + }) } catch (error) { // Abandoned happens when extension is no longer waiting for the // Cline instance to finish aborting (error is thrown here when diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 039036f829c..cb3f8df77b1 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -91,6 +91,9 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { // This ensures the most recent stats are captured regardless of throttle timer // and properly updates the snapshot to prevent redundant emissions task.emitFinalTokenUsageUpdate() + // Ensure any trailing usage/cost emitted after stream completion is persisted + // before delegation/roll-up reads the child's history item. + await task.waitForPendingUsageCollection() TelemetryService.instance.captureTaskCompleted(task.taskId) task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage) From 235cffad3b217f82d9441792b516ab5c0fcd664f Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Sun, 18 Jan 2026 23:01:19 -0500 Subject: [PATCH 11/17] feat: track line changes for delegated subtasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute per-task lines added/removed from tool diffStats, persist on history items, and aggregate recursively across descendants.\n\nRender +/− in todo rows, task header, and expanded task details with diff-like green/red styling. Update i18n labels across locales and adjust layout/alignment.\n\nRelates to PR #10765 --- packages/types/src/history.ts | 2 + packages/types/src/todo.ts | 2 + .../__tests__/taskMetadata.spec.ts | 125 ++++++++++++++++++ src/core/task-persistence/taskMetadata.ts | 68 ++++++++++ src/core/webview/ClineProvider.ts | 2 + .../__tests__/aggregateTaskCosts.spec.ts | 86 ++++++++++++ src/core/webview/aggregateTaskCosts.ts | 46 ++++++- webview-ui/src/components/chat/TaskHeader.tsx | 86 +++++++++++- .../src/components/chat/TodoListDisplay.tsx | 51 +++++-- .../chat/__tests__/TodoListDisplay.spec.tsx | 98 +++++++++++++- webview-ui/src/i18n/locales/ca/common.json | 5 +- webview-ui/src/i18n/locales/de/common.json | 5 +- webview-ui/src/i18n/locales/en/chat.json | 1 + webview-ui/src/i18n/locales/en/common.json | 5 +- webview-ui/src/i18n/locales/es/common.json | 5 +- webview-ui/src/i18n/locales/fr/common.json | 5 +- webview-ui/src/i18n/locales/hi/common.json | 5 +- webview-ui/src/i18n/locales/id/common.json | 5 +- webview-ui/src/i18n/locales/it/common.json | 5 +- webview-ui/src/i18n/locales/ja/common.json | 5 +- webview-ui/src/i18n/locales/ko/common.json | 5 +- webview-ui/src/i18n/locales/nl/common.json | 5 +- webview-ui/src/i18n/locales/pl/common.json | 5 +- webview-ui/src/i18n/locales/pt-BR/common.json | 5 +- webview-ui/src/i18n/locales/ru/common.json | 5 +- webview-ui/src/i18n/locales/tr/common.json | 5 +- webview-ui/src/i18n/locales/vi/common.json | 5 +- webview-ui/src/i18n/locales/zh-CN/common.json | 5 +- webview-ui/src/i18n/locales/zh-TW/common.json | 5 +- webview-ui/src/types/subtasks.ts | 4 + 30 files changed, 629 insertions(+), 32 deletions(-) create mode 100644 src/core/task-persistence/__tests__/taskMetadata.spec.ts diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index b4d84cb9a51..06e246e0dcb 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -16,6 +16,8 @@ export const historyItemSchema = z.object({ cacheWrites: z.number().optional(), cacheReads: z.number().optional(), totalCost: z.number(), + linesAdded: z.number().optional(), + linesRemoved: z.number().optional(), size: z.number().optional(), workspace: z.string().optional(), mode: z.string().optional(), diff --git a/packages/types/src/todo.ts b/packages/types/src/todo.ts index 0530f920542..328beeb359f 100644 --- a/packages/types/src/todo.ts +++ b/packages/types/src/todo.ts @@ -18,6 +18,8 @@ export const todoItemSchema = z.object({ subtaskId: z.string().optional(), // ID of the linked subtask (child task) for direct cost/token attribution tokens: z.number().optional(), // Total tokens (in + out) for linked subtask cost: z.number().optional(), // Total cost for linked subtask + added: z.number().optional(), + removed: z.number().optional(), }) export type TodoItem = z.infer diff --git a/src/core/task-persistence/__tests__/taskMetadata.spec.ts b/src/core/task-persistence/__tests__/taskMetadata.spec.ts new file mode 100644 index 00000000000..52f8ec2a0d0 --- /dev/null +++ b/src/core/task-persistence/__tests__/taskMetadata.spec.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +// Hoisted mocks to avoid initialization ordering issues +const hoisted = vi.hoisted(() => ({ + getTaskDirectoryPathMock: vi.fn().mockResolvedValue("/mock/task/dir"), + getFolderSizeLooseMock: vi.fn().mockResolvedValue(0), + getApiMetricsMock: vi.fn().mockReturnValue({ + totalTokensIn: 0, + totalTokensOut: 0, + totalCacheWrites: 0, + totalCacheReads: 0, + totalCost: 0, + contextTokens: 0, + }), +})) + +vi.mock("get-folder-size", () => ({ + default: { + loose: hoisted.getFolderSizeLooseMock, + }, +})) + +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: hoisted.getTaskDirectoryPathMock, +})) + +vi.mock("../../../shared/getApiMetrics", () => ({ + getApiMetrics: hoisted.getApiMetricsMock, +})) + +// Import after mocks +import { taskMetadata } from "../taskMetadata" + +describe("taskMetadata() line change parsing", () => { + beforeEach(() => { + hoisted.getTaskDirectoryPathMock.mockClear() + hoisted.getFolderSizeLooseMock.mockClear() + hoisted.getApiMetricsMock.mockClear() + }) + + it("computes linesAdded/linesRemoved from tool message diffStats", async () => { + const result = await taskMetadata({ + taskId: "task-1", + taskNumber: 1, + globalStoragePath: "/mock/global", + workspace: "/mock/workspace", + messages: [ + { ts: 1, type: "say", say: "text", text: "Task" } as any, + { + ts: 2, + type: "ask", + ask: "tool", + text: JSON.stringify({ diffStats: { added: 5, removed: 2 } }), + } as any, + ], + }) + + expect(result.historyItem.linesAdded).toBe(5) + expect(result.historyItem.linesRemoved).toBe(2) + }) + + it("aggregates linesAdded/linesRemoved from batch tool message batchDiffs[].diffStats", async () => { + const result = await taskMetadata({ + taskId: "task-2", + taskNumber: 2, + globalStoragePath: "/mock/global", + workspace: "/mock/workspace", + messages: [ + { ts: 1, type: "say", say: "text", text: "Task" } as any, + { + ts: 2, + type: "ask", + ask: "tool", + text: JSON.stringify({ + batchDiffs: [ + { path: "a.ts", diffStats: { added: 1, removed: 1 } }, + { path: "b.ts", diffStats: { added: 2, removed: 3 } }, + ], + }), + } as any, + ], + }) + + expect(result.historyItem.linesAdded).toBe(3) + expect(result.historyItem.linesRemoved).toBe(4) + }) + + it("ignores partial tool messages", async () => { + const result = await taskMetadata({ + taskId: "task-3", + taskNumber: 3, + globalStoragePath: "/mock/global", + workspace: "/mock/workspace", + messages: [ + { ts: 1, type: "say", say: "text", text: "Task" } as any, + { + ts: 2, + type: "ask", + ask: "tool", + partial: true, + text: JSON.stringify({ diffStats: { added: 10, removed: 10 } }), + } as any, + ], + }) + + expect(result.historyItem.linesAdded).toBeUndefined() + expect(result.historyItem.linesRemoved).toBeUndefined() + }) + + it("ignores invalid JSON in tool message text gracefully", async () => { + const result = await taskMetadata({ + taskId: "task-4", + taskNumber: 4, + globalStoragePath: "/mock/global", + workspace: "/mock/workspace", + messages: [ + { ts: 1, type: "say", say: "text", text: "Task" } as any, + { ts: 2, type: "ask", ask: "tool", text: "{not-json" } as any, + ], + }) + + expect(result.historyItem.linesAdded).toBeUndefined() + expect(result.historyItem.linesRemoved).toBeUndefined() + }) +}) diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index cf8d9adb529..92607595a98 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -12,6 +12,64 @@ import { t } from "../../i18n" const taskSizeCache = new NodeCache({ stdTTL: 30, checkperiod: 5 * 60 }) +type DiffStats = { added: number; removed: number } + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) +} + +function isDiffStats(value: unknown): value is DiffStats { + if (!value || typeof value !== "object") return false + + const v = value as { added?: unknown; removed?: unknown } + return isFiniteNumber(v.added) && isFiniteNumber(v.removed) +} + +function getLineStatsFromToolApprovalMessages(messages: ClineMessage[]): { + linesAdded: number + linesRemoved: number + foundAnyStats: boolean +} { + let linesAdded = 0 + let linesRemoved = 0 + let foundAnyStats = false + + for (const m of messages) { + // Only count complete tool approval asks (avoid double-counting partial/streaming updates) + if (!(m.type === "ask" && m.ask === "tool" && m.partial !== true)) continue + if (typeof m.text !== "string" || m.text.length === 0) continue + + let payload: unknown + try { + payload = JSON.parse(m.text) + } catch { + continue + } + + if (!payload || typeof payload !== "object") continue + const p = payload as { diffStats?: unknown; batchDiffs?: unknown } + + if (isDiffStats(p.diffStats)) { + linesAdded += p.diffStats.added + linesRemoved += p.diffStats.removed + foundAnyStats = true + } + + if (Array.isArray(p.batchDiffs)) { + for (const batchDiff of p.batchDiffs) { + if (!batchDiff || typeof batchDiff !== "object") continue + const bd = batchDiff as { diffStats?: unknown } + if (!isDiffStats(bd.diffStats)) continue + linesAdded += bd.diffStats.added + linesRemoved += bd.diffStats.removed + foundAnyStats = true + } + } + } + + return { linesAdded, linesRemoved, foundAnyStats } +} + export type TaskMetadataOptions = { taskId: string rootTaskId?: string @@ -55,6 +113,8 @@ export async function taskMetadata({ let tokenUsage: ReturnType let taskDirSize: number let taskMessage: ClineMessage | undefined + let linesAdded: number | undefined + let linesRemoved: number | undefined if (!hasMessages) { // Handle no messages case @@ -93,6 +153,12 @@ export async function taskMetadata({ } else { taskDirSize = cachedSize } + + const lineStats = getLineStatsFromToolApprovalMessages(messages) + if (lineStats.foundAnyStats) { + linesAdded = lineStats.linesAdded + linesRemoved = lineStats.linesRemoved + } } // Create historyItem once with pre-calculated values. @@ -115,6 +181,8 @@ export async function taskMetadata({ cacheWrites: tokenUsage.totalCacheWrites, cacheReads: tokenUsage.totalCacheReads, totalCost: tokenUsage.totalCost, + ...(typeof linesAdded === "number" ? { linesAdded } : {}), + ...(typeof linesRemoved === "number" ? { linesRemoved } : {}), size: taskDirSize, workspace, mode, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d6aab711ef9..d287e1c338f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3372,6 +3372,8 @@ export class ClineProvider if (linkedTodo) { linkedTodo.tokens = (childHistoryItem?.tokensIn || 0) + (childHistoryItem?.tokensOut || 0) linkedTodo.cost = childHistoryItem?.totalCost || 0 + linkedTodo.added = childHistoryItem?.linesAdded || 0 + linkedTodo.removed = childHistoryItem?.linesRemoved || 0 parentClineMessages.push({ ts: Date.now(), diff --git a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts index eeffcebf477..000460471a6 100644 --- a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts +++ b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts @@ -53,11 +53,15 @@ describe("aggregateTaskCostsRecursive", () => { parent: { id: "parent", totalCost: 1.0, + linesAdded: 2, + linesRemoved: 1, childIds: ["child-1"], } as unknown as HistoryItem, "child-1": { id: "child-1", totalCost: 0.5, + linesAdded: 3, + linesRemoved: 2, childIds: [], } as unknown as HistoryItem, } @@ -69,6 +73,12 @@ describe("aggregateTaskCostsRecursive", () => { expect(result.ownCost).toBe(1.0) expect(result.childrenCost).toBe(0.5) expect(result.totalCost).toBe(1.5) + expect(result.ownAdded).toBe(2) + expect(result.ownRemoved).toBe(1) + expect(result.childrenAdded).toBe(3) + expect(result.childrenRemoved).toBe(2) + expect(result.totalAdded).toBe(5) + expect(result.totalRemoved).toBe(3) expect(result.childBreakdown).toHaveProperty("child-1") const child1 = result.childBreakdown?.["child-1"] expect(child1).toBeDefined() @@ -114,16 +124,22 @@ describe("aggregateTaskCostsRecursive", () => { parent: { id: "parent", totalCost: 1.0, + linesAdded: 2, + linesRemoved: 2, childIds: ["child"], } as unknown as HistoryItem, child: { id: "child", totalCost: 0.5, + linesAdded: 3, + linesRemoved: 1, childIds: ["grandchild"], } as unknown as HistoryItem, grandchild: { id: "grandchild", totalCost: 0.25, + linesAdded: 1, + linesRemoved: 4, childIds: [], } as unknown as HistoryItem, } @@ -136,12 +152,22 @@ describe("aggregateTaskCostsRecursive", () => { expect(result.childrenCost).toBe(0.75) // child (0.5) + grandchild (0.25) expect(result.totalCost).toBe(1.75) + expect(result.ownAdded).toBe(2) + expect(result.ownRemoved).toBe(2) + // children totals include all descendants + expect(result.childrenAdded).toBe(4) // child (3) + grandchild (1) + expect(result.childrenRemoved).toBe(5) // child (1) + grandchild (4) + expect(result.totalAdded).toBe(6) + expect(result.totalRemoved).toBe(7) + // Verify child breakdown const child = result.childBreakdown?.["child"] expect(child).toBeDefined() expect(child!.ownCost).toBe(0.5) expect(child!.childrenCost).toBe(0.25) expect(child!.totalCost).toBe(0.75) + expect(child!.totalAdded).toBe(4) + expect(child!.totalRemoved).toBe(5) // Verify grandchild breakdown const grandchild = child!.childBreakdown?.["grandchild"] @@ -149,6 +175,8 @@ describe("aggregateTaskCostsRecursive", () => { expect(grandchild!.ownCost).toBe(0.25) expect(grandchild!.childrenCost).toBe(0) expect(grandchild!.totalCost).toBe(0.25) + expect(grandchild!.totalAdded).toBe(1) + expect(grandchild!.totalRemoved).toBe(4) }) it("should detect and prevent circular references", async () => { @@ -156,11 +184,15 @@ describe("aggregateTaskCostsRecursive", () => { "task-a": { id: "task-a", totalCost: 1.0, + linesAdded: 2, + linesRemoved: 3, childIds: ["task-b"], } as unknown as HistoryItem, "task-b": { id: "task-b", totalCost: 0.5, + linesAdded: 4, + linesRemoved: 1, childIds: ["task-a"], // Circular reference back to task-a } as unknown as HistoryItem, } @@ -173,6 +205,8 @@ describe("aggregateTaskCostsRecursive", () => { expect(result.ownCost).toBe(1.0) expect(result.childrenCost).toBe(0.5) // Only task-b's own cost, circular ref returns 0 expect(result.totalCost).toBe(1.5) + expect(result.totalAdded).toBe(6) // task-a (2) + task-b (4) + expect(result.totalRemoved).toBe(4) // task-a (3) + task-b (1) // Verify warning was logged expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Circular reference detected: task-a")) @@ -333,11 +367,23 @@ describe("buildSubtaskDetails", () => { ownCost: 0.5, childrenCost: 0, totalCost: 0.5, + ownAdded: 0, + ownRemoved: 0, + childrenAdded: 0, + childrenRemoved: 0, + totalAdded: 10, + totalRemoved: 5, }, "child-2": { ownCost: 0.3, childrenCost: 0.2, totalCost: 0.5, + ownAdded: 0, + ownRemoved: 0, + childrenAdded: 0, + childrenRemoved: 0, + totalAdded: 1, + totalRemoved: 2, }, } @@ -369,6 +415,8 @@ describe("buildSubtaskDetails", () => { expect(child1!.name).toBe("First subtask") expect(child1!.tokens).toBe(150) // 100 + 50 expect(child1!.cost).toBe(0.5) + expect(child1!.added).toBe(10) + expect(child1!.removed).toBe(5) expect(child1!.status).toBe("completed") expect(child1!.hasNestedChildren).toBe(false) @@ -377,6 +425,8 @@ describe("buildSubtaskDetails", () => { expect(child2!.name).toBe("Second subtask with nested children") expect(child2!.tokens).toBe(300) // 200 + 100 expect(child2!.cost).toBe(0.5) + expect(child2!.added).toBe(1) + expect(child2!.removed).toBe(2) expect(child2!.status).toBe("active") expect(child2!.hasNestedChildren).toBe(true) // childrenCost > 0 }) @@ -389,6 +439,12 @@ describe("buildSubtaskDetails", () => { ownCost: 1.0, childrenCost: 0, totalCost: 1.0, + ownAdded: 0, + ownRemoved: 0, + childrenAdded: 0, + childrenRemoved: 0, + totalAdded: 0, + totalRemoved: 0, }, } @@ -418,6 +474,12 @@ describe("buildSubtaskDetails", () => { ownCost: 1.0, childrenCost: 0, totalCost: 1.0, + ownAdded: 0, + ownRemoved: 0, + childrenAdded: 0, + childrenRemoved: 0, + totalAdded: 0, + totalRemoved: 0, }, } @@ -445,11 +507,23 @@ describe("buildSubtaskDetails", () => { ownCost: 0.5, childrenCost: 0, totalCost: 0.5, + ownAdded: 0, + ownRemoved: 0, + childrenAdded: 0, + childrenRemoved: 0, + totalAdded: 0, + totalRemoved: 0, }, "missing-child": { ownCost: 0.3, childrenCost: 0, totalCost: 0.3, + ownAdded: 0, + ownRemoved: 0, + childrenAdded: 0, + childrenRemoved: 0, + totalAdded: 0, + totalRemoved: 0, }, } @@ -488,6 +562,12 @@ describe("buildSubtaskDetails", () => { ownCost: 0.5, childrenCost: 0, totalCost: 0.5, + ownAdded: 0, + ownRemoved: 0, + childrenAdded: 0, + childrenRemoved: 0, + totalAdded: 0, + totalRemoved: 0, }, } @@ -514,6 +594,12 @@ describe("buildSubtaskDetails", () => { ownCost: 0.5, childrenCost: 0, totalCost: 0.5, + ownAdded: 0, + ownRemoved: 0, + childrenAdded: 0, + childrenRemoved: 0, + totalAdded: 0, + totalRemoved: 0, }, } diff --git a/src/core/webview/aggregateTaskCosts.ts b/src/core/webview/aggregateTaskCosts.ts index f85d7176a95..2d163be9c96 100644 --- a/src/core/webview/aggregateTaskCosts.ts +++ b/src/core/webview/aggregateTaskCosts.ts @@ -8,6 +8,8 @@ export interface SubtaskDetail { name: string // First 50 chars of task description tokens: number // tokensIn + tokensOut cost: number // Aggregated total cost + added: number // Aggregated total lines added + removed: number // Aggregated total lines removed status: "active" | "completed" | "delegated" hasNestedChildren: boolean // Has its own subtasks } @@ -16,6 +18,12 @@ export interface AggregatedCosts { ownCost: number // This task's own API costs childrenCost: number // Sum of all direct children costs (recursive) totalCost: number // ownCost + childrenCost + ownAdded: number // This task's own lines added + ownRemoved: number // This task's own lines removed + childrenAdded: number // Sum of all descendant lines added + childrenRemoved: number // Sum of all descendant lines removed + totalAdded: number // ownAdded + childrenAdded + totalRemoved: number // ownRemoved + childrenRemoved childBreakdown?: { // Optional detailed breakdown [childId: string]: AggregatedCosts @@ -39,7 +47,17 @@ export async function aggregateTaskCostsRecursive( // Prevent infinite loops if (visited.has(taskId)) { console.warn(`[aggregateTaskCostsRecursive] Circular reference detected: ${taskId}`) - return { ownCost: 0, childrenCost: 0, totalCost: 0 } + return { + ownCost: 0, + childrenCost: 0, + totalCost: 0, + ownAdded: 0, + ownRemoved: 0, + childrenAdded: 0, + childrenRemoved: 0, + totalAdded: 0, + totalRemoved: 0, + } } visited.add(taskId) @@ -47,11 +65,25 @@ export async function aggregateTaskCostsRecursive( const history = await getTaskHistory(taskId) if (!history) { console.warn(`[aggregateTaskCostsRecursive] Task ${taskId} not found`) - return { ownCost: 0, childrenCost: 0, totalCost: 0 } + return { + ownCost: 0, + childrenCost: 0, + totalCost: 0, + ownAdded: 0, + ownRemoved: 0, + childrenAdded: 0, + childrenRemoved: 0, + totalAdded: 0, + totalRemoved: 0, + } } const ownCost = history.totalCost || 0 + const ownAdded = history.linesAdded || 0 + const ownRemoved = history.linesRemoved || 0 let childrenCost = 0 + let childrenAdded = 0 + let childrenRemoved = 0 const childBreakdown: { [childId: string]: AggregatedCosts } = {} // Recursively aggregate child costs @@ -63,6 +95,8 @@ export async function aggregateTaskCostsRecursive( new Set(visited), // Create new Set to allow sibling traversal ) childrenCost += childAggregated.totalCost + childrenAdded += childAggregated.totalAdded + childrenRemoved += childAggregated.totalRemoved childBreakdown[childId] = childAggregated } } @@ -71,6 +105,12 @@ export async function aggregateTaskCostsRecursive( ownCost, childrenCost, totalCost: ownCost + childrenCost, + ownAdded, + ownRemoved, + childrenAdded, + childrenRemoved, + totalAdded: ownAdded + childrenAdded, + totalRemoved: ownRemoved + childrenRemoved, childBreakdown, } @@ -104,6 +144,8 @@ export async function buildSubtaskDetails( name: truncateTaskName(history.task, 50), tokens: (history.tokensIn || 0) + (history.tokensOut || 0), cost: costs.totalCost, + added: costs.totalAdded, + removed: costs.totalRemoved, status: history.status || "completed", hasNestedChildren: costs.childrenCost > 0, }) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index c6890e884e9..a1f50f85602 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -85,7 +85,7 @@ const TaskHeader = ({ ? (() => { const lastRelevantIndex = findLastIndex( clineMessages, - (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), + (m) => !((m as any)?.ask === "resume_task" || (m as any)?.ask === "resume_completed_task"), ) return lastRelevantIndex !== -1 ? clineMessages[lastRelevantIndex]?.ask === "completion_result" @@ -160,6 +160,50 @@ const TaskHeader = ({ return costs }, [todos, subtaskDetails]) + const aggregatedLineChanges = useMemo(() => { + const ownAdded = (currentTaskItem as any)?.linesAdded + const ownRemoved = (currentTaskItem as any)?.linesRemoved + + const processedSubtasks = new Set() + let childrenAdded = 0 + let childrenRemoved = 0 + + if (Array.isArray(subtaskDetails)) { + for (const subtask of subtaskDetails) { + if (!subtask?.id || typeof subtask.id !== "string") continue + if (processedSubtasks.has(subtask.id)) continue + processedSubtasks.add(subtask.id) + + if (typeof subtask.added === "number" && Number.isFinite(subtask.added)) { + childrenAdded += subtask.added + } + if (typeof subtask.removed === "number" && Number.isFinite(subtask.removed)) { + childrenRemoved += subtask.removed + } + } + } + + const totalAdded = (typeof ownAdded === "number" && Number.isFinite(ownAdded) ? ownAdded : 0) + childrenAdded + const totalRemoved = + (typeof ownRemoved === "number" && Number.isFinite(ownRemoved) ? ownRemoved : 0) + childrenRemoved + + const hasAdded = totalAdded > 0 + const hasRemoved = totalRemoved > 0 + const hasAnyLineChanges = hasAdded || hasRemoved + const formatted = [hasAdded ? `+${totalAdded}` : null, hasRemoved ? `−${totalRemoved}` : null] + .filter(Boolean) + .join(" ") + + return { + totalAdded, + totalRemoved, + hasAdded, + hasRemoved, + hasAnyLineChanges, + formatted, + } + }, [currentTaskItem, subtaskDetails]) + const tooltipCostData = useMemo( () => getTaskHeaderCostTooltipData({ @@ -313,7 +357,7 @@ const TaskHeader = ({ +
      {t("chat:costs.totalWithSubtasks", { cost: displayTotalCost.toFixed(2), @@ -329,7 +373,7 @@ const TaskHeader = ({ } side="top" sideOffset={8}> - + ${displayTotalCost.toFixed(2)} {shouldTreatAsHasSubtasks && ( @@ -339,6 +383,20 @@ const TaskHeader = ({ )} + {aggregatedLineChanges.hasAnyLineChanges && ( + + {aggregatedLineChanges.hasAdded && ( + + +{aggregatedLineChanges.totalAdded} + + )} + {aggregatedLineChanges.hasRemoved && ( + + −{aggregatedLineChanges.totalRemoved} + + )} + + )}
      {showBrowserGlobe && (
      e.stopPropagation()}> @@ -514,6 +572,28 @@ const TaskHeader = ({ )} + {aggregatedLineChanges.hasAnyLineChanges && ( + + + {t("common:stats.lines")} + + + + {aggregatedLineChanges.hasAdded && ( + + +{aggregatedLineChanges.totalAdded} + + )} + {aggregatedLineChanges.hasRemoved && ( + + −{aggregatedLineChanges.totalRemoved} + + )} + + + + )} + {/* Size display */} {!!currentTaskItem?.size && currentTaskItem.size > 0 && ( diff --git a/webview-ui/src/components/chat/TodoListDisplay.tsx b/webview-ui/src/components/chat/TodoListDisplay.tsx index 684ff82feb7..bbc187bacba 100644 --- a/webview-ui/src/components/chat/TodoListDisplay.tsx +++ b/webview-ui/src/components/chat/TodoListDisplay.tsx @@ -19,6 +19,8 @@ interface TodoItem { subtaskId?: string tokens?: number cost?: number + added?: number + removed?: number } function getTodoIcon(status: TodoStatus | null) { @@ -84,7 +86,7 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL ? "text-vscode-charts-yellow" : "text-vscode-foreground", )} - onClick={() => setIsCollapsed((v) => !v)}> + onClick={() => setIsCollapsed((v: boolean) => !v)}> {isCollapsed @@ -113,6 +115,17 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL const displayCost = todo.cost ?? subtaskById?.cost const shouldShowCost = typeof displayTokens === "number" && typeof displayCost === "number" + const displayAdded = todo.added ?? subtaskById?.added + const displayRemoved = todo.removed ?? subtaskById?.removed + const hasValidSubtaskLink = typeof todo.subtaskId === "string" && todo.subtaskId.length > 0 + const shouldShowLineChanges = + hasValidSubtaskLink && (Number.isFinite(displayAdded) || Number.isFinite(displayRemoved)) + + const hasAdded = + typeof displayAdded === "number" && Number.isFinite(displayAdded) && displayAdded > 0 + const hasRemoved = + typeof displayRemoved === "number" && Number.isFinite(displayRemoved) && displayRemoved > 0 + return (
    • {/* Token count and cost display */} - {shouldShowCost && ( + {(shouldShowCost || shouldShowLineChanges) && ( - - {formatLargeNumber(displayTokens)} - - - ${displayCost.toFixed(2)} - + {shouldShowCost && ( + <> + + {formatLargeNumber(displayTokens)} + + + ${displayCost.toFixed(2)} + + + )} + {shouldShowLineChanges && ( + + + {hasAdded ? `+${displayAdded}` : "\u00A0"} + + + {hasRemoved ? `−${displayRemoved}` : "\u00A0"} + + + )} )}
    • diff --git a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx index f5babe983c5..d4a8d349f7b 100644 --- a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx @@ -34,6 +34,8 @@ describe("TodoListDisplay", () => { name: "Task 1: Change background colour", tokens: 95400, cost: 0.22, + added: 10, + removed: 4, status: "completed", hasNestedChildren: false, }, @@ -42,6 +44,8 @@ describe("TodoListDisplay", () => { name: "Task 2: Add timestamp to bottom", tokens: 95000, cost: 0.24, + added: 3, + removed: 2, status: "completed", hasNestedChildren: false, }, @@ -106,7 +110,9 @@ describe("TodoListDisplay", () => { // The pending task has no subtaskId, should not show cost const listItems = screen.getAllByRole("listitem") - const pendingItem = listItems.find((item) => item.textContent?.includes("Task 3: Pending task")) + const pendingItem = listItems.find((item: HTMLElement) => + item.textContent?.includes("Task 3: Pending task"), + ) expect(pendingItem).toBeDefined() expect(pendingItem?.textContent).not.toContain("$") }) @@ -177,6 +183,96 @@ describe("TodoListDisplay", () => { }) }) + describe("line change display", () => { + it("uses todo.added/todo.removed when present", () => { + const todosWithDirectLineChanges = [ + { + id: "1", + content: "Task 1: Change background colour", + status: "completed", + subtaskId: "subtask-1", + added: 7, + removed: 9, + }, + ] + render() + + // Expand + const header = screen.getByText("1 to-dos done") + fireEvent.click(header) + + // Line changes are rendered as separate colored spans + expect(screen.getByText("+7")).toBeInTheDocument() + expect(screen.getByText("−9")).toBeInTheDocument() + }) + + it("falls back to subtaskDetails when todo added/removed are missing", () => { + const todosMissingDirectLineChanges = [ + { + id: "1", + content: "Task 1: Change background colour", + status: "completed", + subtaskId: "subtask-1", + }, + ] + render() + + // Expand + const header = screen.getByText("1 to-dos done") + fireEvent.click(header) + + // Line changes are rendered as separate colored spans + expect(screen.getByText("+10")).toBeInTheDocument() + expect(screen.getByText("−4")).toBeInTheDocument() + }) + + it("hides line deltas when no data available (no subtaskId)", () => { + const todosNoSubtaskLink = [{ id: "1", content: "No link todo", status: "completed" }] + render() + + // Expand + const header = screen.getByText("1 to-dos done") + fireEvent.click(header) + + expect(screen.queryByText(/\+\d+/)).not.toBeInTheDocument() + expect(screen.queryByText(/−\d+/)).not.toBeInTheDocument() + }) + + it("hides line deltas when all values are undefined (subtaskId present)", () => { + const todosWithLinkButNoLineChanges = [ + { + id: "1", + content: "Task 1: Change background colour", + status: "completed", + subtaskId: "subtask-1", + }, + ] + const subtaskDetailsWithoutLineChanges: SubtaskDetail[] = [ + { + id: "subtask-1", + name: "Task 1: Change background colour", + tokens: 95400, + cost: 0.22, + status: "completed", + hasNestedChildren: false, + } as unknown as SubtaskDetail, + ] + render( + , + ) + + // Expand + const header = screen.getByText("1 to-dos done") + fireEvent.click(header) + + expect(screen.queryByText(/\+\d+/)).not.toBeInTheDocument() + expect(screen.queryByText(/−\d+/)).not.toBeInTheDocument() + }) + }) + describe("click handler", () => { it("should call onSubtaskClick when a todo with subtaskId is clicked", () => { const onSubtaskClick = vi.fn() diff --git a/webview-ui/src/i18n/locales/ca/common.json b/webview-ui/src/i18n/locales/ca/common.json index 56e9a3745ef..222cc6ffe7d 100644 --- a/webview-ui/src/i18n/locales/ca/common.json +++ b/webview-ui/src/i18n/locales/ca/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Esperant la tasca filla {{childId}}" }, "costs": { - "own": "Propi", + "own": "Principal", "subtasks": "Subtasques" + }, + "stats": { + "lines": "Línies" } } diff --git a/webview-ui/src/i18n/locales/de/common.json b/webview-ui/src/i18n/locales/de/common.json index ab8bd6d2401..e8f01f5683b 100644 --- a/webview-ui/src/i18n/locales/de/common.json +++ b/webview-ui/src/i18n/locales/de/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Warte auf Unteraufgabe {{childId}}" }, "costs": { - "own": "Eigen", + "own": "Haupt", "subtasks": "Unteraufgaben" + }, + "stats": { + "lines": "Zeilen" } } diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 3ab2c037af2..36c19e54efd 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -6,6 +6,7 @@ "collapse": "Collapse task", "seeMore": "See more", "seeLess": "See less", + "lineChanges": "Lines:", "tokens": "Tokens", "cache": "Cache", "apiCost": "API Cost", diff --git a/webview-ui/src/i18n/locales/en/common.json b/webview-ui/src/i18n/locales/en/common.json index 981eaeec755..32d9f6f41fb 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Awaiting child task {{childId}}" }, "costs": { - "own": "Own", + "own": "Main", "subtasks": "Subtasks" + }, + "stats": { + "lines": "Lines" } } diff --git a/webview-ui/src/i18n/locales/es/common.json b/webview-ui/src/i18n/locales/es/common.json index 03455b7cadc..465243d5bcb 100644 --- a/webview-ui/src/i18n/locales/es/common.json +++ b/webview-ui/src/i18n/locales/es/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Esperando tarea secundaria {{childId}}" }, "costs": { - "own": "Propio", + "own": "Principal", "subtasks": "Subtareas" + }, + "stats": { + "lines": "Líneas" } } diff --git a/webview-ui/src/i18n/locales/fr/common.json b/webview-ui/src/i18n/locales/fr/common.json index def93ad6c5e..979c204285e 100644 --- a/webview-ui/src/i18n/locales/fr/common.json +++ b/webview-ui/src/i18n/locales/fr/common.json @@ -108,7 +108,10 @@ "awaiting_child": "En attente de la tâche enfant {{childId}}" }, "costs": { - "own": "Propre", + "own": "Principal", "subtasks": "Sous-tâches" + }, + "stats": { + "lines": "Lignes" } } diff --git a/webview-ui/src/i18n/locales/hi/common.json b/webview-ui/src/i18n/locales/hi/common.json index 076530e6b02..3f2f1a5728f 100644 --- a/webview-ui/src/i18n/locales/hi/common.json +++ b/webview-ui/src/i18n/locales/hi/common.json @@ -108,7 +108,10 @@ "awaiting_child": "चाइल्ड कार्य {{childId}} की प्रतीक्षा में" }, "costs": { - "own": "स्वयं", + "own": "मुख्य", "subtasks": "उपकार्य" + }, + "stats": { + "lines": "पंक्तियाँ" } } diff --git a/webview-ui/src/i18n/locales/id/common.json b/webview-ui/src/i18n/locales/id/common.json index a65295f28d4..2c2565ff6c6 100644 --- a/webview-ui/src/i18n/locales/id/common.json +++ b/webview-ui/src/i18n/locales/id/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Menunggu tugas anak {{childId}}" }, "costs": { - "own": "Sendiri", + "own": "Utama", "subtasks": "Subtugas" + }, + "stats": { + "lines": "Baris" } } diff --git a/webview-ui/src/i18n/locales/it/common.json b/webview-ui/src/i18n/locales/it/common.json index 9b801628f49..943c350e565 100644 --- a/webview-ui/src/i18n/locales/it/common.json +++ b/webview-ui/src/i18n/locales/it/common.json @@ -108,7 +108,10 @@ "awaiting_child": "In attesa dell'attività figlia {{childId}}" }, "costs": { - "own": "Proprio", + "own": "Principale", "subtasks": "Sottoattività" + }, + "stats": { + "lines": "Linee" } } diff --git a/webview-ui/src/i18n/locales/ja/common.json b/webview-ui/src/i18n/locales/ja/common.json index b3b9d462e07..a8909bbdeb6 100644 --- a/webview-ui/src/i18n/locales/ja/common.json +++ b/webview-ui/src/i18n/locales/ja/common.json @@ -108,7 +108,10 @@ "awaiting_child": "子タスク{{childId}}を待機中" }, "costs": { - "own": "自身", + "own": "メイン", "subtasks": "サブタスク" + }, + "stats": { + "lines": "行" } } diff --git a/webview-ui/src/i18n/locales/ko/common.json b/webview-ui/src/i18n/locales/ko/common.json index d7120e2520d..472891d0d50 100644 --- a/webview-ui/src/i18n/locales/ko/common.json +++ b/webview-ui/src/i18n/locales/ko/common.json @@ -108,7 +108,10 @@ "awaiting_child": "하위 작업 {{childId}} 대기 중" }, "costs": { - "own": "자체", + "own": "메인", "subtasks": "하위작업" + }, + "stats": { + "lines": "라인" } } diff --git a/webview-ui/src/i18n/locales/nl/common.json b/webview-ui/src/i18n/locales/nl/common.json index ec6cf89ccb5..e62f045481e 100644 --- a/webview-ui/src/i18n/locales/nl/common.json +++ b/webview-ui/src/i18n/locales/nl/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Wachten op kindtaak {{childId}}" }, "costs": { - "own": "Eigen", + "own": "Hoofd", "subtasks": "Subtaken" + }, + "stats": { + "lines": "Regels" } } diff --git a/webview-ui/src/i18n/locales/pl/common.json b/webview-ui/src/i18n/locales/pl/common.json index 419aa83af1e..9b5ecbfe51c 100644 --- a/webview-ui/src/i18n/locales/pl/common.json +++ b/webview-ui/src/i18n/locales/pl/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Oczekiwanie na zadanie podrzędne {{childId}}" }, "costs": { - "own": "Własne", + "own": "Główne", "subtasks": "Podzadania" + }, + "stats": { + "lines": "Linie" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/common.json b/webview-ui/src/i18n/locales/pt-BR/common.json index 4990796976f..4f1ebf94d9c 100644 --- a/webview-ui/src/i18n/locales/pt-BR/common.json +++ b/webview-ui/src/i18n/locales/pt-BR/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Aguardando tarefa filha {{childId}}" }, "costs": { - "own": "Próprio", + "own": "Principal", "subtasks": "Subtarefas" + }, + "stats": { + "lines": "Linhas" } } diff --git a/webview-ui/src/i18n/locales/ru/common.json b/webview-ui/src/i18n/locales/ru/common.json index f66384a6937..47bab140c89 100644 --- a/webview-ui/src/i18n/locales/ru/common.json +++ b/webview-ui/src/i18n/locales/ru/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Ожидание дочерней задачи {{childId}}" }, "costs": { - "own": "Собственные", + "own": "Основная", "subtasks": "Подзадачи" + }, + "stats": { + "lines": "Строки" } } diff --git a/webview-ui/src/i18n/locales/tr/common.json b/webview-ui/src/i18n/locales/tr/common.json index db9e991cd58..81dca46196b 100644 --- a/webview-ui/src/i18n/locales/tr/common.json +++ b/webview-ui/src/i18n/locales/tr/common.json @@ -108,7 +108,10 @@ "awaiting_child": "{{childId}} alt görevi bekleniyor" }, "costs": { - "own": "Kendi", + "own": "Ana", "subtasks": "Alt görevler" + }, + "stats": { + "lines": "Satırlar" } } diff --git a/webview-ui/src/i18n/locales/vi/common.json b/webview-ui/src/i18n/locales/vi/common.json index 57eb31fafa4..d3b37fb35cc 100644 --- a/webview-ui/src/i18n/locales/vi/common.json +++ b/webview-ui/src/i18n/locales/vi/common.json @@ -108,7 +108,10 @@ "awaiting_child": "Đang chờ nhiệm vụ con {{childId}}" }, "costs": { - "own": "Riêng", + "own": "Chính", "subtasks": "Nhiệm vụ con" + }, + "stats": { + "lines": "Dòng" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index 10df0893334..cf7847a8d11 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -108,7 +108,10 @@ "awaiting_child": "等待子任务 {{childId}}" }, "costs": { - "own": "自身", + "own": "主要", "subtasks": "子任务" + }, + "stats": { + "lines": "行" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index da47dec72bd..435bda21cb9 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -108,7 +108,10 @@ "awaiting_child": "等待子工作 {{childId}}" }, "costs": { - "own": "自身", + "own": "主要", "subtasks": "子工作" + }, + "stats": { + "lines": "行" } } diff --git a/webview-ui/src/types/subtasks.ts b/webview-ui/src/types/subtasks.ts index a4cd97ed4f9..e0d2bd73220 100644 --- a/webview-ui/src/types/subtasks.ts +++ b/webview-ui/src/types/subtasks.ts @@ -5,6 +5,10 @@ export type SubtaskDetail = { name: string /** tokensIn + tokensOut */ tokens: number + /** Total lines added across the subtask */ + added: number + /** Total lines removed across the subtask */ + removed: number /** Aggregated total cost */ cost: number status: "active" | "completed" | "delegated" From ecadf2c6b5348b73536a7b32bc888d3aa3ac0bc8 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Sun, 18 Jan 2026 23:19:29 -0500 Subject: [PATCH 12/17] fix: include line totals in aggregatedCosts message Align extension->webview message contract with extended AggregatedCosts/SubtaskDetail (added/removed + totals), fixing check-types and enabling line deltas in UI. --- packages/types/src/vscode-extension-host.ts | 8 ++++++++ src/core/webview/ClineProvider.ts | 4 +--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 1448c08c809..49294f08c6a 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -188,11 +188,19 @@ export interface ExtensionMessage { totalCost: number ownCost: number childrenCost: number + ownAdded?: number + ownRemoved?: number + childrenAdded?: number + childrenRemoved?: number + totalAdded?: number + totalRemoved?: number childDetails?: { id: string name: string tokens: number cost: number + added: number + removed: number status: "active" | "completed" | "delegated" hasNestedChildren: boolean }[] diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d287e1c338f..dd74a259db5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1711,9 +1711,7 @@ export class ClineProvider return { historyItem, aggregatedCosts: { - totalCost: aggregatedCosts.totalCost, - ownCost: aggregatedCosts.ownCost, - childrenCost: aggregatedCosts.childrenCost, + ...aggregatedCosts, childDetails, }, } From 13e27909ec287547640a06c8cc8b5584f9be2732 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Mon, 19 Jan 2026 21:24:45 -0500 Subject: [PATCH 13/17] fix: preserve todo metrics and line-change display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match todos by subtaskId first to retain tokens/cost/added/removed when content or derived IDs change. Improve UI rendering to suppress misleading +0/−0 while running unless values are explicitly provided. Relates to #5376 --- src/core/tools/UpdateTodoListTool.ts | 93 ++++--- .../__tests__/updateTodoListTool.spec.ts | 255 ++++++++++++++++++ .../src/components/chat/TodoListDisplay.tsx | 50 ++-- .../chat/__tests__/TodoListDisplay.spec.tsx | 111 ++++++++ 4 files changed, 462 insertions(+), 47 deletions(-) diff --git a/src/core/tools/UpdateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts index a895f08ca76..795903bb38a 100644 --- a/src/core/tools/UpdateTodoListTool.ts +++ b/src/core/tools/UpdateTodoListTool.ts @@ -32,7 +32,12 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { const historyHasMetadata = Array.isArray(previousFromHistory) && previousFromHistory.some( - (t) => t?.subtaskId !== undefined || t?.tokens !== undefined || t?.cost !== undefined, + (t) => + t?.subtaskId !== undefined || + t?.tokens !== undefined || + t?.cost !== undefined || + t?.added !== undefined || + t?.removed !== undefined, ) const previousTodos: TodoItem[] = @@ -77,6 +82,8 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { subtaskId: t.subtaskId, tokens: t.tokens, cost: t.cost, + added: t.added, + removed: t.removed, })) const approvalMsg = JSON.stringify({ @@ -215,45 +222,55 @@ function normalizeStatus(status: string | undefined): TodoStatus { } /** - * Preserve metadata (subtaskId, tokens, cost) from previous todos onto next todos. + * Preserve metadata (subtaskId, tokens, cost, added, removed) from previous todos onto next todos. * * Matching strategy (in priority order): - * 1. **ID match**: If both todos have an `id` field and they match exactly, preserve metadata. + * 1. **Subtask ID match**: If the next todo has a `subtaskId`, match against previous todos with + * the same `subtaskId`. This is the most stable identifier when content (and derived IDs) + * changes. + * 2. **ID match**: If both todos have an `id` field and they match exactly, preserve metadata. * This handles the common case where ID is stable across updates. - * 2. **Content match with position awareness**: For todos without matching IDs, fall back to - * content-based matching. Duplicates are matched in order (first unmatched previous with - * same content gets matched to first unmatched next with same content). + * 3. **Content match with position awareness**: For todos without matching subtask IDs or IDs, + * fall back to content-based matching. Duplicates are matched in order (first unmatched + * previous with same content gets matched to first unmatched next with same content). * - * This approach ensures metadata survives status changes (which can alter the derived ID) + * This approach ensures metadata survives status/content changes (which can alter the derived ID) * and handles duplicates deterministically. */ function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): TodoItem[] { const safePrevious = previousTodos ?? [] const safeNext = nextTodos ?? [] - // Build ID -> todo mapping for O(1) lookup - const previousById = new Map() - for (const prev of safePrevious) { - if (prev?.id && typeof prev.id === "string") { - // Only store the first occurrence for each ID (handle duplicates deterministically) - if (!previousById.has(prev.id)) { - previousById.set(prev.id, prev) - } - } - } - // Track which previous todos have been used (by their index) to avoid double-matching const usedPreviousIndices = new Set() - // Build content -> queue mapping for fallback (content-based matching) - // Each queue entry includes the original index for tracking + // Build lookup maps for matching strategies. + // - Subtask ID: may have duplicates; match in order for determinism. + // - ID: should be unique; store first occurrence. + // - Content: may have duplicates; match in order for determinism. + const previousBySubtaskId = new Map>() + const previousById = new Map() const previousByContent = new Map>() + for (let i = 0; i < safePrevious.length; i++) { const prev = safePrevious[i] - if (!prev || typeof prev.content !== "string") continue - const list = previousByContent.get(prev.content) - if (list) list.push({ todo: prev, index: i }) - else previousByContent.set(prev.content, [{ todo: prev, index: i }]) + if (!prev) continue + + if (typeof prev.subtaskId === "string") { + const list = previousBySubtaskId.get(prev.subtaskId) + if (list) list.push({ todo: prev, index: i }) + else previousBySubtaskId.set(prev.subtaskId, [{ todo: prev, index: i }]) + } + + if (typeof prev.id === "string" && !previousById.has(prev.id)) { + previousById.set(prev.id, { todo: prev, index: i }) + } + + if (typeof prev.content === "string") { + const list = previousByContent.get(prev.content) + if (list) list.push({ todo: prev, index: i }) + else previousByContent.set(prev.content, [{ todo: prev, index: i }]) + } } return safeNext.map((next) => { @@ -262,16 +279,26 @@ function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): let matchedPrev: TodoItem | undefined = undefined let matchedIndex: number | undefined = undefined + // Strategy 0: Try subtaskId-based matching first (most stable across content/ID changes) + if (typeof next.subtaskId === "string") { + const candidates = previousBySubtaskId.get(next.subtaskId) + if (candidates) { + for (const candidate of candidates) { + if (!usedPreviousIndices.has(candidate.index)) { + matchedPrev = candidate.todo + matchedIndex = candidate.index + break + } + } + } + } + // Strategy 1: Try ID-based matching first (most reliable) - if (next.id && typeof next.id === "string") { + if (!matchedPrev && next.id && typeof next.id === "string") { const byId = previousById.get(next.id) - if (byId) { - // Find the index of this todo in the original array - const idx = safePrevious.findIndex((p) => p === byId) - if (idx !== -1 && !usedPreviousIndices.has(idx)) { - matchedPrev = byId - matchedIndex = idx - } + if (byId && !usedPreviousIndices.has(byId.index)) { + matchedPrev = byId.todo + matchedIndex = byId.index } } @@ -298,6 +325,8 @@ function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): subtaskId: next.subtaskId ?? matchedPrev.subtaskId, tokens: next.tokens ?? matchedPrev.tokens, cost: next.cost ?? matchedPrev.cost, + added: next.added ?? matchedPrev.added, + removed: next.removed ?? matchedPrev.removed, } } diff --git a/src/core/tools/__tests__/updateTodoListTool.spec.ts b/src/core/tools/__tests__/updateTodoListTool.spec.ts index 8ed33c10e57..c67730e6c8f 100644 --- a/src/core/tools/__tests__/updateTodoListTool.spec.ts +++ b/src/core/tools/__tests__/updateTodoListTool.spec.ts @@ -292,4 +292,259 @@ describe("UpdateTodoListTool.execute", () => { }), ) }) + + it("should treat added/removed as metadata and prefer history todos when present", async () => { + const md = "[ ] Task 1" + + const previousFromMemory = parseMarkdownChecklist(md) + const previousFromHistory: TodoItem[] = previousFromMemory.map((t) => ({ + ...t, + added: 10, + removed: 3, + })) + + const task = { + todoList: previousFromMemory, + clineMessages: [ + { + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "updateTodoList", todos: previousFromHistory }), + }, + ], + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + say: vi.fn(), + } as any + + const tool = new UpdateTodoListTool() + await tool.execute({ todos: md }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockResolvedValue(true), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + }) + + expect(task.todoList).toHaveLength(1) + expect(task.todoList[0]).toEqual( + expect.objectContaining({ + content: "Task 1", + added: 10, + removed: 3, + }), + ) + }) + + it("should preserve metadata by subtaskId even when content (and derived id) changes", async () => { + // This test simulates the "user edited todo list" flow. The tool re-applies metadata + // after approval; subtaskId should be used as the primary match when content/id changes. + const md = "[ ] Old text" + + const previousFromMemory: TodoItem[] = parseMarkdownChecklist(md).map((t) => ({ + ...t, + subtaskId: "subtask-1", + tokens: 123, + cost: 0.01, + added: 10, + removed: 3, + })) + + const task = { + todoList: previousFromMemory, + clineMessages: [], + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + say: vi.fn(), + } as any + + // Simulate user-edited todo list with updated content and a different id, but the same subtaskId. + const userEditedTodos: TodoItem[] = [ + { + id: "new-id", + content: "New text", + status: "completed", + subtaskId: "subtask-1", + // tokens/cost/added/removed intentionally omitted to verify preservation + }, + ] + + const tool = new UpdateTodoListTool() + await tool.execute({ todos: md }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockImplementation(async () => { + setPendingTodoList(userEditedTodos) + return true + }), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + }) + + expect(task.todoList).toHaveLength(1) + expect(task.todoList[0]).toEqual( + expect.objectContaining({ + id: "new-id", + content: "New text", + status: "completed", + subtaskId: "subtask-1", + tokens: 123, + cost: 0.01, + added: 10, + removed: 3, + }), + ) + }) + + it("should preserve added/removed through normalization", async () => { + const md = "[x] Task 1" + + const previousFromMemory: TodoItem[] = parseMarkdownChecklist("[ ] Task 1").map((t) => ({ + ...t, + added: 10, + removed: 3, + })) + + const task = { + todoList: previousFromMemory, + clineMessages: [], + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + say: vi.fn(), + } as any + + const tool = new UpdateTodoListTool() + await tool.execute({ todos: md }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockResolvedValue(true), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + }) + + expect(task.todoList).toHaveLength(1) + expect(task.todoList[0]).toEqual( + expect.objectContaining({ + content: "Task 1", + status: "completed", + added: 10, + removed: 3, + }), + ) + }) + + it("should not cross-contaminate metadata when no subtaskId is present", async () => { + const initialMd = "[ ] Task 1\n[ ] Task 2" + const md = "[x] Task 1\n[ ] Task 2" // status changes for Task 1 -> derived id changes + + const previousFromMemory: TodoItem[] = parseMarkdownChecklist(initialMd).map((t) => + t.content === "Task 1" + ? { ...t, tokens: 111, cost: 0.11, added: 11, removed: 1 } + : { ...t, tokens: 222, cost: 0.22, added: 22, removed: 2 }, + ) + + const task = { + todoList: previousFromMemory, + clineMessages: [], + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + say: vi.fn(), + } as any + + const tool = new UpdateTodoListTool() + await tool.execute({ todos: md }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockResolvedValue(true), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + }) + + expect(task.todoList).toHaveLength(2) + + const task1 = task.todoList.find((t: TodoItem) => t.content === "Task 1") + const task2 = task.todoList.find((t: TodoItem) => t.content === "Task 2") + + expect(task1).toEqual( + expect.objectContaining({ + content: "Task 1", + status: "completed", + tokens: 111, + cost: 0.11, + added: 11, + removed: 1, + }), + ) + + expect(task2).toEqual( + expect.objectContaining({ + content: "Task 2", + status: "pending", + tokens: 222, + cost: 0.22, + added: 22, + removed: 2, + }), + ) + }) + + it("should not preserve metadata when content changes and there is no subtaskId", async () => { + const initialMd = "[ ] Task 1\n[ ] Task 2" + const md = "[x] Task 1 (updated)\n[ ] Task 2" + + const previousFromMemory: TodoItem[] = parseMarkdownChecklist(initialMd).map((t) => + t.content === "Task 1" + ? { ...t, tokens: 111, cost: 0.11, added: 11, removed: 1 } + : { ...t, tokens: 222, cost: 0.22, added: 22, removed: 2 }, + ) + + const task = { + todoList: previousFromMemory, + clineMessages: [], + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + say: vi.fn(), + } as any + + const tool = new UpdateTodoListTool() + await tool.execute({ todos: md }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockResolvedValue(true), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + }) + + expect(task.todoList).toHaveLength(2) + + const updated = task.todoList.find((t: TodoItem) => t.content === "Task 1 (updated)") + const task2 = task.todoList.find((t: TodoItem) => t.content === "Task 2") + + expect(updated).toEqual( + expect.objectContaining({ + content: "Task 1 (updated)", + status: "completed", + }), + ) + expect(updated?.tokens).toBeUndefined() + expect(updated?.cost).toBeUndefined() + expect(updated?.added).toBeUndefined() + expect(updated?.removed).toBeUndefined() + + expect(task2).toEqual( + expect.objectContaining({ + content: "Task 2", + status: "pending", + tokens: 222, + cost: 0.22, + added: 22, + removed: 2, + }), + ) + }) }) diff --git a/webview-ui/src/components/chat/TodoListDisplay.tsx b/webview-ui/src/components/chat/TodoListDisplay.tsx index bbc187bacba..c3da1c717ba 100644 --- a/webview-ui/src/components/chat/TodoListDisplay.tsx +++ b/webview-ui/src/components/chat/TodoListDisplay.tsx @@ -105,7 +105,8 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL {!isCollapsed && (
        {todos.map((todo, idx: number) => { - const icon = getTodoIcon(todo.status as TodoStatus) + const todoStatus = (todo.status as TodoStatus) ?? "pending" + const icon = getTodoIcon(todoStatus) const isClickable = Boolean(todo.subtaskId && onSubtaskClick) const subtaskById = subtaskDetails && todo.subtaskId @@ -115,16 +116,33 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL const displayCost = todo.cost ?? subtaskById?.cost const shouldShowCost = typeof displayTokens === "number" && typeof displayCost === "number" - const displayAdded = todo.added ?? subtaskById?.added - const displayRemoved = todo.removed ?? subtaskById?.removed + const todoAddedIsFinite = typeof todo.added === "number" && Number.isFinite(todo.added) + const todoRemovedIsFinite = typeof todo.removed === "number" && Number.isFinite(todo.removed) + + const displayAdded = todoAddedIsFinite ? todo.added : subtaskById?.added + const displayRemoved = todoRemovedIsFinite ? todo.removed : subtaskById?.removed + + const displayAddedIsFinite = typeof displayAdded === "number" && Number.isFinite(displayAdded) + const displayRemovedIsFinite = + typeof displayRemoved === "number" && Number.isFinite(displayRemoved) const hasValidSubtaskLink = typeof todo.subtaskId === "string" && todo.subtaskId.length > 0 - const shouldShowLineChanges = - hasValidSubtaskLink && (Number.isFinite(displayAdded) || Number.isFinite(displayRemoved)) - const hasAdded = - typeof displayAdded === "number" && Number.isFinite(displayAdded) && displayAdded > 0 - const hasRemoved = - typeof displayRemoved === "number" && Number.isFinite(displayRemoved) && displayRemoved > 0 + // Upstream aggregation may coerce missing stats to 0. + // To avoid showing misleading `+0/−0` for in-progress/pending rows, + // only render 0 while running if it was explicitly provided on the todo itself. + const canRenderAdded = + displayAddedIsFinite && + (todoStatus === "completed" || displayAdded !== 0 || todoAddedIsFinite) + const canRenderRemoved = + displayRemovedIsFinite && + (todoStatus === "completed" || displayRemoved !== 0 || todoRemovedIsFinite) + + const shouldShowLineChanges = hasValidSubtaskLink && (canRenderAdded || canRenderRemoved) + + const isAddedPositive = canRenderAdded && (displayAdded as number) > 0 + const isRemovedPositive = canRenderRemoved && (displayRemoved as number) > 0 + const isAddedZero = canRenderAdded && displayAdded === 0 + const isRemovedZero = canRenderRemoved && displayRemoved === 0 return (
      • (itemRefs.current[idx] = el)} className={cn( "font-light flex flex-row gap-2 items-start min-h-[20px] leading-normal mb-2", - todo.status === "in_progress" && "text-vscode-charts-yellow", - todo.status !== "in_progress" && todo.status !== "completed" && "opacity-60", + todoStatus === "in_progress" && "text-vscode-charts-yellow", + todoStatus !== "in_progress" && todoStatus !== "completed" && "opacity-60", )}> {icon} - {hasAdded ? `+${displayAdded}` : "\u00A0"} + {canRenderAdded ? `+${displayAdded}` : "\u00A0"} - {hasRemoved ? `−${displayRemoved}` : "\u00A0"} + {canRenderRemoved ? `−${displayRemoved}` : "\u00A0"} )} diff --git a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx index d4a8d349f7b..67650153f10 100644 --- a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx @@ -206,6 +206,117 @@ describe("TodoListDisplay", () => { expect(screen.getByText("−9")).toBeInTheDocument() }) + it("shows +0/−0 for completed subtask when fallback metrics are explicitly zero", () => { + const todosMissingDirectLineChanges = [ + { + id: "1", + content: "Task 1: Zero changes", + status: "completed", + subtaskId: "subtask-1", + }, + ] + const subtaskDetailsWithZeroLineChanges: SubtaskDetail[] = [ + { + id: "subtask-1", + name: "Task 1: Zero changes", + tokens: 1, + cost: 0.01, + added: 0, + removed: 0, + status: "completed", + hasNestedChildren: false, + }, + ] + + render( + , + ) + + // Expand + const header = screen.getByText("1 to-dos done") + fireEvent.click(header) + + const addedEl = screen.getByText("+0") + const removedEl = screen.getByText("−0") + + expect(addedEl).toBeInTheDocument() + expect(removedEl).toBeInTheDocument() + + // Zero values should be visually muted (not green/red emphasized) + expect(addedEl.className).toContain("opacity-50") + expect(addedEl.className).not.toContain("text-vscode-charts-green") + + expect(removedEl.className).toContain("opacity-50") + expect(removedEl.className).not.toContain("text-vscode-charts-red") + }) + + it("in-progress: does not show +0/−0 when zeros only come from fallback", () => { + const todosMissingDirectLineChanges = [ + { + id: "1", + content: "Task 1: Zero changes (running)", + status: "in_progress", + subtaskId: "subtask-1", + }, + ] + const subtaskDetailsWithZeroLineChanges: SubtaskDetail[] = [ + { + id: "subtask-1", + name: "Task 1: Zero changes (running)", + tokens: 1, + cost: 0.01, + added: 0, + removed: 0, + status: "active", + hasNestedChildren: false, + }, + ] + + render( + , + ) + + // Expand + const header = screen.getByText("Task 1: Zero changes (running)") + fireEvent.click(header) + + expect(screen.queryByText("+0")).not.toBeInTheDocument() + expect(screen.queryByText("−0")).not.toBeInTheDocument() + }) + + it("in-progress: shows +0/−0 when explicitly present on todo", () => { + const todosWithDirectLineChanges = [ + { + id: "1", + content: "Task 1: Zero changes (explicit)", + status: "in_progress", + subtaskId: "subtask-1", + added: 0, + removed: 0, + }, + ] + + render() + + // Expand + const header = screen.getByText("Task 1: Zero changes (explicit)") + fireEvent.click(header) + + const addedEl = screen.getByText("+0") + const removedEl = screen.getByText("−0") + expect(addedEl).toBeInTheDocument() + expect(removedEl).toBeInTheDocument() + + expect(addedEl.className).toContain("opacity-50") + expect(removedEl.className).toContain("opacity-50") + }) + it("falls back to subtaskDetails when todo added/removed are missing", () => { const todosMissingDirectLineChanges = [ { From 3900b3c2783800c7bb10548d91913a55c568f795 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Tue, 20 Jan 2026 16:46:40 -0500 Subject: [PATCH 14/17] fix: preserve todo metadata across subtask updates Carry forward subtaskId/cost/tokens/line changes when the LLM rewrites todo text, including sequential updates.\n\nIncludes [TODO-DEBUG] instrumentation across extension/webview and regression tests. --- .../presentAssistantMessage.ts | 5 + src/core/tools/UpdateTodoListTool.ts | 636 +++++++++++++++++- .../__tests__/updateTodoListTool.spec.ts | 483 ++++++++++++- src/core/webview/ClineProvider.ts | 99 ++- ...openParentFromDelegation.writeback.spec.ts | 237 +++++++ .../__tests__/aggregateTaskCosts.spec.ts | 68 ++ src/shared/todo.ts | 69 +- webview-ui/src/components/chat/ChatRow.tsx | 29 +- webview-ui/src/components/chat/ChatView.tsx | 10 + .../src/components/chat/TodoChangeDisplay.tsx | 41 +- .../src/components/chat/TodoListDisplay.tsx | 52 ++ 11 files changed, 1662 insertions(+), 67 deletions(-) create mode 100644 src/core/webview/__tests__/ClineProvider.reopenParentFromDelegation.writeback.spec.ts diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 693327a022e..7bc29ac95ae 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -881,6 +881,11 @@ export async function presentAssistantMessage(cline: Task) { }) break case "update_todo_list": + console.log("[TODO-DEBUG]", "presentAssistantMessage dispatching update_todo_list", { + toolUseId: (block as any).id, + partial: block.partial, + params: (block as any).params, + }) await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { askApproval, handleError, diff --git a/src/core/tools/UpdateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts index 795903bb38a..1f5467034ae 100644 --- a/src/core/tools/UpdateTodoListTool.ts +++ b/src/core/tools/UpdateTodoListTool.ts @@ -2,8 +2,10 @@ import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" -import cloneDeep from "clone-deep" import crypto from "crypto" +import fs from "fs" +import os from "os" +import path from "path" import { TodoItem, TodoStatus, todoStatusSchema } from "@roo-code/types" import { getLatestTodo } from "../../shared/todo" @@ -23,11 +25,54 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { } async execute(params: UpdateTodoListParams, task: Task, callbacks: ToolCallbacks): Promise { + console.log("[TODO-DEBUG] execute() STEP 0: ENTERED", { + tool: "update_todo_list", + paramsTodosType: typeof params?.todos, + paramsTodosLength: typeof params?.todos === "string" ? params.todos.length : undefined, + }) const { pushToolResult, handleError, askApproval, toolProtocol } = callbacks try { + const summarizeTodoForDebug = (t: TodoItem | undefined) => { + if (!t) return undefined + return { + id: typeof t.id === "string" ? t.id : undefined, + status: typeof t.status === "string" ? t.status : undefined, + content: typeof t.content === "string" ? t.content.slice(0, 120) : undefined, + subtaskId: typeof t.subtaskId === "string" ? t.subtaskId : undefined, + tokens: typeof t.tokens === "number" ? t.tokens : undefined, + cost: typeof t.cost === "number" ? t.cost : undefined, + added: typeof t.added === "number" ? t.added : undefined, + removed: typeof t.removed === "number" ? t.removed : undefined, + } + } + + const shouldTodoDebugLog = + process.env.ROO_DEBUG_TODO_METADATA === "1" || + process.env.ROO_DEBUG_TODO_METADATA === "true" || + process.env.ROO_CLI_DEBUG_LOG === "1" + console.log("[TODO-DEBUG] execute() STEP 1: computed debug flags", { + shouldTodoDebugLog, + ROO_DEBUG_TODO_METADATA: process.env.ROO_DEBUG_TODO_METADATA, + ROO_CLI_DEBUG_LOG: process.env.ROO_CLI_DEBUG_LOG, + toolProtocol, + }) + const previousFromMemory = getTodoListForTask(task) + console.log("[TODO-DEBUG] execute() STEP 2: previous todos from memory", { + previousFromMemoryCount: Array.isArray(previousFromMemory) ? previousFromMemory.length : 0, + previousFromMemoryPreview: Array.isArray(previousFromMemory) + ? previousFromMemory.slice(0, 10).map((t) => summarizeTodoForDebug(t)) + : undefined, + }) + const previousFromHistory = getLatestTodo(task.clineMessages) as unknown as TodoItem[] | undefined + console.log("[TODO-DEBUG] execute() STEP 3: previous todos from history", { + previousFromHistoryCount: Array.isArray(previousFromHistory) ? previousFromHistory.length : 0, + previousFromHistoryPreview: Array.isArray(previousFromHistory) + ? previousFromHistory.slice(0, 10).map((t) => summarizeTodoForDebug(t)) + : undefined, + }) const historyHasMetadata = Array.isArray(previousFromHistory) && @@ -39,6 +84,21 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { t?.added !== undefined || t?.removed !== undefined, ) + console.log("[TODO-DEBUG] execute() STEP 4: analyzed history metadata", { + historyHasMetadata, + historyHasSubtaskId: Array.isArray(previousFromHistory) + ? previousFromHistory.some((t) => typeof t?.subtaskId === "string") + : false, + historyHasTokens: Array.isArray(previousFromHistory) + ? previousFromHistory.some((t) => typeof t?.tokens === "number") + : false, + historyHasCost: Array.isArray(previousFromHistory) + ? previousFromHistory.some((t) => typeof t?.cost === "number") + : false, + historyHasLineChanges: Array.isArray(previousFromHistory) + ? previousFromHistory.some((t) => typeof t?.added === "number" || typeof t?.removed === "number") + : false, + }) const previousTodos: TodoItem[] = (previousFromMemory?.length ?? 0) === 0 @@ -48,25 +108,109 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { : historyHasMetadata ? (previousFromHistory ?? []) : (previousFromMemory ?? []) + console.log("[TODO-DEBUG] execute() STEP 5: selected previousTodos", { + selectedPreviousTodosCount: Array.isArray(previousTodos) ? previousTodos.length : 0, + selectedPreviousTodosWithSubtaskIdCount: Array.isArray(previousTodos) + ? previousTodos.filter((t) => typeof t?.subtaskId === "string").length + : 0, + selectedPreviousTodosPreview: Array.isArray(previousTodos) + ? previousTodos.slice(0, 10).map((t) => summarizeTodoForDebug(t)) + : undefined, + }) const todosRaw = params.todos + if (shouldTodoDebugLog) { + console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() received params.todos", { + tool: "update_todo_list", + todosRawType: typeof todosRaw, + todosRawLength: typeof todosRaw === "string" ? todosRaw.length : undefined, + todosRawPreview: typeof todosRaw === "string" ? todosRaw.slice(0, 500) : undefined, + }) + console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() previousTodos summary", { + previousFromMemoryCount: Array.isArray(previousFromMemory) ? previousFromMemory.length : 0, + previousFromHistoryCount: Array.isArray(previousFromHistory) ? previousFromHistory.length : 0, + historyHasMetadata, + selectedPreviousTodosCount: Array.isArray(previousTodos) ? previousTodos.length : 0, + previousTodosWithSubtaskIdCount: Array.isArray(previousTodos) + ? previousTodos.filter((t) => typeof t?.subtaskId === "string").length + : 0, + }) + } let todos: TodoItem[] - try { - todos = parseMarkdownChecklist(todosRaw || "") - } catch { + const jsonParseResult = tryParseTodoItemsJson(todosRaw) + if (jsonParseResult.parsed) { + todos = jsonParseResult.parsed + console.log("[TODO-DEBUG] execute() STEP 6: parsed todos via JSON", { + parsedCount: todos.length, + parsedPreview: todos.slice(0, 10).map((t) => summarizeTodoForDebug(t)), + }) + if (shouldTodoDebugLog) { + console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() parsed todos from JSON", { + parsedCount: todos.length, + hasAnySubtaskId: todos.some((t) => typeof t?.subtaskId === "string"), + subtaskIds: todos.map((t) => t?.subtaskId).filter(Boolean), + }) + } + } else if (jsonParseResult.error) { + console.log("[TODO-DEBUG] execute() STEP 6: JSON parse/validate error", { + error: jsonParseResult.error, + todosRawPreview: typeof todosRaw === "string" ? todosRaw.slice(0, 500) : undefined, + }) task.consecutiveMistakeCount++ task.recordToolError("update_todo_list") task.didToolFailInCurrentTurn = true - pushToolResult(formatResponse.toolError("The todos parameter is not valid markdown checklist or JSON")) + pushToolResult(formatResponse.toolError(jsonParseResult.error)) return + } else { + // Backward compatible: fall back to markdown checklist parsing when JSON parsing is not applicable. + todos = parseMarkdownChecklist(todosRaw || "") + console.log("[TODO-DEBUG] execute() STEP 6: parsed todos via markdown checklist", { + parsedCount: todos.length, + parsedPreview: todos.slice(0, 10).map((t) => summarizeTodoForDebug(t)), + }) + if (shouldTodoDebugLog) { + console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() parsed todos from markdown checklist", { + parsedCount: todos.length, + hasAnySubtaskId: todos.some((t) => typeof t?.subtaskId === "string"), + }) + } } // Preserve metadata (subtaskId/tokens/cost) for todos whose content matches an existing todo. // Matching is by exact content string; duplicates are matched in order. - const todosWithPreservedMetadata = preserveTodoMetadata(todos, previousTodos) + // NOTE: Instrumentation is enabled here (once per tool execute) to detect metadata-preservation failures. + console.log("[TODO-DEBUG] execute() STEP 7: about to call preserveTodoMetadata", { + nextTodosCount: Array.isArray(todos) ? todos.length : 0, + previousTodosCount: Array.isArray(previousTodos) ? previousTodos.length : 0, + enableInstrumentation: true, + }) + const todosWithPreservedMetadata = preserveTodoMetadata(todos, previousTodos, { + enableInstrumentation: true, + }) + console.log("[TODO-DEBUG] execute() STEP 8: returned from preserveTodoMetadata", { + resultCount: todosWithPreservedMetadata.length, + resultPreview: todosWithPreservedMetadata.slice(0, 10).map((t) => summarizeTodoForDebug(t)), + }) + if (shouldTodoDebugLog) { + console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() after preserveTodoMetadata()", { + nextTodosCount: todosWithPreservedMetadata.length, + todosWithSubtaskIdCount: todosWithPreservedMetadata.filter((t) => typeof t?.subtaskId === "string") + .length, + subtaskIds: todosWithPreservedMetadata.map((t) => t?.subtaskId).filter(Boolean), + hasAnyTokens: todosWithPreservedMetadata.some((t) => typeof t?.tokens === "number"), + hasAnyCost: todosWithPreservedMetadata.some((t) => typeof t?.cost === "number"), + hasAnyLineChanges: todosWithPreservedMetadata.some( + (t) => typeof t?.added === "number" || typeof t?.removed === "number", + ), + }) + } - const { valid, error } = validateTodos(todos) + const { valid, error } = validateTodos(todosWithPreservedMetadata) + console.log("[TODO-DEBUG] execute() STEP 9: validateTodos", { + valid, + error, + }) if (!valid) { task.consecutiveMistakeCount++ task.recordToolError("update_todo_list") @@ -85,27 +229,76 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { added: t.added, removed: t.removed, })) + console.log("[TODO-DEBUG] execute() STEP 10: normalizedTodos (pre-approval)", { + normalizedCount: normalizedTodos.length, + normalizedPreview: normalizedTodos.slice(0, 10).map((t) => summarizeTodoForDebug(t)), + }) + if (shouldTodoDebugLog) { + console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() normalizedTodos (pre-approval)", { + normalizedCount: normalizedTodos.length, + todosWithSubtaskIdCount: normalizedTodos.filter((t) => typeof t?.subtaskId === "string").length, + subtaskIds: normalizedTodos.map((t) => t?.subtaskId).filter(Boolean), + }) + } const approvalMsg = JSON.stringify({ tool: "updateTodoList", todos: normalizedTodos, }) - approvedTodoList = cloneDeep(normalizedTodos) + // TodoItem is a flat object shape; a shallow copy is sufficient here. + approvedTodoList = normalizedTodos.map((t) => ({ ...t })) + console.log("[TODO-DEBUG] execute() STEP 11: asking approval", { + approvalPayloadLength: approvalMsg.length, + normalizedCount: normalizedTodos.length, + }) const didApprove = await askApproval("tool", approvalMsg) + console.log("[TODO-DEBUG] execute() STEP 12: approval result", { + didApprove, + }) if (!didApprove) { + console.log("[TODO-DEBUG] execute() STEP 13: user declined; returning", {}) pushToolResult("User declined to update the todoList.") return } const isTodoListChanged = approvedTodoList !== undefined && JSON.stringify(normalizedTodos) !== JSON.stringify(approvedTodoList) + console.log("[TODO-DEBUG] execute() STEP 14: checked approval UI edits", { + isTodoListChanged, + }) if (isTodoListChanged) { normalizedTodos = approvedTodoList ?? [] + console.log("[TODO-DEBUG] execute() STEP 15: using user-edited todos", { + editedCount: normalizedTodos.length, + editedPreview: normalizedTodos.slice(0, 10).map((t) => summarizeTodoForDebug(t)), + }) + if (shouldTodoDebugLog) { + console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() user edited todos in approval UI", { + editedCount: normalizedTodos.length, + todosWithSubtaskIdCount: normalizedTodos.filter((t) => typeof t?.subtaskId === "string").length, + subtaskIds: normalizedTodos.map((t) => t?.subtaskId).filter(Boolean), + }) + } // If the user-edited todo list dropped metadata fields, re-apply metadata preservation against // the previous list (and keep any explicitly provided metadata in the edited list). - normalizedTodos = preserveTodoMetadata(normalizedTodos, previousTodos) + // NOTE: Do not instrument here to avoid double-logging within the same update. + console.log("[TODO-DEBUG] execute() STEP 16: about to re-call preserveTodoMetadata (user-edited)", { + enableInstrumentation: false, + }) + normalizedTodos = preserveTodoMetadata(normalizedTodos, previousTodos, { enableInstrumentation: false }) + console.log("[TODO-DEBUG] execute() STEP 17: returned from re-preserve (user-edited)", { + normalizedCount: normalizedTodos.length, + normalizedPreview: normalizedTodos.slice(0, 10).map((t) => summarizeTodoForDebug(t)), + }) + if (shouldTodoDebugLog) { + console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() normalizedTodos after re-preserve", { + normalizedCount: normalizedTodos.length, + todosWithSubtaskIdCount: normalizedTodos.filter((t) => typeof t?.subtaskId === "string").length, + subtaskIds: normalizedTodos.map((t) => t?.subtaskId).filter(Boolean), + }) + } task.say( "user_edit_todos", @@ -116,15 +309,29 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { ) } + console.log("[TODO-DEBUG] execute() STEP 18: setting todoList on task", { + finalTodosCount: normalizedTodos.length, + }) await setTodoListForTask(task, normalizedTodos) + console.log("[TODO-DEBUG] execute() STEP 19: setTodoListForTask completed", { + taskTodoListCount: Array.isArray(task?.todoList) ? task.todoList.length : undefined, + }) if (isTodoListChanged) { const md = todoListToMarkdown(normalizedTodos) + console.log("[TODO-DEBUG] execute() STEP 20: returning tool result (user edits)", { + mdLength: md.length, + }) pushToolResult(formatResponse.toolResult("User edits todo:\n\n" + md)) } else { + console.log("[TODO-DEBUG] execute() STEP 20: returning tool result (no user edits)", {}) pushToolResult(formatResponse.toolResult("Todo list updated successfully.")) } } catch (error) { + console.log("[TODO-DEBUG] execute() STEP 99: caught error", { + error: + error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : error, + }) await handleError("update todo list", error as Error) } } @@ -142,7 +349,8 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { todos = [] } - todos = preserveTodoMetadata(todos, previousTodos) + // Avoid log spam: partial updates can stream frequently. + todos = preserveTodoMetadata(todos, previousTodos, { enableInstrumentation: false }) const approvalMsg = JSON.stringify({ tool: "updateTodoList", @@ -237,10 +445,56 @@ function normalizeStatus(status: string | undefined): TodoStatus { * This approach ensures metadata survives status/content changes (which can alter the derived ID) * and handles duplicates deterministically. */ -function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): TodoItem[] { +function preserveTodoMetadata( + nextTodos: TodoItem[], + previousTodos: TodoItem[], + options?: { enableInstrumentation?: boolean }, +): TodoItem[] { + console.log("[TODO-DEBUG] preserveTodoMetadata() STEP 0: ENTERED", { + nextTodosCount: Array.isArray(nextTodos) ? nextTodos.length : 0, + previousTodosCount: Array.isArray(previousTodos) ? previousTodos.length : 0, + enableInstrumentationOption: options?.enableInstrumentation ?? false, + ROO_DEBUG_TODO_METADATA: process.env.ROO_DEBUG_TODO_METADATA, + ROO_CLI_DEBUG_LOG: process.env.ROO_CLI_DEBUG_LOG, + }) + + const shouldTodoDebugLogToConsole = + process.env.ROO_DEBUG_TODO_METADATA === "1" || + process.env.ROO_DEBUG_TODO_METADATA === "true" || + process.env.ROO_CLI_DEBUG_LOG === "1" + const safePrevious = previousTodos ?? [] const safeNext = nextTodos ?? [] + const summarizeTodoForDebug = (t: TodoItem | undefined) => { + if (!t) return undefined + return { + id: typeof t.id === "string" ? t.id : undefined, + status: typeof t.status === "string" ? t.status : undefined, + content: typeof t.content === "string" ? t.content.substring(0, 50) : undefined, + subtaskId: typeof t.subtaskId === "string" ? t.subtaskId : undefined, + tokens: typeof t.tokens === "number" ? t.tokens : undefined, + cost: typeof t.cost === "number" ? t.cost : undefined, + added: typeof t.added === "number" ? t.added : undefined, + removed: typeof t.removed === "number" ? t.removed : undefined, + } + } + + if (shouldTodoDebugLogToConsole) { + console.log("[TODO-DEBUG] preserveTodoMetadata INPUT", { + previousTodosCount: safePrevious.length, + previousTodos: safePrevious.map((t) => summarizeTodoForDebug(t)), + newTodosCount: safeNext.length, + newTodos: safeNext.map((t) => summarizeTodoForDebug(t)), + }) + } + + // Instrumentation must never write to stdout/stderr (CLI TUI) and should be opt-in. + // Gate it behind an env var so we don't write files during normal operation. + const enableInstrumentation = + (options?.enableInstrumentation ?? false) && + (process.env.ROO_CLI_DEBUG_LOG === "1" || process.env.ROO_DEBUG_TODO_METADATA === "1") + // Track which previous todos have been used (by their index) to avoid double-matching const usedPreviousIndices = new Set() @@ -252,10 +506,21 @@ function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): const previousById = new Map() const previousByContent = new Map>() + const previousMetadataIndices = new Set() for (let i = 0; i < safePrevious.length; i++) { const prev = safePrevious[i] if (!prev) continue + const hasMetadata = + prev.subtaskId !== undefined || + prev.tokens !== undefined || + prev.cost !== undefined || + prev.added !== undefined || + prev.removed !== undefined + if (hasMetadata) { + previousMetadataIndices.add(i) + } + if (typeof prev.subtaskId === "string") { const list = previousBySubtaskId.get(prev.subtaskId) if (list) list.push({ todo: prev, index: i }) @@ -267,26 +532,62 @@ function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): } if (typeof prev.content === "string") { - const list = previousByContent.get(prev.content) + const normalizedContent = normalizeTodoContentForId(prev.content) + const list = previousByContent.get(normalizedContent) if (list) list.push({ todo: prev, index: i }) - else previousByContent.set(prev.content, [{ todo: prev, index: i }]) + else previousByContent.set(normalizedContent, [{ todo: prev, index: i }]) } } - return safeNext.map((next) => { - if (!next) return next + // IMPORTANT: use an explicit fill here (vs a sparse array) so checks like + // [`Array.prototype.some()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some) + // properly visit every index. This is required for the rename-only index-carryover fallback. + const matchedPreviousIndexByNextIndex: Array = new Array(safeNext.length).fill(undefined) + const result: TodoItem[] = new Array(safeNext.length) + + for (let nextIndex = 0; nextIndex < safeNext.length; nextIndex++) { + const next = safeNext[nextIndex] + if (!next) { + result[nextIndex] = next + continue + } + + if (shouldTodoDebugLogToConsole) { + console.log("[TODO-DEBUG] preserveTodoMetadata ITERATION", { + nextIndex, + next: summarizeTodoForDebug(next), + usedPreviousIndicesCount: usedPreviousIndices.size, + }) + } let matchedPrev: TodoItem | undefined = undefined let matchedIndex: number | undefined = undefined + let matchStrategy: "subtaskId" | "id" | "content" | "none" = "none" // Strategy 0: Try subtaskId-based matching first (most stable across content/ID changes) if (typeof next.subtaskId === "string") { const candidates = previousBySubtaskId.get(next.subtaskId) if (candidates) { + if (shouldTodoDebugLogToConsole) { + console.log("[TODO-DEBUG] preserveTodoMetadata subtaskId candidates", { + nextIndex, + subtaskId: next.subtaskId, + candidatesCount: candidates.length, + }) + } for (const candidate of candidates) { + if (shouldTodoDebugLogToConsole) { + console.log("[TODO-DEBUG] preserveTodoMetadata subtaskId candidate", { + nextIndex, + candidateIndex: candidate.index, + candidate: summarizeTodoForDebug(candidate.todo), + candidateAlreadyUsed: usedPreviousIndices.has(candidate.index), + }) + } if (!usedPreviousIndices.has(candidate.index)) { matchedPrev = candidate.todo matchedIndex = candidate.index + matchStrategy = "subtaskId" break } } @@ -299,18 +600,36 @@ function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): if (byId && !usedPreviousIndices.has(byId.index)) { matchedPrev = byId.todo matchedIndex = byId.index + matchStrategy = "id" } } // Strategy 2: Fall back to content-based matching if ID didn't match if (!matchedPrev && typeof next.content === "string") { - const candidates = previousByContent.get(next.content) + const normalizedContent = normalizeTodoContentForId(next.content) + const candidates = previousByContent.get(normalizedContent) if (candidates) { + if (shouldTodoDebugLogToConsole) { + console.log("[TODO-DEBUG] preserveTodoMetadata content candidates", { + nextIndex, + normalizedContent: normalizedContent.substring(0, 50), + candidatesCount: candidates.length, + }) + } // Find first unused candidate for (const candidate of candidates) { + if (shouldTodoDebugLogToConsole) { + console.log("[TODO-DEBUG] preserveTodoMetadata content candidate", { + nextIndex, + candidateIndex: candidate.index, + candidate: summarizeTodoForDebug(candidate.todo), + candidateAlreadyUsed: usedPreviousIndices.has(candidate.index), + }) + } if (!usedPreviousIndices.has(candidate.index)) { matchedPrev = candidate.todo matchedIndex = candidate.index + matchStrategy = "content" break } } @@ -319,8 +638,26 @@ function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): // Mark as used and apply metadata if (matchedPrev && matchedIndex !== undefined) { + const metadataCopiedFromPrev = { + subtaskId: next.subtaskId === undefined ? matchedPrev.subtaskId : undefined, + tokens: next.tokens === undefined ? matchedPrev.tokens : undefined, + cost: next.cost === undefined ? matchedPrev.cost : undefined, + added: next.added === undefined ? matchedPrev.added : undefined, + removed: next.removed === undefined ? matchedPrev.removed : undefined, + } + if (shouldTodoDebugLogToConsole) { + console.log("[TODO-DEBUG] preserveTodoMetadata MATCH", { + nextIndex, + matchStrategy, + matchedIndex, + matchedPrev: summarizeTodoForDebug(matchedPrev), + metadataCopiedFromPrev, + }) + } + usedPreviousIndices.add(matchedIndex) - return { + matchedPreviousIndexByNextIndex[nextIndex] = matchedIndex + result[nextIndex] = { ...next, subtaskId: next.subtaskId ?? matchedPrev.subtaskId, tokens: next.tokens ?? matchedPrev.tokens, @@ -328,10 +665,191 @@ function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): added: next.added ?? matchedPrev.added, removed: next.removed ?? matchedPrev.removed, } + continue } - return next - }) + if (shouldTodoDebugLogToConsole) { + console.log("[TODO-DEBUG] preserveTodoMetadata NO_MATCH", { + nextIndex, + next: summarizeTodoForDebug(next), + }) + } + + result[nextIndex] = next + } + + // Short-term patch: deterministic “rename-only by index” metadata carryover. + // + // Applies only when: + // - lengths are identical + // - status sequence matches index-for-index + // - at least one row could not be matched by subtaskId/id/content + // + // For unmatched rows, carry over metadata fields by index. + const hasUnmatchedRows = matchedPreviousIndexByNextIndex.some((idx) => idx === undefined) + const canApplyIndexRenameCarryover = + hasUnmatchedRows && + safePrevious.length === safeNext.length && + todoStatusSequenceMatchesByIndex(safePrevious, safeNext) + + if (canApplyIndexRenameCarryover) { + let indexCarryoverCount = 0 + for (let i = 0; i < safeNext.length; i++) { + if (matchedPreviousIndexByNextIndex[i] !== undefined) continue // already matched by stable strategy + if (usedPreviousIndices.has(i)) continue // avoid double-using a previous row + const prev = safePrevious[i] + const next = result[i] + if (!prev || !next) continue + + if (shouldTodoDebugLogToConsole) { + console.log("[TODO-DEBUG] preserveTodoMetadata INDEX_CARRYOVER", { + nextIndex: i, + previousIndex: i, + prev: summarizeTodoForDebug(prev), + next: summarizeTodoForDebug(next), + metadataCopiedFromPrev: { + subtaskId: next.subtaskId === undefined ? prev.subtaskId : undefined, + tokens: next.tokens === undefined ? prev.tokens : undefined, + cost: next.cost === undefined ? prev.cost : undefined, + added: next.added === undefined ? prev.added : undefined, + removed: next.removed === undefined ? prev.removed : undefined, + }, + }) + } + + result[i] = { + ...next, + subtaskId: next.subtaskId ?? prev.subtaskId, + tokens: next.tokens ?? prev.tokens, + cost: next.cost ?? prev.cost, + added: next.added ?? prev.added, + removed: next.removed ?? prev.removed, + } + usedPreviousIndices.add(i) + indexCarryoverCount++ + } + + if (enableInstrumentation && indexCarryoverCount > 0) { + appendRooCliDebugLog("[Roo-Debug] preserveTodoMetadata: applied index-based rename carryover", { + indexCarryoverCount, + previousTodosCount: safePrevious.length, + nextTodosCount: safeNext.length, + }) + } + } + + // Fallback: carry forward metadata from unmatched delegated todos + // to new todos that don't have a subtaskId yet. + // + // This addresses the case where delegation replaces the original todo content with a synthetic + // placeholder (e.g. "Delegated to subtask") and an ID like "synthetic-{subtaskId}", but the LLM + // later rewrites the todo content entirely. In that scenario, subtaskId/id/content matching can all + // fail, causing the delegated metadata to be lost. + const unmatchedPreviousDelegatedTodos = safePrevious + .map((prev, index) => ({ prev, index })) + .filter(({ prev, index }) => typeof prev?.subtaskId === "string" && !usedPreviousIndices.has(index)) + + const nextTodoIndicesWithoutSubtaskId = result + .map((todo, index) => ({ todo, index })) + .filter(({ todo }) => todo !== undefined && todo.subtaskId === undefined) + .map(({ index }) => index) + + for (let i = 0; i < unmatchedPreviousDelegatedTodos.length && i < nextTodoIndicesWithoutSubtaskId.length; i++) { + const { prev: orphanedPrev, index: orphanedPrevIndex } = unmatchedPreviousDelegatedTodos[i] + const targetNextIndex = nextTodoIndicesWithoutSubtaskId[i] + const targetNext = result[targetNextIndex] + if (!orphanedPrev || !targetNext) continue + + const updatedTarget: TodoItem = { + ...targetNext, + subtaskId: targetNext.subtaskId ?? orphanedPrev.subtaskId, + tokens: targetNext.tokens ?? orphanedPrev.tokens, + cost: targetNext.cost ?? orphanedPrev.cost, + added: targetNext.added ?? orphanedPrev.added, + removed: targetNext.removed ?? orphanedPrev.removed, + } + + result[targetNextIndex] = updatedTarget + usedPreviousIndices.add(orphanedPrevIndex) + matchedPreviousIndexByNextIndex[targetNextIndex] = orphanedPrevIndex + + if (shouldTodoDebugLogToConsole) { + console.log("[TODO-DEBUG] preserveTodoMetadata ORPHAN_CARRYOVER", { + orphanedContent: orphanedPrev.content?.substring(0, 40), + orphanedSubtaskId: orphanedPrev.subtaskId, + targetContent: updatedTarget.content?.substring(0, 40), + copiedFields: { + subtaskId: updatedTarget.subtaskId, + tokens: updatedTarget.tokens, + cost: updatedTarget.cost, + }, + }) + } + } + + // Lightweight debug instrumentation: detect when previous rows that had metadata could not be + // matched to any next todo row (and therefore their metadata could not be preserved). + // + // Keep payload minimal to avoid logging user content. + if (enableInstrumentation && previousMetadataIndices.size > 0) { + let lostMetadataRowCount = 0 + for (const prevIndex of previousMetadataIndices) { + if (!usedPreviousIndices.has(prevIndex)) { + lostMetadataRowCount++ + } + } + + if (lostMetadataRowCount > 0) { + if (shouldTodoDebugLogToConsole) { + console.log("[TODO-DEBUG] preserveTodoMetadata LOST_METADATA", { + lostMetadataRowCount, + previousMetadataRowCount: previousMetadataIndices.size, + previousTodosCount: safePrevious.length, + nextTodosCount: safeNext.length, + }) + } + // IMPORTANT: do not use console.log here in the CLI TUI. It can corrupt rendering (e.g. dropdowns). + appendRooCliDebugLog("[Roo-Debug] preserveTodoMetadata: previous todo(s) with metadata were not matched", { + lostMetadataRowCount, + previousMetadataRowCount: previousMetadataIndices.size, + previousTodosCount: safePrevious.length, + nextTodosCount: safeNext.length, + }) + } + } + + if (shouldTodoDebugLogToConsole) { + console.log("[TODO-DEBUG] preserveTodoMetadata OUTPUT", { + resultCount: result.length, + resultTodos: result.map((t) => summarizeTodoForDebug(t)), + }) + } + + return result +} + +function todoStatusSequenceMatchesByIndex(previous: TodoItem[], next: TodoItem[]): boolean { + const safePrevious = previous ?? [] + const safeNext = next ?? [] + if (safePrevious.length !== safeNext.length) return false + for (let i = 0; i < safeNext.length; i++) { + const prevStatus = normalizeStatus(safePrevious[i]?.status) + const nextStatus = normalizeStatus(safeNext[i]?.status) + if (prevStatus !== nextStatus) return false + } + return true +} + +const ROO_CLI_DEBUG_LOG_PATH = path.join(os.tmpdir(), "roo-cli-debug.log") + +function appendRooCliDebugLog(message: string, data?: unknown) { + try { + const timestamp = new Date().toISOString() + const entry = data ? `[${timestamp}] ${message}: ${JSON.stringify(data)}\n` : `[${timestamp}] ${message}\n` + fs.appendFileSync(ROO_CLI_DEBUG_LOG_PATH, entry) + } catch { + // Swallow errors: logging must never break tool execution. + } } export function parseMarkdownChecklist(md: string): TodoItem[] { @@ -340,6 +858,11 @@ export function parseMarkdownChecklist(md: string): TodoItem[] { .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean) + + // Tracks occurrences of the same normalized todo content so duplicate rows get + // deterministic, stable IDs. + const occurrenceByNormalizedContent = new Map() + const todos: TodoItem[] = [] for (const line of lines) { const match = line.match(/^(?:-\s*)?\[\s*([ xX\-~])\s*\]\s+(.+)$/) @@ -347,19 +870,84 @@ export function parseMarkdownChecklist(md: string): TodoItem[] { let status: TodoStatus = "pending" if (match[1] === "x" || match[1] === "X") status = "completed" else if (match[1] === "-" || match[1] === "~") status = "in_progress" - const id = crypto - .createHash("md5") - .update(match[2] + status) - .digest("hex") + + const content = match[2] + const normalizedContent = normalizeTodoContentForId(content) + const occurrence = (occurrenceByNormalizedContent.get(normalizedContent) ?? 0) + 1 + occurrenceByNormalizedContent.set(normalizedContent, occurrence) + + // ID must be stable across status changes. + // For duplicates (same normalized content), include occurrence index. + const id = crypto.createHash("md5").update(`${normalizedContent}#${occurrence}`).digest("hex") todos.push({ id, - content: match[2], + content, status, }) } return todos } +function tryParseTodoItemsJson(raw: string): { parsed?: TodoItem[]; error?: string } { + if (typeof raw !== "string") return {} + const trimmed = raw.trim() + if (trimmed.length === 0) return {} + + // Fast-path: avoid trying JSON.parse for obvious markdown inputs. + // JSON arrays/objects must start with '[' or '{'. + const firstChar = trimmed[0] + if (firstChar !== "[" && firstChar !== "{") return {} + + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + return {} + } + + if (!Array.isArray(parsed)) { + // Only support the new structured format when it is a JSON array of TodoItem-like objects. + return {} + } + + const normalized: TodoItem[] = [] + for (const [i, item] of parsed.entries()) { + if (!item || typeof item !== "object") { + return { error: `Item ${i + 1} is not an object` } + } + + const t = item as Record + const id = t.id + const content = t.content + + if (typeof id !== "string" || id.length === 0) return { error: `Item ${i + 1} is missing id` } + if (typeof content !== "string" || content.length === 0) return { error: `Item ${i + 1} is missing content` } + + const normalizedItem: TodoItem = { + id, + content, + status: normalizeStatus(typeof t.status === "string" ? t.status : undefined), + ...(typeof t.subtaskId === "string" ? { subtaskId: t.subtaskId } : {}), + ...(typeof t.tokens === "number" ? { tokens: t.tokens } : {}), + ...(typeof t.cost === "number" ? { cost: t.cost } : {}), + ...(typeof t.added === "number" ? { added: t.added } : {}), + ...(typeof t.removed === "number" ? { removed: t.removed } : {}), + } + + normalized.push(normalizedItem) + } + + const { valid, error } = validateTodos(normalized) + if (!valid) return { error: error || "todos JSON validation failed" } + + return { parsed: normalized } +} + +function normalizeTodoContentForId(content: string): string { + // Normalize whitespace so trivial formatting changes don't churn IDs. + return content.trim().replace(/\s+/g, " ") +} + export function setPendingTodoList(todos: TodoItem[]) { approvedTodoList = todos } diff --git a/src/core/tools/__tests__/updateTodoListTool.spec.ts b/src/core/tools/__tests__/updateTodoListTool.spec.ts index c67730e6c8f..af96c352f7d 100644 --- a/src/core/tools/__tests__/updateTodoListTool.spec.ts +++ b/src/core/tools/__tests__/updateTodoListTool.spec.ts @@ -206,7 +206,7 @@ Just some text }) describe("ID generation", () => { - it("should generate consistent IDs for the same content and status", () => { + it("should generate consistent IDs for the same content", () => { const md1 = `[ ] Task 1 [x] Task 2` const md2 = `[ ] Task 1 @@ -225,11 +225,19 @@ Just some text expect(result[0].id).not.toBe(result[1].id) }) - it("should generate different IDs for same content but different status", () => { - const md = `[ ] Task 1 -[x] Task 1` - const result = parseMarkdownChecklist(md) - expect(result[0].id).not.toBe(result[1].id) + it("should generate the same ID for the same content even when status changes", () => { + const pending = parseMarkdownChecklist(`[ ] Task 1`) + const completed = parseMarkdownChecklist(`[x] Task 1`) + expect(pending[0].id).toBe(completed[0].id) + }) + + it("should keep duplicate IDs stable by occurrence even when status changes", () => { + const pending = parseMarkdownChecklist(`[ ] Task 1\n[ ] Task 1`) + const completed = parseMarkdownChecklist(`[x] Task 1\n[x] Task 1`) + expect(pending[0].id).toBe(completed[0].id) + expect(pending[1].id).toBe(completed[1].id) + // Within a single parse, duplicates must not share IDs. + expect(pending[0].id).not.toBe(pending[1].id) }) it("should generate same IDs regardless of dash prefix", () => { @@ -239,6 +247,14 @@ Just some text const result2 = parseMarkdownChecklist(md2) expect(result1[0].id).toBe(result2[0].id) }) + + it("should generate the same IDs for the same content even when whitespace differs", () => { + const md1 = `[ ] Task 1` + const md2 = `[ ] Task 1` + const result1 = parseMarkdownChecklist(md1) + const result2 = parseMarkdownChecklist(md2) + expect(result1[0].id).toBe(result2[0].id) + }) }) }) @@ -247,6 +263,244 @@ describe("UpdateTodoListTool.execute", () => { setPendingTodoList([]) }) + it("should preserve per-row metadata (subtaskId/tokens/cost/added/removed) when only statuses change (bulk markdown rewrite)", async () => { + /** + * Regression test: a bulk markdown rewrite often changes the derived todo `id` + * (since [`parseMarkdownChecklist()`](../UpdateTodoListTool.ts:337) hashes + * `content + status`). When only statuses change, we must still preserve the + * existing per-row metadata. This is especially important for duplicates, + * where unstable IDs and/or duplicate IDs can cause metadata to be dropped + * or misapplied. + */ + const initialMd = "[ ] Task A\n[ ] Task B\n[ ] Task A\n[ ] Task C" + const updatedMd = "[x] Task A\n[x] Task B\n[x] Task A\n[ ] Task C" // content identical, only statuses change + + const previousFromMemory: TodoItem[] = parseMarkdownChecklist(initialMd).map((t, idx) => ({ + ...t, + subtaskId: `subtask-${idx + 1}`, + tokens: 1000 + idx, + cost: 0.01 * (idx + 1), + added: 10 * (idx + 1), + removed: idx, + })) + + const task = { + todoList: previousFromMemory, + clineMessages: [], + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + say: vi.fn(), + } as any + + const tool = new UpdateTodoListTool() + await tool.execute({ todos: updatedMd }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockResolvedValue(true), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + }) + + expect(task.todoList).toHaveLength(4) + + // Preserve per-row metadata (including duplicates) by order. + expect(task.todoList[0]).toEqual( + expect.objectContaining({ + content: "Task A", + status: "completed", + subtaskId: "subtask-1", + tokens: 1000, + cost: 0.01, + added: 10, + removed: 0, + }), + ) + + expect(task.todoList[1]).toEqual( + expect.objectContaining({ + content: "Task B", + status: "completed", + subtaskId: "subtask-2", + tokens: 1001, + cost: 0.02, + added: 20, + removed: 1, + }), + ) + + expect(task.todoList[2]).toEqual( + expect.objectContaining({ + content: "Task A", + status: "completed", + subtaskId: "subtask-3", + tokens: 1002, + cost: 0.03, + added: 30, + removed: 2, + }), + ) + + expect(task.todoList[3]).toEqual( + expect.objectContaining({ + content: "Task C", + status: "pending", + subtaskId: "subtask-4", + tokens: 1003, + cost: 0.04, + added: 40, + removed: 3, + }), + ) + }) + + it("should preserve subtaskId/metrics when items are renamed but status sequence and length are unchanged (markdown)", async () => { + const initialMd = "[ ] Old A\n[x] Old B\n[-] Old C" + const updatedMd = "[ ] New A\n[x] New B\n[-] New C" // same length + same status sequence, only content changed + + const previousFromMemory: TodoItem[] = parseMarkdownChecklist(initialMd).map((t, idx) => ({ + ...t, + subtaskId: `subtask-${idx + 1}`, + tokens: 100 + idx, + cost: 0.01 * (idx + 1), + added: 10 * (idx + 1), + removed: idx, + })) + + const task = { + todoList: previousFromMemory, + clineMessages: [], + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + say: vi.fn(), + } as any + + const tool = new UpdateTodoListTool() + await tool.execute({ todos: updatedMd }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockResolvedValue(true), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + }) + + expect(task.todoList).toHaveLength(3) + + expect(task.todoList[0]).toEqual( + expect.objectContaining({ + content: "New A", + status: "pending", + subtaskId: "subtask-1", + tokens: 100, + cost: 0.01, + added: 10, + removed: 0, + }), + ) + expect(task.todoList[1]).toEqual( + expect.objectContaining({ + content: "New B", + status: "completed", + subtaskId: "subtask-2", + tokens: 101, + cost: 0.02, + added: 20, + removed: 1, + }), + ) + expect(task.todoList[2]).toEqual( + expect.objectContaining({ + content: "New C", + status: "in_progress", + subtaskId: "subtask-3", + tokens: 102, + cost: 0.03, + added: 30, + removed: 2, + }), + ) + }) + + it("should accept JSON TodoItem[] payload and preserve ids/subtask links across renames", async () => { + const previousFromMemory: TodoItem[] = [ + { + id: "id-1", + content: "Alpha", + status: "pending", + subtaskId: "subtask-1", + tokens: 111, + cost: 0.11, + added: 11, + removed: 1, + }, + { + id: "id-2", + content: "Beta", + status: "completed", + subtaskId: "subtask-2", + tokens: 222, + cost: 0.22, + added: 22, + removed: 2, + }, + ] + + // Reorder + rename while keeping id/subtaskId stable; omit metrics to verify preservation. + const jsonPayload: TodoItem[] = [ + { id: "id-2", content: "Beta renamed", status: "completed", subtaskId: "subtask-2" }, + { id: "id-1", content: "Alpha renamed", status: "pending", subtaskId: "subtask-1" }, + ] + + const task = { + todoList: previousFromMemory, + clineMessages: [], + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + say: vi.fn(), + } as any + + const tool = new UpdateTodoListTool() + await tool.execute({ todos: JSON.stringify(jsonPayload) }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockResolvedValue(true), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + }) + + expect(task.todoList).toHaveLength(2) + // Order should match the JSON payload. + expect(task.todoList.map((t: TodoItem) => t.id)).toEqual(["id-2", "id-1"]) + + expect(task.todoList[0]).toEqual( + expect.objectContaining({ + id: "id-2", + content: "Beta renamed", + status: "completed", + subtaskId: "subtask-2", + tokens: 222, + cost: 0.22, + added: 22, + removed: 2, + }), + ) + + expect(task.todoList[1]).toEqual( + expect.objectContaining({ + id: "id-1", + content: "Alpha renamed", + status: "pending", + subtaskId: "subtask-1", + tokens: 111, + cost: 0.11, + added: 11, + removed: 1, + }), + ) + }) + it("should prefer history todos when they contain metadata (subtaskId/tokens/cost)", async () => { const md = "[ ] Task 1" @@ -436,6 +690,223 @@ describe("UpdateTodoListTool.execute", () => { ) }) + it("should preserve metadata when content changes only by whitespace/formatting (legacy ids)", async () => { + const md = "[x] Task 1\n[ ] Task 2" + + const previousFromMemory: TodoItem[] = [ + { + id: "legacy-1", + content: "Task 1", + status: "pending", + tokens: 111, + cost: 0.11, + added: 11, + removed: 1, + }, + { + id: "legacy-2", + content: "Task 2", + status: "pending", + tokens: 222, + cost: 0.22, + added: 22, + removed: 2, + }, + ] + + const task = { + todoList: previousFromMemory, + clineMessages: [], + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + say: vi.fn(), + } as any + + const tool = new UpdateTodoListTool() + await tool.execute({ todos: md }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockResolvedValue(true), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + } as any) + + expect(task.todoList).toHaveLength(2) + + const task1 = task.todoList.find((t: TodoItem) => t.content === "Task 1") + const task2 = task.todoList.find((t: TodoItem) => t.content === "Task 2") + + expect(task1).toEqual( + expect.objectContaining({ + content: "Task 1", + status: "completed", + tokens: 111, + cost: 0.11, + added: 11, + removed: 1, + }), + ) + expect(task2).toEqual( + expect.objectContaining({ + content: "Task 2", + status: "pending", + tokens: 222, + cost: 0.22, + added: 22, + removed: 2, + }), + ) + }) + + it("should carry forward metadata from unmatched delegated todos when the LLM rewrites content", async () => { + const delegatedSubtaskId = "019bdcf3-b738-7779-ba86-a4838b490b40" + const previousFromMemory: TodoItem[] = [ + { + id: `synthetic-${delegatedSubtaskId}`, + content: "Delegated to subtask", + status: "pending", + subtaskId: delegatedSubtaskId, + tokens: 1234, + cost: 0.12, + added: 10, + removed: 2, + }, + ] + + // Simulate the LLM rewriting the todo content entirely (no content/id/subtaskId match). + // Use a different-length list so the rename-by-index fallback does NOT apply. + const updatedMd = "[ ] Delegate joke-telling to Ask mode\n[ ] Another unrelated task" + + const task = { + todoList: previousFromMemory, + clineMessages: [], + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + say: vi.fn(), + } as any + + const tool = new UpdateTodoListTool() + await tool.execute({ todos: updatedMd }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockResolvedValue(true), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + }) + + expect(task.todoList).toHaveLength(2) + expect(task.todoList[0]).toEqual( + expect.objectContaining({ + content: "Delegate joke-telling to Ask mode", + status: "pending", + subtaskId: delegatedSubtaskId, + tokens: 1234, + cost: 0.12, + added: 10, + removed: 2, + }), + ) + + // Ensure the second new todo doesn't incorrectly inherit delegated metadata. + expect(task.todoList[1]).toEqual( + expect.objectContaining({ + content: "Another unrelated task", + status: "pending", + }), + ) + expect(task.todoList[1].subtaskId).toBeUndefined() + expect(task.todoList[1].tokens).toBeUndefined() + expect(task.todoList[1].cost).toBeUndefined() + expect(task.todoList[1].added).toBeUndefined() + expect(task.todoList[1].removed).toBeUndefined() + }) + + it("should carry forward metadata from unmatched delegated todos even when the previous id is non-synthetic (sequential updates)", async () => { + const delegatedSubtaskId = "019bdcf3-b738-7779-ba86-a4838b490b41" + const previousFromMemory: TodoItem[] = [ + { + id: `synthetic-${delegatedSubtaskId}`, + content: "Delegated to subtask", + status: "pending", + subtaskId: delegatedSubtaskId, + tokens: 1234, + cost: 0.12, + added: 10, + removed: 2, + }, + { + id: "other-1", + content: "Another unrelated task", + status: "pending", + }, + ] + + const task = { + todoList: previousFromMemory, + clineMessages: [], + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + say: vi.fn(), + } as any + + const tool = new UpdateTodoListTool() + + // Update 1: delegated todo gets rewritten into a new markdown todo (ID becomes derived md5, i.e. non-synthetic) + const updatedMd1 = "[ ] Delegate joke-telling to Ask mode\n[ ] Another unrelated task" + await tool.execute({ todos: updatedMd1 }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockResolvedValue(true), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + }) + + expect(task.todoList).toHaveLength(2) + expect(task.todoList[0]).toEqual( + expect.objectContaining({ + content: "Delegate joke-telling to Ask mode", + subtaskId: delegatedSubtaskId, + tokens: 1234, + cost: 0.12, + added: 10, + removed: 2, + }), + ) + // Ensure the carried-over todo now has a non-synthetic ID (this is the regression scenario). + expect(task.todoList[0].id).not.toMatch(/^synthetic-/) + + // Update 2: LLM rewrites the delegated todo content again, and list length changes so index-carryover won't apply. + // No subtaskId is provided in the markdown. + const updatedMd2 = + "[ ] Delegate joke-telling to Ask mode (updated)\n[ ] Another unrelated task\n[ ] Third task added" + await tool.execute({ todos: updatedMd2 }, task, { + pushToolResult: vi.fn(), + handleError: vi.fn(), + askApproval: vi.fn().mockResolvedValue(true), + removeClosingTag: vi.fn(), + toolProtocol: "xml", + }) + + expect(task.todoList).toHaveLength(3) + expect(task.todoList[0]).toEqual( + expect.objectContaining({ + content: "Delegate joke-telling to Ask mode (updated)", + subtaskId: delegatedSubtaskId, + tokens: 1234, + cost: 0.12, + added: 10, + removed: 2, + }), + ) + + // Ensure non-delegated todos do not accidentally inherit delegated metadata. + expect(task.todoList[1].subtaskId).toBeUndefined() + expect(task.todoList[2].subtaskId).toBeUndefined() + }) + it("should not cross-contaminate metadata when no subtaskId is present", async () => { const initialMd = "[ ] Task 1\n[ ] Task 2" const md = "[x] Task 1\n[ ] Task 2" // status changes for Task 1 -> derived id changes diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index dd74a259db5..15f321451ac 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1694,18 +1694,43 @@ export class ClineProvider }> { const { historyItem } = await this.getTaskWithId(taskId) - const aggregatedCosts = await aggregateTaskCostsRecursive(taskId, async (id: string) => { - const result = await this.getTaskWithId(id) - return result.historyItem - }) + const loadTaskHistoryItemForAggregation = async (id: string): Promise => { + // Root task must still error if missing (enforced by getTaskWithId(taskId) above). + if (id === taskId) { + return historyItem + } + + try { + const result = await this.getTaskWithId(id) + return result.historyItem + } catch (error) { + const errno = error as NodeJS.ErrnoException + const message = error instanceof Error ? error.message : String(error) + + // Child tasks may be missing on disk (e.g. pruned task folder). Treat as absent so rollups still render. + if (errno?.code === "ENOENT" || message === "Task not found") { + // getTaskWithId already performs cleanup for the "Task not found" path. + // For an ENOENT race (folder deleted between exists check and read), ensure state is also cleaned. + if (errno?.code === "ENOENT") { + await this.deleteTaskFromState(id) + } + + this.log( + `[ClineProvider#getTaskWithAggregatedCosts] Skipping missing child task ${id} while aggregating costs for ${taskId}: ${message}`, + ) + return undefined + } + + throw error + } + } + + const aggregatedCosts = await aggregateTaskCostsRecursive(taskId, loadTaskHistoryItemForAggregation) // Build subtask details if there are children let childDetails: SubtaskDetail[] | undefined if (aggregatedCosts.childBreakdown && Object.keys(aggregatedCosts.childBreakdown).length > 0) { - childDetails = await buildSubtaskDetails(aggregatedCosts.childBreakdown, async (id: string) => { - const result = await this.getTaskWithId(id) - return result.historyItem - }) + childDetails = await buildSubtaskDetails(aggregatedCosts.childBreakdown, loadTaskHistoryItemForAggregation) } return { @@ -2558,7 +2583,9 @@ export class ClineProvider public log(message: string) { this.outputChannel.appendLine(message) - console.log(message) + // IMPORTANT: never write to stdout/stderr from shared core code. + // The Roo CLI runs a TUI that is corrupted by any console output (e.g., mode selector dropdown). + // VS Code extension logs are available via the OutputChannel. } // getters @@ -3174,6 +3201,16 @@ export class ClineProvider const parentMessages = await readTaskMessages({ taskId: parentTaskId, globalStoragePath }) let todos = (getLatestTodo(parentMessages) as unknown as TodoItem[]) ?? [] + this.log( + `[TODO-DEBUG] delegateParentAndOpenChild loaded parent todos ${JSON.stringify({ + parentTaskId, + childTaskId: child.taskId, + parentMessagesCount: Array.isArray(parentMessages) ? parentMessages.length : undefined, + todosCount: Array.isArray(todos) ? todos.length : undefined, + todos, + })}`, + ) + // Ensure todos is a valid array if (!Array.isArray(todos)) { todos = [] @@ -3215,6 +3252,17 @@ export class ClineProvider } // Always persist the updated todo list + this.log( + `[TODO-DEBUG] delegateParentAndOpenChild persisting system_update_todos ${JSON.stringify({ + parentTaskId, + childTaskId: child.taskId, + chosenTodoId: chosen?.id, + chosenTodoStatus: chosen?.status, + chosenTodoSubtaskId: chosen?.subtaskId, + persistTodosCount: Array.isArray(todos) ? todos.length : undefined, + todos, + })}`, + ) await saveTaskMessages({ messages: [ ...parentMessages, @@ -3337,6 +3385,18 @@ export class ClineProvider // pick the same deterministic anchor todo and set its subtaskId = childTaskId before writing tokens/cost. try { let todos = (getLatestTodo(parentClineMessages) as unknown as TodoItem[]) ?? [] + + this.log( + `[TODO-DEBUG] reopenParentFromDelegation loaded parent todos ${JSON.stringify({ + parentTaskId, + childTaskId, + parentClineMessagesCount: Array.isArray(parentClineMessages) + ? parentClineMessages.length + : undefined, + todosCount: Array.isArray(todos) ? todos.length : undefined, + todos, + })}`, + ) if (!Array.isArray(todos)) { todos = [] } @@ -3344,6 +3404,7 @@ export class ClineProvider if (todos.length > 0) { // Primary lookup by subtaskId let linkedTodo = todos.find((t) => t?.subtaskId === childTaskId) + let usedAnchorFallbackLinking = false // Fallback: if subtaskId link wasn't found but parent history confirms this child belongs to it, // use the deterministic anchor selection to establish the link now. @@ -3364,15 +3425,35 @@ export class ClineProvider // Set the subtaskId on the fallback anchor if found if (linkedTodo) { linkedTodo.subtaskId = childTaskId + usedAnchorFallbackLinking = true } } if (linkedTodo) { + // Lightweight debug instrumentation (once per reopen) when we had to link via anchor + // instead of finding by subtaskId. + if (usedAnchorFallbackLinking) { + this.log( + `[Roo-Debug] [reopenParentFromDelegation] Provider write-back used anchor fallback (no subtaskId match). parent=${parentTaskId} child=${childTaskId}`, + ) + } + linkedTodo.tokens = (childHistoryItem?.tokensIn || 0) + (childHistoryItem?.tokensOut || 0) linkedTodo.cost = childHistoryItem?.totalCost || 0 linkedTodo.added = childHistoryItem?.linesAdded || 0 linkedTodo.removed = childHistoryItem?.linesRemoved || 0 + this.log( + `[TODO-DEBUG] reopenParentFromDelegation persisting system_update_todos ${JSON.stringify({ + parentTaskId, + childTaskId, + linkedTodoId: linkedTodo?.id, + usedAnchorFallbackLinking, + persistTodosCount: Array.isArray(todos) ? todos.length : undefined, + todos, + })}`, + ) + parentClineMessages.push({ ts: Date.now(), type: "say", diff --git a/src/core/webview/__tests__/ClineProvider.reopenParentFromDelegation.writeback.spec.ts b/src/core/webview/__tests__/ClineProvider.reopenParentFromDelegation.writeback.spec.ts new file mode 100644 index 00000000000..f7d00d435b9 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.reopenParentFromDelegation.writeback.spec.ts @@ -0,0 +1,237 @@ +// npx vitest run core/webview/__tests__/ClineProvider.reopenParentFromDelegation.writeback.spec.ts + +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { ClineMessage, HistoryItem, TodoItem } from "@roo-code/types" + +// Mock safe-stable-stringify to avoid runtime error +vi.mock("safe-stable-stringify", () => ({ + default: (obj: any) => JSON.stringify(obj), +})) + +// Mock TelemetryService (provider imports it) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + setProvider: vi.fn(), + captureTaskCreated: vi.fn(), + captureTaskCompleted: vi.fn(), + }, + }, +})) + +// vscode mock for ClineProvider imports +vi.mock("vscode", () => { + const window = { + createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + } + const workspace = { + getConfiguration: vi.fn(() => ({ + get: vi.fn((_key: string, defaultValue: any) => defaultValue), + update: vi.fn(), + })), + workspaceFolders: [], + } + const env = { + machineId: "test-machine", + uriScheme: "vscode", + appName: "VSCode", + language: "en", + sessionId: "sess", + } + const Uri = { file: (p: string) => ({ fsPath: p, toString: () => p }) } + const commands = { executeCommand: vi.fn() } + const ExtensionMode = { Development: 2 } + const version = "1.0.0-test" + return { window, workspace, env, Uri, commands, ExtensionMode, version } +}) + +// Mock persistence helpers used by provider reopen flow BEFORE importing provider +vi.mock("../../task-persistence/taskMessages", () => ({ + readTaskMessages: vi.fn().mockResolvedValue([]), +})) +vi.mock("../../task-persistence", () => ({ + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), +})) + +import { ClineProvider } from "../ClineProvider" +import { readTaskMessages } from "../../task-persistence/taskMessages" +import { readApiMessages, saveTaskMessages } from "../../task-persistence" + +/** + * Regression: after a bulk todo rewrite (status changes), delegation completion writeback + * must still update the correct parent todo row by `subtaskId` (not by position/anchor) + * and must not break subtask linkage for other todo rows. + */ +describe("ClineProvider.reopenParentFromDelegation() writeback", () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("updates the correct linked todo after bulk status rewrite while preserving subtaskId", async () => { + const parentTaskId = "parent-1" + const child1TaskId = "child-1" + const child2TaskId = "child-2" + + const initialTodos: TodoItem[] = [ + { id: "t1", content: "Prep", status: "pending" }, + { id: "t2", content: "Subtask 1", status: "pending", subtaskId: child1TaskId }, + { id: "t3", content: "Subtask 2", status: "pending", subtaskId: child2TaskId }, + ] + + // Simulate a bulk rewrite (e.g. update_todo_list) that marks earlier items completed + // while preserving `subtaskId` links, but with regenerated todo IDs. + const bulkRewrittenTodos: TodoItem[] = [ + { id: "t1b", content: "Prep", status: "completed" }, + { id: "t2b", content: "Subtask 1", status: "completed", subtaskId: child1TaskId }, + { id: "t3b", content: "Subtask 2", status: "pending", subtaskId: child2TaskId }, + ] + + const parentClineMessages: ClineMessage[] = [ + { + type: "say", + say: "system_update_todos", + ts: 1, + text: JSON.stringify({ tool: "updateTodoList", todos: initialTodos }), + } as unknown as ClineMessage, + { + type: "say", + say: "system_update_todos", + ts: 2, + text: JSON.stringify({ tool: "updateTodoList", todos: bulkRewrittenTodos }), + } as unknown as ClineMessage, + ] + + vi.mocked(readTaskMessages).mockResolvedValue(parentClineMessages as any) + vi.mocked(readApiMessages).mockResolvedValue([ + { + role: "assistant", + content: [{ type: "tool_use", name: "new_task", id: "tool-use-1" }], + ts: 0, + }, + ] as any) + + const historyIndex: Record = { + [parentTaskId]: { + id: parentTaskId, + ts: 1, + task: "Parent", + status: "delegated", + childIds: [child1TaskId, child2TaskId], + mode: "code", + workspace: "/tmp", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } as unknown as HistoryItem, + [child1TaskId]: { + id: child1TaskId, + ts: 2, + task: "Child 1", + status: "active", + mode: "code", + workspace: "/tmp", + tokensIn: 1, + tokensOut: 1, + totalCost: 0.01, + } as unknown as HistoryItem, + [child2TaskId]: { + id: child2TaskId, + ts: 3, + task: "Child 2", + status: "active", + mode: "code", + workspace: "/tmp", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.123, + linesAdded: 7, + linesRemoved: 2, + } as unknown as HistoryItem, + } + + const provider = { + contextProxy: { globalStorageUri: { fsPath: "/tmp" } }, + log: vi.fn(), + getTaskWithId: vi.fn(async (id: string) => { + const historyItem = historyIndex[id] + if (!historyItem) throw new Error(`Task not found: ${id}`) + return { + historyItem, + apiConversationHistory: [], + taskDirPath: "/tmp", + apiConversationHistoryFilePath: "/tmp/api.json", + uiMessagesFilePath: "/tmp/ui.json", + } + }), + updateTaskHistory: vi.fn(async (updated: any) => { + historyIndex[updated.id] = updated + return Object.values(historyIndex) + }), + emit: vi.fn(), + getCurrentTask: vi.fn(() => ({ taskId: child2TaskId })), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTaskWithHistoryItem: vi.fn().mockResolvedValue({ + taskId: parentTaskId, + overwriteClineMessages: vi.fn().mockResolvedValue(undefined), + overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined), + resumeAfterDelegation: vi.fn().mockResolvedValue(undefined), + }), + } as unknown as ClineProvider + + await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, { + parentTaskId, + childTaskId: child2TaskId, + completionResultSummary: "Child 2 complete", + }) + + // Capture the saved messages and extract the writeback todo payload. + expect(saveTaskMessages).toHaveBeenCalledWith(expect.objectContaining({ taskId: parentTaskId })) + const savedMessages = vi.mocked(saveTaskMessages).mock.calls.at(-1)![0].messages as ClineMessage[] + const lastTodoUpdate = [...savedMessages] + .reverse() + .find((m) => m.type === "say" && (m as any).say === "system_update_todos") as any + expect(lastTodoUpdate).toBeTruthy() + + const payload = JSON.parse(lastTodoUpdate.text) as { tool: string; todos: TodoItem[] } + expect(payload.tool).toBe("updateTodoList") + expect(payload.todos).toHaveLength(3) + + const updatedChild2Row = payload.todos.find((t) => t.subtaskId === child2TaskId) + const updatedChild1Row = payload.todos.find((t) => t.subtaskId === child1TaskId) + const updatedPrepRow = payload.todos.find((t) => t.content === "Prep") + + expect(updatedChild2Row).toEqual( + expect.objectContaining({ + id: "t3b", + content: "Subtask 2", + status: "pending", + subtaskId: child2TaskId, + tokens: 15, + cost: 0.123, + added: 7, + removed: 2, + }), + ) + + // Ensure other rows were not mutated by this child completion writeback. + expect(updatedChild1Row).toEqual( + expect.objectContaining({ + id: "t2b", + content: "Subtask 1", + status: "completed", + subtaskId: child1TaskId, + }), + ) + expect(updatedChild1Row?.tokens).toBeUndefined() + expect(updatedChild1Row?.cost).toBeUndefined() + expect(updatedChild1Row?.added).toBeUndefined() + expect(updatedChild1Row?.removed).toBeUndefined() + + expect(updatedPrepRow).toEqual(expect.objectContaining({ id: "t1b", content: "Prep", status: "completed" })) + expect((updatedPrepRow as any)?.subtaskId).toBeUndefined() + }) +}) diff --git a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts index 000460471a6..c11741af157 100644 --- a/src/core/webview/__tests__/aggregateTaskCosts.spec.ts +++ b/src/core/webview/__tests__/aggregateTaskCosts.spec.ts @@ -233,6 +233,74 @@ describe("aggregateTaskCostsRecursive", () => { expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Task nonexistent-child not found")) }) + /** + * Regression test: + * - Provider loader may hit ENOENT for missing child history files. + * - Provider hardening converts ENOENT -> undefined. + * - Aggregator must tolerate undefined children and still return partial results. + */ + it("should tolerate ENOENT from underlying history load and still include costs for existing children", async () => { + const mockHistory: Record = { + root: { + id: "root", + totalCost: 1.0, + linesAdded: 1, + linesRemoved: 0, + childIds: ["child-ok", "child-missing"], + } as unknown as HistoryItem, + "child-ok": { + id: "child-ok", + totalCost: 0.5, + linesAdded: 3, + linesRemoved: 2, + childIds: [], + } as unknown as HistoryItem, + } + + const loadHistory = vi.fn(async (id: string) => { + if (id === "child-missing") { + const err = new Error("ENOENT: no such file or directory") as Error & { code?: string } + err.code = "ENOENT" + throw err + } + + return mockHistory[id] + }) + + // Mimic provider behavior: swallow ENOENT and return undefined. + const getTaskHistory = vi.fn(async (id: string) => { + try { + return await loadHistory(id) + } catch (err) { + if ((err as { code?: string })?.code === "ENOENT") { + return undefined + } + throw err + } + }) + + const result = await aggregateTaskCostsRecursive("root", getTaskHistory) + + // Should still include existing child contributions. + expect(result.ownCost).toBe(1.0) + expect(result.childrenCost).toBe(0.5) + expect(result.totalCost).toBe(1.5) + expect(result.childrenAdded).toBe(3) + expect(result.childrenRemoved).toBe(2) + expect(result.totalAdded).toBe(4) + expect(result.totalRemoved).toBe(2) + + const childOk = result.childBreakdown?.["child-ok"] + expect(childOk).toBeDefined() + expect(childOk!.totalCost).toBe(0.5) + expect(childOk!.totalAdded).toBe(3) + expect(childOk!.totalRemoved).toBe(2) + + // Missing child should not crash aggregation. + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Task child-missing not found")) + expect(loadHistory).toHaveBeenCalledWith("child-missing") + }) + it("should return zero costs for completely missing task", async () => { const mockHistory: Record = {} diff --git a/src/shared/todo.ts b/src/shared/todo.ts index 81e4559a93e..3f2ca0c3fab 100644 --- a/src/shared/todo.ts +++ b/src/shared/todo.ts @@ -1,26 +1,51 @@ -import { ClineMessage } from "@roo-code/types" +import { ClineMessage, TodoItem } from "@roo-code/types" -export function getLatestTodo(clineMessages: ClineMessage[]) { - const todos = clineMessages - .filter( - (msg) => - (msg.type === "ask" && msg.ask === "tool") || - (msg.type === "say" && (msg.say === "user_edit_todos" || msg.say === "system_update_todos")), - ) - .map((msg) => { - try { - return JSON.parse(msg.text ?? "{}") - } catch { - return null - } - }) - .filter((item) => item && item.tool === "updateTodoList" && Array.isArray(item.todos)) - .map((item) => item.todos) - .pop() - - if (todos) { - return todos - } else { +export function getLatestTodo(clineMessages: ClineMessage[]): TodoItem[] { + if (!Array.isArray(clineMessages) || clineMessages.length === 0) { + console.log("[TODO-DEBUG]", "getLatestTodo called with empty clineMessages") return [] } + + const candidateMessages = clineMessages.filter( + (msg) => + (msg.type === "ask" && msg.ask === "tool") || + (msg.type === "say" && (msg.say === "user_edit_todos" || msg.say === "system_update_todos")), + ) + + let lastTodos: TodoItem[] | undefined + let matchedUpdateTodoListCount = 0 + let parseFailureCount = 0 + + for (const msg of candidateMessages) { + let parsed: any + try { + parsed = JSON.parse(msg.text ?? "{}") + } catch { + parseFailureCount++ + continue + } + + if (parsed && parsed.tool === "updateTodoList" && Array.isArray(parsed.todos)) { + matchedUpdateTodoListCount++ + lastTodos = parsed.todos as TodoItem[] + } + } + + console.log("[TODO-DEBUG]", "getLatestTodo scanned messages", { + totalMessages: clineMessages.length, + candidateMessages: candidateMessages.length, + matchedUpdateTodoListCount, + parseFailureCount, + returnedTodosCount: Array.isArray(lastTodos) ? lastTodos.length : 0, + // Only log lightweight metadata for the last few candidates (avoid dumping full message content) + lastCandidates: candidateMessages.slice(-5).map((m) => ({ + ts: m.ts, + type: m.type, + ask: (m as any).ask, + say: (m as any).say, + textLength: typeof m.text === "string" ? m.text.length : 0, + })), + }) + + return Array.isArray(lastTodos) ? lastTodos : [] } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 89d817e4aa9..3a882231907 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -393,10 +393,22 @@ export const ChatRowContent = ({ wordBreak: "break-word", } - const tool = useMemo( - () => (message.ask === "tool" ? safeJsonParse(message.text) : null), - [message.ask, message.text], - ) + const tool = useMemo(() => { + if (message.ask !== "tool") return null + const parsed = safeJsonParse(message.text) + + // TODO debugging: verify tool JSON is actually being parsed in the webview. + if ((parsed as any)?.tool === "updateTodoList") { + console.log("[TODO-DEBUG]", "ChatRow parsed tool JSON", { + messageTs: message.ts, + toolName: (parsed as any)?.tool, + newTodosCount: Array.isArray((parsed as any)?.todos) ? (parsed as any).todos.length : undefined, + parsed, + }) + } + + return parsed + }, [message.ask, message.text, message.ts]) // Unified diff content (provided by backend when relevant) const unifiedDiff = useMemo(() => { @@ -569,6 +581,15 @@ export const ChatRowContent = ({ // Get previous todos from the latest todos in the task context const previousTodos = getPreviousTodos(clineMessages, message.ts) + console.log("[TODO-DEBUG]", "ChatRow rendering TodoChangeDisplay", { + messageTs: message.ts, + previousTodosCount: Array.isArray(previousTodos) ? previousTodos.length : undefined, + newTodosCount: Array.isArray(todos) ? todos.length : undefined, + previousTodos, + newTodos: todos, + parsedTool: tool, + }) + return } case "newFileCreated": diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index d222c8c5046..17ec30d70f8 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -125,6 +125,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + // TODO debugging: ensure todo extraction runs and surfaces state that should drive UI. + console.log("[TODO-DEBUG]", "ChatView latestTodos computed", { + messagesCount: Array.isArray(messages) ? messages.length : undefined, + currentTaskTodosCount: Array.isArray(currentTaskTodos) ? currentTaskTodos.length : undefined, + latestTodosCount: Array.isArray(latestTodos) ? latestTodos.length : undefined, + latestTodos, + }) + }, [messages, currentTaskTodos, latestTodos]) + const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages]) // Has to be after api_req_finished are all reduced into api_req_started messages. diff --git a/webview-ui/src/components/chat/TodoChangeDisplay.tsx b/webview-ui/src/components/chat/TodoChangeDisplay.tsx index 3904cd9c9ff..d2043f435b6 100644 --- a/webview-ui/src/components/chat/TodoChangeDisplay.tsx +++ b/webview-ui/src/components/chat/TodoChangeDisplay.tsx @@ -26,6 +26,17 @@ function getTodoIcon(status: TodoStatus | null) { } export function TodoChangeDisplay({ previousTodos, newTodos }: TodoChangeDisplayProps) { + console.log("[TODO-DEBUG]", "TodoChangeDisplay compare todos", { + previousTodosCount: Array.isArray(previousTodos) ? previousTodos.length : 0, + newTodosCount: Array.isArray(newTodos) ? newTodos.length : 0, + previousTodos: Array.isArray(previousTodos) + ? previousTodos.map((t) => ({ id: t.id, content: t.content, status: t.status })) + : [], + newTodos: Array.isArray(newTodos) + ? newTodos.map((t) => ({ id: t.id, content: t.content, status: t.status })) + : [], + }) + const isInitialState = previousTodos.length === 0 // Determine which todos to display @@ -34,19 +45,45 @@ export function TodoChangeDisplay({ previousTodos, newTodos }: TodoChangeDisplay if (isInitialState && newTodos.length > 0) { // For initial state, show all todos in their original order todosToDisplay = newTodos + console.log("[TODO-DEBUG]", "TodoChangeDisplay selection: initial state -> show all newTodos", { + todosToDisplayCount: todosToDisplay.length, + }) } else { // For updates, only show changes (completed or started) in their original order todosToDisplay = newTodos.filter((newTodo) => { if (newTodo.status === "completed") { const previousTodo = previousTodos.find((p) => p.id === newTodo.id || p.content === newTodo.content) - return !previousTodo || previousTodo.status !== "completed" + const include = !previousTodo || previousTodo.status !== "completed" + console.log("[TODO-DEBUG]", "TodoChangeDisplay selection: completed todo", { + newTodo: { id: newTodo.id, content: newTodo.content, status: newTodo.status }, + matchedPreviousTodo: previousTodo + ? { id: previousTodo.id, content: previousTodo.content, status: previousTodo.status } + : undefined, + include, + }) + return include } if (newTodo.status === "in_progress") { const previousTodo = previousTodos.find((p) => p.id === newTodo.id || p.content === newTodo.content) - return !previousTodo || previousTodo.status !== "in_progress" + const include = !previousTodo || previousTodo.status !== "in_progress" + console.log("[TODO-DEBUG]", "TodoChangeDisplay selection: in_progress todo", { + newTodo: { id: newTodo.id, content: newTodo.content, status: newTodo.status }, + matchedPreviousTodo: previousTodo + ? { id: previousTodo.id, content: previousTodo.content, status: previousTodo.status } + : undefined, + include, + }) + return include } + console.log("[TODO-DEBUG]", "TodoChangeDisplay selection: ignored todo (not completed/in_progress)", { + newTodo: { id: newTodo.id, content: newTodo.content, status: newTodo.status }, + }) return false }) + console.log("[TODO-DEBUG]", "TodoChangeDisplay selection result", { + todosToDisplayCount: todosToDisplay.length, + todosToDisplay: todosToDisplay.map((t) => ({ id: t.id, content: t.content, status: t.status })), + }) } // If no todos to display, don't render anything diff --git a/webview-ui/src/components/chat/TodoListDisplay.tsx b/webview-ui/src/components/chat/TodoListDisplay.tsx index c3da1c717ba..902206d66f4 100644 --- a/webview-ui/src/components/chat/TodoListDisplay.tsx +++ b/webview-ui/src/components/chat/TodoListDisplay.tsx @@ -41,6 +41,16 @@ export interface TodoListDisplayProps { } export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoListDisplayProps) { + useEffect(() => { + console.log("[TODO-DEBUG]", "TodoListDisplay props received", { + todosCount: Array.isArray(todos) ? todos.length : 0, + todoSubtaskIds: Array.isArray(todos) ? todos.map((t) => t?.subtaskId).filter(Boolean) : [], + subtaskDetailsCount: Array.isArray(subtaskDetails) ? subtaskDetails.length : 0, + subtaskDetailsIds: Array.isArray(subtaskDetails) ? subtaskDetails.map((s) => s?.id).filter(Boolean) : [], + hasOnSubtaskClick: Boolean(onSubtaskClick), + }) + }, [todos, subtaskDetails, onSubtaskClick]) + const [isCollapsed, setIsCollapsed] = useState(true) const ulRef = useRef(null) const itemRefs = useRef<(HTMLLIElement | null)[]>([]) @@ -108,10 +118,25 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL const todoStatus = (todo.status as TodoStatus) ?? "pending" const icon = getTodoIcon(todoStatus) const isClickable = Boolean(todo.subtaskId && onSubtaskClick) + console.log("[TODO-DEBUG]", "TodoListDisplay subtask match start", { + todoIndex: idx, + todoId: todo.id, + todoContent: todo.content, + todoSubtaskId: todo.subtaskId, + availableSubtaskDetailIds: Array.isArray(subtaskDetails) + ? subtaskDetails.map((s) => s?.id).filter(Boolean) + : [], + }) const subtaskById = subtaskDetails && todo.subtaskId ? subtaskDetails.find((s) => s.id === todo.subtaskId) : undefined + console.log("[TODO-DEBUG]", "TodoListDisplay subtask match result", { + todoIndex: idx, + todoSubtaskId: todo.subtaskId, + matched: Boolean(subtaskById), + matchedSubtaskId: subtaskById?.id, + }) const displayTokens = todo.tokens ?? subtaskById?.tokens const displayCost = todo.cost ?? subtaskById?.cost const shouldShowCost = typeof displayTokens === "number" && typeof displayCost === "number" @@ -139,6 +164,33 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL const shouldShowLineChanges = hasValidSubtaskLink && (canRenderAdded || canRenderRemoved) + console.log("[TODO-DEBUG]", "TodoListDisplay metadata computed", { + todoIndex: idx, + todoSubtaskId: todo.subtaskId, + fromTodo: { + tokens: todo.tokens, + cost: todo.cost, + added: todo.added, + removed: todo.removed, + }, + fromSubtaskDetails: subtaskById + ? { + tokens: subtaskById.tokens, + cost: subtaskById.cost, + added: subtaskById.added, + removed: subtaskById.removed, + } + : undefined, + display: { + displayTokens, + displayCost, + displayAdded, + displayRemoved, + }, + shouldShowCost, + shouldShowLineChanges, + }) + const isAddedPositive = canRenderAdded && (displayAdded as number) > 0 const isRemovedPositive = canRenderRemoved && (displayRemoved as number) > 0 const isAddedZero = canRenderAdded && displayAdded === 0 From 4be568874ac3e93558c0b342bdb70aeebab7ba8f Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Tue, 20 Jan 2026 17:44:03 -0500 Subject: [PATCH 15/17] chore(i18n): set costs.own label to Task --- webview-ui/src/i18n/locales/ca/common.json | 2 +- webview-ui/src/i18n/locales/de/common.json | 2 +- webview-ui/src/i18n/locales/en/common.json | 2 +- webview-ui/src/i18n/locales/es/common.json | 2 +- webview-ui/src/i18n/locales/fr/common.json | 2 +- webview-ui/src/i18n/locales/hi/common.json | 2 +- webview-ui/src/i18n/locales/id/common.json | 2 +- webview-ui/src/i18n/locales/it/common.json | 2 +- webview-ui/src/i18n/locales/ja/common.json | 2 +- webview-ui/src/i18n/locales/ko/common.json | 2 +- webview-ui/src/i18n/locales/nl/common.json | 2 +- webview-ui/src/i18n/locales/pl/common.json | 2 +- webview-ui/src/i18n/locales/pt-BR/common.json | 2 +- webview-ui/src/i18n/locales/ru/common.json | 2 +- webview-ui/src/i18n/locales/tr/common.json | 2 +- webview-ui/src/i18n/locales/vi/common.json | 2 +- webview-ui/src/i18n/locales/zh-CN/common.json | 2 +- webview-ui/src/i18n/locales/zh-TW/common.json | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/common.json b/webview-ui/src/i18n/locales/ca/common.json index 222cc6ffe7d..c51cef26072 100644 --- a/webview-ui/src/i18n/locales/ca/common.json +++ b/webview-ui/src/i18n/locales/ca/common.json @@ -108,7 +108,7 @@ "awaiting_child": "Esperant la tasca filla {{childId}}" }, "costs": { - "own": "Principal", + "own": "Task", "subtasks": "Subtasques" }, "stats": { diff --git a/webview-ui/src/i18n/locales/de/common.json b/webview-ui/src/i18n/locales/de/common.json index e8f01f5683b..83c2e746088 100644 --- a/webview-ui/src/i18n/locales/de/common.json +++ b/webview-ui/src/i18n/locales/de/common.json @@ -108,7 +108,7 @@ "awaiting_child": "Warte auf Unteraufgabe {{childId}}" }, "costs": { - "own": "Haupt", + "own": "Task", "subtasks": "Unteraufgaben" }, "stats": { diff --git a/webview-ui/src/i18n/locales/en/common.json b/webview-ui/src/i18n/locales/en/common.json index 32d9f6f41fb..f830b38c412 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -108,7 +108,7 @@ "awaiting_child": "Awaiting child task {{childId}}" }, "costs": { - "own": "Main", + "own": "Task", "subtasks": "Subtasks" }, "stats": { diff --git a/webview-ui/src/i18n/locales/es/common.json b/webview-ui/src/i18n/locales/es/common.json index 465243d5bcb..e64507350e6 100644 --- a/webview-ui/src/i18n/locales/es/common.json +++ b/webview-ui/src/i18n/locales/es/common.json @@ -108,7 +108,7 @@ "awaiting_child": "Esperando tarea secundaria {{childId}}" }, "costs": { - "own": "Principal", + "own": "Task", "subtasks": "Subtareas" }, "stats": { diff --git a/webview-ui/src/i18n/locales/fr/common.json b/webview-ui/src/i18n/locales/fr/common.json index 979c204285e..bb6e658bd74 100644 --- a/webview-ui/src/i18n/locales/fr/common.json +++ b/webview-ui/src/i18n/locales/fr/common.json @@ -108,7 +108,7 @@ "awaiting_child": "En attente de la tâche enfant {{childId}}" }, "costs": { - "own": "Principal", + "own": "Task", "subtasks": "Sous-tâches" }, "stats": { diff --git a/webview-ui/src/i18n/locales/hi/common.json b/webview-ui/src/i18n/locales/hi/common.json index 3f2f1a5728f..e3104e9d59c 100644 --- a/webview-ui/src/i18n/locales/hi/common.json +++ b/webview-ui/src/i18n/locales/hi/common.json @@ -108,7 +108,7 @@ "awaiting_child": "चाइल्ड कार्य {{childId}} की प्रतीक्षा में" }, "costs": { - "own": "मुख्य", + "own": "Task", "subtasks": "उपकार्य" }, "stats": { diff --git a/webview-ui/src/i18n/locales/id/common.json b/webview-ui/src/i18n/locales/id/common.json index 2c2565ff6c6..6557610847d 100644 --- a/webview-ui/src/i18n/locales/id/common.json +++ b/webview-ui/src/i18n/locales/id/common.json @@ -108,7 +108,7 @@ "awaiting_child": "Menunggu tugas anak {{childId}}" }, "costs": { - "own": "Utama", + "own": "Task", "subtasks": "Subtugas" }, "stats": { diff --git a/webview-ui/src/i18n/locales/it/common.json b/webview-ui/src/i18n/locales/it/common.json index 943c350e565..efb134f75eb 100644 --- a/webview-ui/src/i18n/locales/it/common.json +++ b/webview-ui/src/i18n/locales/it/common.json @@ -108,7 +108,7 @@ "awaiting_child": "In attesa dell'attività figlia {{childId}}" }, "costs": { - "own": "Principale", + "own": "Task", "subtasks": "Sottoattività" }, "stats": { diff --git a/webview-ui/src/i18n/locales/ja/common.json b/webview-ui/src/i18n/locales/ja/common.json index a8909bbdeb6..d7502d3ddc7 100644 --- a/webview-ui/src/i18n/locales/ja/common.json +++ b/webview-ui/src/i18n/locales/ja/common.json @@ -108,7 +108,7 @@ "awaiting_child": "子タスク{{childId}}を待機中" }, "costs": { - "own": "メイン", + "own": "Task", "subtasks": "サブタスク" }, "stats": { diff --git a/webview-ui/src/i18n/locales/ko/common.json b/webview-ui/src/i18n/locales/ko/common.json index 472891d0d50..c0be2ff9159 100644 --- a/webview-ui/src/i18n/locales/ko/common.json +++ b/webview-ui/src/i18n/locales/ko/common.json @@ -108,7 +108,7 @@ "awaiting_child": "하위 작업 {{childId}} 대기 중" }, "costs": { - "own": "메인", + "own": "Task", "subtasks": "하위작업" }, "stats": { diff --git a/webview-ui/src/i18n/locales/nl/common.json b/webview-ui/src/i18n/locales/nl/common.json index e62f045481e..a0c74f6da59 100644 --- a/webview-ui/src/i18n/locales/nl/common.json +++ b/webview-ui/src/i18n/locales/nl/common.json @@ -108,7 +108,7 @@ "awaiting_child": "Wachten op kindtaak {{childId}}" }, "costs": { - "own": "Hoofd", + "own": "Task", "subtasks": "Subtaken" }, "stats": { diff --git a/webview-ui/src/i18n/locales/pl/common.json b/webview-ui/src/i18n/locales/pl/common.json index 9b5ecbfe51c..a7410b91cbf 100644 --- a/webview-ui/src/i18n/locales/pl/common.json +++ b/webview-ui/src/i18n/locales/pl/common.json @@ -108,7 +108,7 @@ "awaiting_child": "Oczekiwanie na zadanie podrzędne {{childId}}" }, "costs": { - "own": "Główne", + "own": "Task", "subtasks": "Podzadania" }, "stats": { diff --git a/webview-ui/src/i18n/locales/pt-BR/common.json b/webview-ui/src/i18n/locales/pt-BR/common.json index 4f1ebf94d9c..d186e331227 100644 --- a/webview-ui/src/i18n/locales/pt-BR/common.json +++ b/webview-ui/src/i18n/locales/pt-BR/common.json @@ -108,7 +108,7 @@ "awaiting_child": "Aguardando tarefa filha {{childId}}" }, "costs": { - "own": "Principal", + "own": "Task", "subtasks": "Subtarefas" }, "stats": { diff --git a/webview-ui/src/i18n/locales/ru/common.json b/webview-ui/src/i18n/locales/ru/common.json index 47bab140c89..3d2306c589c 100644 --- a/webview-ui/src/i18n/locales/ru/common.json +++ b/webview-ui/src/i18n/locales/ru/common.json @@ -108,7 +108,7 @@ "awaiting_child": "Ожидание дочерней задачи {{childId}}" }, "costs": { - "own": "Основная", + "own": "Task", "subtasks": "Подзадачи" }, "stats": { diff --git a/webview-ui/src/i18n/locales/tr/common.json b/webview-ui/src/i18n/locales/tr/common.json index 81dca46196b..deddd91f14f 100644 --- a/webview-ui/src/i18n/locales/tr/common.json +++ b/webview-ui/src/i18n/locales/tr/common.json @@ -108,7 +108,7 @@ "awaiting_child": "{{childId}} alt görevi bekleniyor" }, "costs": { - "own": "Ana", + "own": "Task", "subtasks": "Alt görevler" }, "stats": { diff --git a/webview-ui/src/i18n/locales/vi/common.json b/webview-ui/src/i18n/locales/vi/common.json index d3b37fb35cc..92a8f2facf2 100644 --- a/webview-ui/src/i18n/locales/vi/common.json +++ b/webview-ui/src/i18n/locales/vi/common.json @@ -108,7 +108,7 @@ "awaiting_child": "Đang chờ nhiệm vụ con {{childId}}" }, "costs": { - "own": "Chính", + "own": "Task", "subtasks": "Nhiệm vụ con" }, "stats": { diff --git a/webview-ui/src/i18n/locales/zh-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index cf7847a8d11..653df60972e 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -108,7 +108,7 @@ "awaiting_child": "等待子任务 {{childId}}" }, "costs": { - "own": "主要", + "own": "Task", "subtasks": "子任务" }, "stats": { diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index 435bda21cb9..2b20cb4eed2 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -108,7 +108,7 @@ "awaiting_child": "等待子工作 {{childId}}" }, "costs": { - "own": "主要", + "own": "Task", "subtasks": "子工作" }, "stats": { From 249737dbf03a49a272b6d894cdee6e4d35f73b8c Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Tue, 20 Jan 2026 19:19:29 -0500 Subject: [PATCH 16/17] chore: remove debug logging from PR #10765 - Remove 63 [TODO-DEBUG] console.log statements across 8 files - Remove file-based debug log mechanism (ROO_CLI_DEBUG_LOG_PATH) - Replace console.error in Task.ts with TelemetryService Files modified: - src/core/tools/UpdateTodoListTool.ts - src/core/assistant-message/presentAssistantMessage.ts - src/core/task/Task.ts - src/shared/todo.ts - webview-ui/src/components/chat/ChatRow.tsx - webview-ui/src/components/chat/ChatView.tsx - webview-ui/src/components/chat/TodoChangeDisplay.tsx - webview-ui/src/components/chat/TodoListDisplay.tsx --- .../presentAssistantMessage.ts | 5 - src/core/task/Task.ts | 9 +- src/core/tools/UpdateTodoListTool.ts | 422 +----------------- src/shared/todo.ts | 17 - webview-ui/src/components/chat/ChatRow.tsx | 21 +- webview-ui/src/components/chat/ChatView.tsx | 8 +- .../src/components/chat/TodoChangeDisplay.tsx | 35 -- .../src/components/chat/TodoListDisplay.tsx | 52 --- 8 files changed, 15 insertions(+), 554 deletions(-) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7bc29ac95ae..693327a022e 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -881,11 +881,6 @@ export async function presentAssistantMessage(cline: Task) { }) break case "update_todo_list": - console.log("[TODO-DEBUG]", "presentAssistantMessage dispatching update_todo_list", { - toolUseId: (block as any).id, - partial: block.partial, - params: (block as any).params, - }) await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { askApproval, handleError, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 0c1d71b2a4d..636c909f052 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3286,7 +3286,14 @@ export class Task extends EventEmitter implements TaskLike { // IMPORTANT: keep a reference so completion/delegation can await final persisted usage/cost. this.pendingUsageCollectionPromise = drainStreamInBackgroundToFindAllUsage(lastApiReqIndex) .catch((error) => { - console.error("Background usage collection failed:", error) + const err = error instanceof Error ? error : new Error("Background usage collection failed") + TelemetryService.instance.captureException(err, { + location: "Task.pendingUsageCollectionPromise", + taskId: this.taskId, + instanceId: this.instanceId, + lastApiReqIndex, + originalError: error instanceof Error ? undefined : serializeError(error), + }) }) .finally(() => { if (this.pendingUsageCollectionPromise) { diff --git a/src/core/tools/UpdateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts index 1f5467034ae..c87cc28b0b7 100644 --- a/src/core/tools/UpdateTodoListTool.ts +++ b/src/core/tools/UpdateTodoListTool.ts @@ -3,9 +3,6 @@ import { formatResponse } from "../prompts/responses" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import crypto from "crypto" -import fs from "fs" -import os from "os" -import path from "path" import { TodoItem, TodoStatus, todoStatusSchema } from "@roo-code/types" import { getLatestTodo } from "../../shared/todo" @@ -25,54 +22,12 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { } async execute(params: UpdateTodoListParams, task: Task, callbacks: ToolCallbacks): Promise { - console.log("[TODO-DEBUG] execute() STEP 0: ENTERED", { - tool: "update_todo_list", - paramsTodosType: typeof params?.todos, - paramsTodosLength: typeof params?.todos === "string" ? params.todos.length : undefined, - }) - const { pushToolResult, handleError, askApproval, toolProtocol } = callbacks + const { pushToolResult, handleError, askApproval } = callbacks try { - const summarizeTodoForDebug = (t: TodoItem | undefined) => { - if (!t) return undefined - return { - id: typeof t.id === "string" ? t.id : undefined, - status: typeof t.status === "string" ? t.status : undefined, - content: typeof t.content === "string" ? t.content.slice(0, 120) : undefined, - subtaskId: typeof t.subtaskId === "string" ? t.subtaskId : undefined, - tokens: typeof t.tokens === "number" ? t.tokens : undefined, - cost: typeof t.cost === "number" ? t.cost : undefined, - added: typeof t.added === "number" ? t.added : undefined, - removed: typeof t.removed === "number" ? t.removed : undefined, - } - } - - const shouldTodoDebugLog = - process.env.ROO_DEBUG_TODO_METADATA === "1" || - process.env.ROO_DEBUG_TODO_METADATA === "true" || - process.env.ROO_CLI_DEBUG_LOG === "1" - console.log("[TODO-DEBUG] execute() STEP 1: computed debug flags", { - shouldTodoDebugLog, - ROO_DEBUG_TODO_METADATA: process.env.ROO_DEBUG_TODO_METADATA, - ROO_CLI_DEBUG_LOG: process.env.ROO_CLI_DEBUG_LOG, - toolProtocol, - }) - const previousFromMemory = getTodoListForTask(task) - console.log("[TODO-DEBUG] execute() STEP 2: previous todos from memory", { - previousFromMemoryCount: Array.isArray(previousFromMemory) ? previousFromMemory.length : 0, - previousFromMemoryPreview: Array.isArray(previousFromMemory) - ? previousFromMemory.slice(0, 10).map((t) => summarizeTodoForDebug(t)) - : undefined, - }) const previousFromHistory = getLatestTodo(task.clineMessages) as unknown as TodoItem[] | undefined - console.log("[TODO-DEBUG] execute() STEP 3: previous todos from history", { - previousFromHistoryCount: Array.isArray(previousFromHistory) ? previousFromHistory.length : 0, - previousFromHistoryPreview: Array.isArray(previousFromHistory) - ? previousFromHistory.slice(0, 10).map((t) => summarizeTodoForDebug(t)) - : undefined, - }) const historyHasMetadata = Array.isArray(previousFromHistory) && @@ -84,21 +39,6 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { t?.added !== undefined || t?.removed !== undefined, ) - console.log("[TODO-DEBUG] execute() STEP 4: analyzed history metadata", { - historyHasMetadata, - historyHasSubtaskId: Array.isArray(previousFromHistory) - ? previousFromHistory.some((t) => typeof t?.subtaskId === "string") - : false, - historyHasTokens: Array.isArray(previousFromHistory) - ? previousFromHistory.some((t) => typeof t?.tokens === "number") - : false, - historyHasCost: Array.isArray(previousFromHistory) - ? previousFromHistory.some((t) => typeof t?.cost === "number") - : false, - historyHasLineChanges: Array.isArray(previousFromHistory) - ? previousFromHistory.some((t) => typeof t?.added === "number" || typeof t?.removed === "number") - : false, - }) const previousTodos: TodoItem[] = (previousFromMemory?.length ?? 0) === 0 @@ -108,55 +48,14 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { : historyHasMetadata ? (previousFromHistory ?? []) : (previousFromMemory ?? []) - console.log("[TODO-DEBUG] execute() STEP 5: selected previousTodos", { - selectedPreviousTodosCount: Array.isArray(previousTodos) ? previousTodos.length : 0, - selectedPreviousTodosWithSubtaskIdCount: Array.isArray(previousTodos) - ? previousTodos.filter((t) => typeof t?.subtaskId === "string").length - : 0, - selectedPreviousTodosPreview: Array.isArray(previousTodos) - ? previousTodos.slice(0, 10).map((t) => summarizeTodoForDebug(t)) - : undefined, - }) const todosRaw = params.todos - if (shouldTodoDebugLog) { - console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() received params.todos", { - tool: "update_todo_list", - todosRawType: typeof todosRaw, - todosRawLength: typeof todosRaw === "string" ? todosRaw.length : undefined, - todosRawPreview: typeof todosRaw === "string" ? todosRaw.slice(0, 500) : undefined, - }) - console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() previousTodos summary", { - previousFromMemoryCount: Array.isArray(previousFromMemory) ? previousFromMemory.length : 0, - previousFromHistoryCount: Array.isArray(previousFromHistory) ? previousFromHistory.length : 0, - historyHasMetadata, - selectedPreviousTodosCount: Array.isArray(previousTodos) ? previousTodos.length : 0, - previousTodosWithSubtaskIdCount: Array.isArray(previousTodos) - ? previousTodos.filter((t) => typeof t?.subtaskId === "string").length - : 0, - }) - } let todos: TodoItem[] const jsonParseResult = tryParseTodoItemsJson(todosRaw) if (jsonParseResult.parsed) { todos = jsonParseResult.parsed - console.log("[TODO-DEBUG] execute() STEP 6: parsed todos via JSON", { - parsedCount: todos.length, - parsedPreview: todos.slice(0, 10).map((t) => summarizeTodoForDebug(t)), - }) - if (shouldTodoDebugLog) { - console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() parsed todos from JSON", { - parsedCount: todos.length, - hasAnySubtaskId: todos.some((t) => typeof t?.subtaskId === "string"), - subtaskIds: todos.map((t) => t?.subtaskId).filter(Boolean), - }) - } } else if (jsonParseResult.error) { - console.log("[TODO-DEBUG] execute() STEP 6: JSON parse/validate error", { - error: jsonParseResult.error, - todosRawPreview: typeof todosRaw === "string" ? todosRaw.slice(0, 500) : undefined, - }) task.consecutiveMistakeCount++ task.recordToolError("update_todo_list") task.didToolFailInCurrentTurn = true @@ -165,52 +64,13 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { } else { // Backward compatible: fall back to markdown checklist parsing when JSON parsing is not applicable. todos = parseMarkdownChecklist(todosRaw || "") - console.log("[TODO-DEBUG] execute() STEP 6: parsed todos via markdown checklist", { - parsedCount: todos.length, - parsedPreview: todos.slice(0, 10).map((t) => summarizeTodoForDebug(t)), - }) - if (shouldTodoDebugLog) { - console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() parsed todos from markdown checklist", { - parsedCount: todos.length, - hasAnySubtaskId: todos.some((t) => typeof t?.subtaskId === "string"), - }) - } } // Preserve metadata (subtaskId/tokens/cost) for todos whose content matches an existing todo. // Matching is by exact content string; duplicates are matched in order. - // NOTE: Instrumentation is enabled here (once per tool execute) to detect metadata-preservation failures. - console.log("[TODO-DEBUG] execute() STEP 7: about to call preserveTodoMetadata", { - nextTodosCount: Array.isArray(todos) ? todos.length : 0, - previousTodosCount: Array.isArray(previousTodos) ? previousTodos.length : 0, - enableInstrumentation: true, - }) - const todosWithPreservedMetadata = preserveTodoMetadata(todos, previousTodos, { - enableInstrumentation: true, - }) - console.log("[TODO-DEBUG] execute() STEP 8: returned from preserveTodoMetadata", { - resultCount: todosWithPreservedMetadata.length, - resultPreview: todosWithPreservedMetadata.slice(0, 10).map((t) => summarizeTodoForDebug(t)), - }) - if (shouldTodoDebugLog) { - console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() after preserveTodoMetadata()", { - nextTodosCount: todosWithPreservedMetadata.length, - todosWithSubtaskIdCount: todosWithPreservedMetadata.filter((t) => typeof t?.subtaskId === "string") - .length, - subtaskIds: todosWithPreservedMetadata.map((t) => t?.subtaskId).filter(Boolean), - hasAnyTokens: todosWithPreservedMetadata.some((t) => typeof t?.tokens === "number"), - hasAnyCost: todosWithPreservedMetadata.some((t) => typeof t?.cost === "number"), - hasAnyLineChanges: todosWithPreservedMetadata.some( - (t) => typeof t?.added === "number" || typeof t?.removed === "number", - ), - }) - } + const todosWithPreservedMetadata = preserveTodoMetadata(todos, previousTodos) const { valid, error } = validateTodos(todosWithPreservedMetadata) - console.log("[TODO-DEBUG] execute() STEP 9: validateTodos", { - valid, - error, - }) if (!valid) { task.consecutiveMistakeCount++ task.recordToolError("update_todo_list") @@ -229,17 +89,6 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { added: t.added, removed: t.removed, })) - console.log("[TODO-DEBUG] execute() STEP 10: normalizedTodos (pre-approval)", { - normalizedCount: normalizedTodos.length, - normalizedPreview: normalizedTodos.slice(0, 10).map((t) => summarizeTodoForDebug(t)), - }) - if (shouldTodoDebugLog) { - console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() normalizedTodos (pre-approval)", { - normalizedCount: normalizedTodos.length, - todosWithSubtaskIdCount: normalizedTodos.filter((t) => typeof t?.subtaskId === "string").length, - subtaskIds: normalizedTodos.map((t) => t?.subtaskId).filter(Boolean), - }) - } const approvalMsg = JSON.stringify({ tool: "updateTodoList", @@ -248,57 +97,20 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { // TodoItem is a flat object shape; a shallow copy is sufficient here. approvedTodoList = normalizedTodos.map((t) => ({ ...t })) - console.log("[TODO-DEBUG] execute() STEP 11: asking approval", { - approvalPayloadLength: approvalMsg.length, - normalizedCount: normalizedTodos.length, - }) const didApprove = await askApproval("tool", approvalMsg) - console.log("[TODO-DEBUG] execute() STEP 12: approval result", { - didApprove, - }) if (!didApprove) { - console.log("[TODO-DEBUG] execute() STEP 13: user declined; returning", {}) pushToolResult("User declined to update the todoList.") return } const isTodoListChanged = approvedTodoList !== undefined && JSON.stringify(normalizedTodos) !== JSON.stringify(approvedTodoList) - console.log("[TODO-DEBUG] execute() STEP 14: checked approval UI edits", { - isTodoListChanged, - }) if (isTodoListChanged) { normalizedTodos = approvedTodoList ?? [] - console.log("[TODO-DEBUG] execute() STEP 15: using user-edited todos", { - editedCount: normalizedTodos.length, - editedPreview: normalizedTodos.slice(0, 10).map((t) => summarizeTodoForDebug(t)), - }) - if (shouldTodoDebugLog) { - console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() user edited todos in approval UI", { - editedCount: normalizedTodos.length, - todosWithSubtaskIdCount: normalizedTodos.filter((t) => typeof t?.subtaskId === "string").length, - subtaskIds: normalizedTodos.map((t) => t?.subtaskId).filter(Boolean), - }) - } // If the user-edited todo list dropped metadata fields, re-apply metadata preservation against // the previous list (and keep any explicitly provided metadata in the edited list). - // NOTE: Do not instrument here to avoid double-logging within the same update. - console.log("[TODO-DEBUG] execute() STEP 16: about to re-call preserveTodoMetadata (user-edited)", { - enableInstrumentation: false, - }) - normalizedTodos = preserveTodoMetadata(normalizedTodos, previousTodos, { enableInstrumentation: false }) - console.log("[TODO-DEBUG] execute() STEP 17: returned from re-preserve (user-edited)", { - normalizedCount: normalizedTodos.length, - normalizedPreview: normalizedTodos.slice(0, 10).map((t) => summarizeTodoForDebug(t)), - }) - if (shouldTodoDebugLog) { - console.log("[TODO-DEBUG]", "UpdateTodoListTool.execute() normalizedTodos after re-preserve", { - normalizedCount: normalizedTodos.length, - todosWithSubtaskIdCount: normalizedTodos.filter((t) => typeof t?.subtaskId === "string").length, - subtaskIds: normalizedTodos.map((t) => t?.subtaskId).filter(Boolean), - }) - } + normalizedTodos = preserveTodoMetadata(normalizedTodos, previousTodos) task.say( "user_edit_todos", @@ -309,29 +121,15 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { ) } - console.log("[TODO-DEBUG] execute() STEP 18: setting todoList on task", { - finalTodosCount: normalizedTodos.length, - }) await setTodoListForTask(task, normalizedTodos) - console.log("[TODO-DEBUG] execute() STEP 19: setTodoListForTask completed", { - taskTodoListCount: Array.isArray(task?.todoList) ? task.todoList.length : undefined, - }) if (isTodoListChanged) { const md = todoListToMarkdown(normalizedTodos) - console.log("[TODO-DEBUG] execute() STEP 20: returning tool result (user edits)", { - mdLength: md.length, - }) pushToolResult(formatResponse.toolResult("User edits todo:\n\n" + md)) } else { - console.log("[TODO-DEBUG] execute() STEP 20: returning tool result (no user edits)", {}) pushToolResult(formatResponse.toolResult("Todo list updated successfully.")) } } catch (error) { - console.log("[TODO-DEBUG] execute() STEP 99: caught error", { - error: - error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : error, - }) await handleError("update todo list", error as Error) } } @@ -350,7 +148,7 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { } // Avoid log spam: partial updates can stream frequently. - todos = preserveTodoMetadata(todos, previousTodos, { enableInstrumentation: false }) + todos = preserveTodoMetadata(todos, previousTodos) const approvalMsg = JSON.stringify({ tool: "updateTodoList", @@ -445,56 +243,10 @@ function normalizeStatus(status: string | undefined): TodoStatus { * This approach ensures metadata survives status/content changes (which can alter the derived ID) * and handles duplicates deterministically. */ -function preserveTodoMetadata( - nextTodos: TodoItem[], - previousTodos: TodoItem[], - options?: { enableInstrumentation?: boolean }, -): TodoItem[] { - console.log("[TODO-DEBUG] preserveTodoMetadata() STEP 0: ENTERED", { - nextTodosCount: Array.isArray(nextTodos) ? nextTodos.length : 0, - previousTodosCount: Array.isArray(previousTodos) ? previousTodos.length : 0, - enableInstrumentationOption: options?.enableInstrumentation ?? false, - ROO_DEBUG_TODO_METADATA: process.env.ROO_DEBUG_TODO_METADATA, - ROO_CLI_DEBUG_LOG: process.env.ROO_CLI_DEBUG_LOG, - }) - - const shouldTodoDebugLogToConsole = - process.env.ROO_DEBUG_TODO_METADATA === "1" || - process.env.ROO_DEBUG_TODO_METADATA === "true" || - process.env.ROO_CLI_DEBUG_LOG === "1" - +function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): TodoItem[] { const safePrevious = previousTodos ?? [] const safeNext = nextTodos ?? [] - const summarizeTodoForDebug = (t: TodoItem | undefined) => { - if (!t) return undefined - return { - id: typeof t.id === "string" ? t.id : undefined, - status: typeof t.status === "string" ? t.status : undefined, - content: typeof t.content === "string" ? t.content.substring(0, 50) : undefined, - subtaskId: typeof t.subtaskId === "string" ? t.subtaskId : undefined, - tokens: typeof t.tokens === "number" ? t.tokens : undefined, - cost: typeof t.cost === "number" ? t.cost : undefined, - added: typeof t.added === "number" ? t.added : undefined, - removed: typeof t.removed === "number" ? t.removed : undefined, - } - } - - if (shouldTodoDebugLogToConsole) { - console.log("[TODO-DEBUG] preserveTodoMetadata INPUT", { - previousTodosCount: safePrevious.length, - previousTodos: safePrevious.map((t) => summarizeTodoForDebug(t)), - newTodosCount: safeNext.length, - newTodos: safeNext.map((t) => summarizeTodoForDebug(t)), - }) - } - - // Instrumentation must never write to stdout/stderr (CLI TUI) and should be opt-in. - // Gate it behind an env var so we don't write files during normal operation. - const enableInstrumentation = - (options?.enableInstrumentation ?? false) && - (process.env.ROO_CLI_DEBUG_LOG === "1" || process.env.ROO_DEBUG_TODO_METADATA === "1") - // Track which previous todos have been used (by their index) to avoid double-matching const usedPreviousIndices = new Set() @@ -505,22 +257,10 @@ function preserveTodoMetadata( const previousBySubtaskId = new Map>() const previousById = new Map() const previousByContent = new Map>() - - const previousMetadataIndices = new Set() for (let i = 0; i < safePrevious.length; i++) { const prev = safePrevious[i] if (!prev) continue - const hasMetadata = - prev.subtaskId !== undefined || - prev.tokens !== undefined || - prev.cost !== undefined || - prev.added !== undefined || - prev.removed !== undefined - if (hasMetadata) { - previousMetadataIndices.add(i) - } - if (typeof prev.subtaskId === "string") { const list = previousBySubtaskId.get(prev.subtaskId) if (list) list.push({ todo: prev, index: i }) @@ -552,14 +292,6 @@ function preserveTodoMetadata( continue } - if (shouldTodoDebugLogToConsole) { - console.log("[TODO-DEBUG] preserveTodoMetadata ITERATION", { - nextIndex, - next: summarizeTodoForDebug(next), - usedPreviousIndicesCount: usedPreviousIndices.size, - }) - } - let matchedPrev: TodoItem | undefined = undefined let matchedIndex: number | undefined = undefined let matchStrategy: "subtaskId" | "id" | "content" | "none" = "none" @@ -568,22 +300,7 @@ function preserveTodoMetadata( if (typeof next.subtaskId === "string") { const candidates = previousBySubtaskId.get(next.subtaskId) if (candidates) { - if (shouldTodoDebugLogToConsole) { - console.log("[TODO-DEBUG] preserveTodoMetadata subtaskId candidates", { - nextIndex, - subtaskId: next.subtaskId, - candidatesCount: candidates.length, - }) - } for (const candidate of candidates) { - if (shouldTodoDebugLogToConsole) { - console.log("[TODO-DEBUG] preserveTodoMetadata subtaskId candidate", { - nextIndex, - candidateIndex: candidate.index, - candidate: summarizeTodoForDebug(candidate.todo), - candidateAlreadyUsed: usedPreviousIndices.has(candidate.index), - }) - } if (!usedPreviousIndices.has(candidate.index)) { matchedPrev = candidate.todo matchedIndex = candidate.index @@ -609,23 +326,8 @@ function preserveTodoMetadata( const normalizedContent = normalizeTodoContentForId(next.content) const candidates = previousByContent.get(normalizedContent) if (candidates) { - if (shouldTodoDebugLogToConsole) { - console.log("[TODO-DEBUG] preserveTodoMetadata content candidates", { - nextIndex, - normalizedContent: normalizedContent.substring(0, 50), - candidatesCount: candidates.length, - }) - } // Find first unused candidate for (const candidate of candidates) { - if (shouldTodoDebugLogToConsole) { - console.log("[TODO-DEBUG] preserveTodoMetadata content candidate", { - nextIndex, - candidateIndex: candidate.index, - candidate: summarizeTodoForDebug(candidate.todo), - candidateAlreadyUsed: usedPreviousIndices.has(candidate.index), - }) - } if (!usedPreviousIndices.has(candidate.index)) { matchedPrev = candidate.todo matchedIndex = candidate.index @@ -638,23 +340,6 @@ function preserveTodoMetadata( // Mark as used and apply metadata if (matchedPrev && matchedIndex !== undefined) { - const metadataCopiedFromPrev = { - subtaskId: next.subtaskId === undefined ? matchedPrev.subtaskId : undefined, - tokens: next.tokens === undefined ? matchedPrev.tokens : undefined, - cost: next.cost === undefined ? matchedPrev.cost : undefined, - added: next.added === undefined ? matchedPrev.added : undefined, - removed: next.removed === undefined ? matchedPrev.removed : undefined, - } - if (shouldTodoDebugLogToConsole) { - console.log("[TODO-DEBUG] preserveTodoMetadata MATCH", { - nextIndex, - matchStrategy, - matchedIndex, - matchedPrev: summarizeTodoForDebug(matchedPrev), - metadataCopiedFromPrev, - }) - } - usedPreviousIndices.add(matchedIndex) matchedPreviousIndexByNextIndex[nextIndex] = matchedIndex result[nextIndex] = { @@ -668,13 +353,6 @@ function preserveTodoMetadata( continue } - if (shouldTodoDebugLogToConsole) { - console.log("[TODO-DEBUG] preserveTodoMetadata NO_MATCH", { - nextIndex, - next: summarizeTodoForDebug(next), - }) - } - result[nextIndex] = next } @@ -693,7 +371,6 @@ function preserveTodoMetadata( todoStatusSequenceMatchesByIndex(safePrevious, safeNext) if (canApplyIndexRenameCarryover) { - let indexCarryoverCount = 0 for (let i = 0; i < safeNext.length; i++) { if (matchedPreviousIndexByNextIndex[i] !== undefined) continue // already matched by stable strategy if (usedPreviousIndices.has(i)) continue // avoid double-using a previous row @@ -701,22 +378,6 @@ function preserveTodoMetadata( const next = result[i] if (!prev || !next) continue - if (shouldTodoDebugLogToConsole) { - console.log("[TODO-DEBUG] preserveTodoMetadata INDEX_CARRYOVER", { - nextIndex: i, - previousIndex: i, - prev: summarizeTodoForDebug(prev), - next: summarizeTodoForDebug(next), - metadataCopiedFromPrev: { - subtaskId: next.subtaskId === undefined ? prev.subtaskId : undefined, - tokens: next.tokens === undefined ? prev.tokens : undefined, - cost: next.cost === undefined ? prev.cost : undefined, - added: next.added === undefined ? prev.added : undefined, - removed: next.removed === undefined ? prev.removed : undefined, - }, - }) - } - result[i] = { ...next, subtaskId: next.subtaskId ?? prev.subtaskId, @@ -726,15 +387,6 @@ function preserveTodoMetadata( removed: next.removed ?? prev.removed, } usedPreviousIndices.add(i) - indexCarryoverCount++ - } - - if (enableInstrumentation && indexCarryoverCount > 0) { - appendRooCliDebugLog("[Roo-Debug] preserveTodoMetadata: applied index-based rename carryover", { - indexCarryoverCount, - previousTodosCount: safePrevious.length, - nextTodosCount: safeNext.length, - }) } } @@ -772,57 +424,6 @@ function preserveTodoMetadata( result[targetNextIndex] = updatedTarget usedPreviousIndices.add(orphanedPrevIndex) matchedPreviousIndexByNextIndex[targetNextIndex] = orphanedPrevIndex - - if (shouldTodoDebugLogToConsole) { - console.log("[TODO-DEBUG] preserveTodoMetadata ORPHAN_CARRYOVER", { - orphanedContent: orphanedPrev.content?.substring(0, 40), - orphanedSubtaskId: orphanedPrev.subtaskId, - targetContent: updatedTarget.content?.substring(0, 40), - copiedFields: { - subtaskId: updatedTarget.subtaskId, - tokens: updatedTarget.tokens, - cost: updatedTarget.cost, - }, - }) - } - } - - // Lightweight debug instrumentation: detect when previous rows that had metadata could not be - // matched to any next todo row (and therefore their metadata could not be preserved). - // - // Keep payload minimal to avoid logging user content. - if (enableInstrumentation && previousMetadataIndices.size > 0) { - let lostMetadataRowCount = 0 - for (const prevIndex of previousMetadataIndices) { - if (!usedPreviousIndices.has(prevIndex)) { - lostMetadataRowCount++ - } - } - - if (lostMetadataRowCount > 0) { - if (shouldTodoDebugLogToConsole) { - console.log("[TODO-DEBUG] preserveTodoMetadata LOST_METADATA", { - lostMetadataRowCount, - previousMetadataRowCount: previousMetadataIndices.size, - previousTodosCount: safePrevious.length, - nextTodosCount: safeNext.length, - }) - } - // IMPORTANT: do not use console.log here in the CLI TUI. It can corrupt rendering (e.g. dropdowns). - appendRooCliDebugLog("[Roo-Debug] preserveTodoMetadata: previous todo(s) with metadata were not matched", { - lostMetadataRowCount, - previousMetadataRowCount: previousMetadataIndices.size, - previousTodosCount: safePrevious.length, - nextTodosCount: safeNext.length, - }) - } - } - - if (shouldTodoDebugLogToConsole) { - console.log("[TODO-DEBUG] preserveTodoMetadata OUTPUT", { - resultCount: result.length, - resultTodos: result.map((t) => summarizeTodoForDebug(t)), - }) } return result @@ -839,19 +440,6 @@ function todoStatusSequenceMatchesByIndex(previous: TodoItem[], next: TodoItem[] } return true } - -const ROO_CLI_DEBUG_LOG_PATH = path.join(os.tmpdir(), "roo-cli-debug.log") - -function appendRooCliDebugLog(message: string, data?: unknown) { - try { - const timestamp = new Date().toISOString() - const entry = data ? `[${timestamp}] ${message}: ${JSON.stringify(data)}\n` : `[${timestamp}] ${message}\n` - fs.appendFileSync(ROO_CLI_DEBUG_LOG_PATH, entry) - } catch { - // Swallow errors: logging must never break tool execution. - } -} - export function parseMarkdownChecklist(md: string): TodoItem[] { if (typeof md !== "string") return [] const lines = md diff --git a/src/shared/todo.ts b/src/shared/todo.ts index 3f2ca0c3fab..d2a01225d79 100644 --- a/src/shared/todo.ts +++ b/src/shared/todo.ts @@ -2,7 +2,6 @@ import { ClineMessage, TodoItem } from "@roo-code/types" export function getLatestTodo(clineMessages: ClineMessage[]): TodoItem[] { if (!Array.isArray(clineMessages) || clineMessages.length === 0) { - console.log("[TODO-DEBUG]", "getLatestTodo called with empty clineMessages") return [] } @@ -31,21 +30,5 @@ export function getLatestTodo(clineMessages: ClineMessage[]): TodoItem[] { } } - console.log("[TODO-DEBUG]", "getLatestTodo scanned messages", { - totalMessages: clineMessages.length, - candidateMessages: candidateMessages.length, - matchedUpdateTodoListCount, - parseFailureCount, - returnedTodosCount: Array.isArray(lastTodos) ? lastTodos.length : 0, - // Only log lightweight metadata for the last few candidates (avoid dumping full message content) - lastCandidates: candidateMessages.slice(-5).map((m) => ({ - ts: m.ts, - type: m.type, - ask: (m as any).ask, - say: (m as any).say, - textLength: typeof m.text === "string" ? m.text.length : 0, - })), - }) - return Array.isArray(lastTodos) ? lastTodos : [] } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 3a882231907..9fbcffcfef5 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -397,18 +397,8 @@ export const ChatRowContent = ({ if (message.ask !== "tool") return null const parsed = safeJsonParse(message.text) - // TODO debugging: verify tool JSON is actually being parsed in the webview. - if ((parsed as any)?.tool === "updateTodoList") { - console.log("[TODO-DEBUG]", "ChatRow parsed tool JSON", { - messageTs: message.ts, - toolName: (parsed as any)?.tool, - newTodosCount: Array.isArray((parsed as any)?.todos) ? (parsed as any).todos.length : undefined, - parsed, - }) - } - return parsed - }, [message.ask, message.text, message.ts]) + }, [message.ask, message.text]) // Unified diff content (provided by backend when relevant) const unifiedDiff = useMemo(() => { @@ -581,15 +571,6 @@ export const ChatRowContent = ({ // Get previous todos from the latest todos in the task context const previousTodos = getPreviousTodos(clineMessages, message.ts) - console.log("[TODO-DEBUG]", "ChatRow rendering TodoChangeDisplay", { - messageTs: message.ts, - previousTodosCount: Array.isArray(previousTodos) ? previousTodos.length : undefined, - newTodosCount: Array.isArray(todos) ? todos.length : undefined, - previousTodos, - newTodos: todos, - parsedTool: tool, - }) - return } case "newFileCreated": diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 17ec30d70f8..0786699ca12 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -126,13 +126,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // TODO debugging: ensure todo extraction runs and surfaces state that should drive UI. - console.log("[TODO-DEBUG]", "ChatView latestTodos computed", { - messagesCount: Array.isArray(messages) ? messages.length : undefined, - currentTaskTodosCount: Array.isArray(currentTaskTodos) ? currentTaskTodos.length : undefined, - latestTodosCount: Array.isArray(latestTodos) ? latestTodos.length : undefined, - latestTodos, - }) + // Intentionally left blank: keep dependencies so todo extraction logic remains reactive. }, [messages, currentTaskTodos, latestTodos]) const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages]) diff --git a/webview-ui/src/components/chat/TodoChangeDisplay.tsx b/webview-ui/src/components/chat/TodoChangeDisplay.tsx index d2043f435b6..e5f9f86043f 100644 --- a/webview-ui/src/components/chat/TodoChangeDisplay.tsx +++ b/webview-ui/src/components/chat/TodoChangeDisplay.tsx @@ -26,17 +26,6 @@ function getTodoIcon(status: TodoStatus | null) { } export function TodoChangeDisplay({ previousTodos, newTodos }: TodoChangeDisplayProps) { - console.log("[TODO-DEBUG]", "TodoChangeDisplay compare todos", { - previousTodosCount: Array.isArray(previousTodos) ? previousTodos.length : 0, - newTodosCount: Array.isArray(newTodos) ? newTodos.length : 0, - previousTodos: Array.isArray(previousTodos) - ? previousTodos.map((t) => ({ id: t.id, content: t.content, status: t.status })) - : [], - newTodos: Array.isArray(newTodos) - ? newTodos.map((t) => ({ id: t.id, content: t.content, status: t.status })) - : [], - }) - const isInitialState = previousTodos.length === 0 // Determine which todos to display @@ -45,45 +34,21 @@ export function TodoChangeDisplay({ previousTodos, newTodos }: TodoChangeDisplay if (isInitialState && newTodos.length > 0) { // For initial state, show all todos in their original order todosToDisplay = newTodos - console.log("[TODO-DEBUG]", "TodoChangeDisplay selection: initial state -> show all newTodos", { - todosToDisplayCount: todosToDisplay.length, - }) } else { // For updates, only show changes (completed or started) in their original order todosToDisplay = newTodos.filter((newTodo) => { if (newTodo.status === "completed") { const previousTodo = previousTodos.find((p) => p.id === newTodo.id || p.content === newTodo.content) const include = !previousTodo || previousTodo.status !== "completed" - console.log("[TODO-DEBUG]", "TodoChangeDisplay selection: completed todo", { - newTodo: { id: newTodo.id, content: newTodo.content, status: newTodo.status }, - matchedPreviousTodo: previousTodo - ? { id: previousTodo.id, content: previousTodo.content, status: previousTodo.status } - : undefined, - include, - }) return include } if (newTodo.status === "in_progress") { const previousTodo = previousTodos.find((p) => p.id === newTodo.id || p.content === newTodo.content) const include = !previousTodo || previousTodo.status !== "in_progress" - console.log("[TODO-DEBUG]", "TodoChangeDisplay selection: in_progress todo", { - newTodo: { id: newTodo.id, content: newTodo.content, status: newTodo.status }, - matchedPreviousTodo: previousTodo - ? { id: previousTodo.id, content: previousTodo.content, status: previousTodo.status } - : undefined, - include, - }) return include } - console.log("[TODO-DEBUG]", "TodoChangeDisplay selection: ignored todo (not completed/in_progress)", { - newTodo: { id: newTodo.id, content: newTodo.content, status: newTodo.status }, - }) return false }) - console.log("[TODO-DEBUG]", "TodoChangeDisplay selection result", { - todosToDisplayCount: todosToDisplay.length, - todosToDisplay: todosToDisplay.map((t) => ({ id: t.id, content: t.content, status: t.status })), - }) } // If no todos to display, don't render anything diff --git a/webview-ui/src/components/chat/TodoListDisplay.tsx b/webview-ui/src/components/chat/TodoListDisplay.tsx index 902206d66f4..c3da1c717ba 100644 --- a/webview-ui/src/components/chat/TodoListDisplay.tsx +++ b/webview-ui/src/components/chat/TodoListDisplay.tsx @@ -41,16 +41,6 @@ export interface TodoListDisplayProps { } export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoListDisplayProps) { - useEffect(() => { - console.log("[TODO-DEBUG]", "TodoListDisplay props received", { - todosCount: Array.isArray(todos) ? todos.length : 0, - todoSubtaskIds: Array.isArray(todos) ? todos.map((t) => t?.subtaskId).filter(Boolean) : [], - subtaskDetailsCount: Array.isArray(subtaskDetails) ? subtaskDetails.length : 0, - subtaskDetailsIds: Array.isArray(subtaskDetails) ? subtaskDetails.map((s) => s?.id).filter(Boolean) : [], - hasOnSubtaskClick: Boolean(onSubtaskClick), - }) - }, [todos, subtaskDetails, onSubtaskClick]) - const [isCollapsed, setIsCollapsed] = useState(true) const ulRef = useRef(null) const itemRefs = useRef<(HTMLLIElement | null)[]>([]) @@ -118,25 +108,10 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL const todoStatus = (todo.status as TodoStatus) ?? "pending" const icon = getTodoIcon(todoStatus) const isClickable = Boolean(todo.subtaskId && onSubtaskClick) - console.log("[TODO-DEBUG]", "TodoListDisplay subtask match start", { - todoIndex: idx, - todoId: todo.id, - todoContent: todo.content, - todoSubtaskId: todo.subtaskId, - availableSubtaskDetailIds: Array.isArray(subtaskDetails) - ? subtaskDetails.map((s) => s?.id).filter(Boolean) - : [], - }) const subtaskById = subtaskDetails && todo.subtaskId ? subtaskDetails.find((s) => s.id === todo.subtaskId) : undefined - console.log("[TODO-DEBUG]", "TodoListDisplay subtask match result", { - todoIndex: idx, - todoSubtaskId: todo.subtaskId, - matched: Boolean(subtaskById), - matchedSubtaskId: subtaskById?.id, - }) const displayTokens = todo.tokens ?? subtaskById?.tokens const displayCost = todo.cost ?? subtaskById?.cost const shouldShowCost = typeof displayTokens === "number" && typeof displayCost === "number" @@ -164,33 +139,6 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL const shouldShowLineChanges = hasValidSubtaskLink && (canRenderAdded || canRenderRemoved) - console.log("[TODO-DEBUG]", "TodoListDisplay metadata computed", { - todoIndex: idx, - todoSubtaskId: todo.subtaskId, - fromTodo: { - tokens: todo.tokens, - cost: todo.cost, - added: todo.added, - removed: todo.removed, - }, - fromSubtaskDetails: subtaskById - ? { - tokens: subtaskById.tokens, - cost: subtaskById.cost, - added: subtaskById.added, - removed: subtaskById.removed, - } - : undefined, - display: { - displayTokens, - displayCost, - displayAdded, - displayRemoved, - }, - shouldShowCost, - shouldShowLineChanges, - }) - const isAddedPositive = canRenderAdded && (displayAdded as number) > 0 const isRemovedPositive = canRenderRemoved && (displayRemoved as number) > 0 const isAddedZero = canRenderAdded && displayAdded === 0 From e8bcb8cf049ebcea46d3e5052d05cc809ccfbc8e Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Tue, 20 Jan 2026 23:31:26 -0500 Subject: [PATCH 17/17] refactor: extract utility functions from taskMetadata into shared modules Extract isFiniteNumber, isDiffStats, and getLineStatsFromToolApprovalMessages from taskMetadata.ts into reusable shared utilities for better code organization and reusability. Changes: - Add DiffStats interface to @roo-code/types for shared type definition - Create src/shared/typeGuards.ts with isFiniteNumber and isDiffStats type guards - Create src/shared/messageUtils.ts with getLineStatsFromToolApprovalMessages - Update taskMetadata.ts to import from new shared utilities (removes 58 lines) - Add comprehensive test coverage (21 tests total) for extracted functions All existing tests continue to pass, ensuring no regression. --- packages/types/src/tool-params.ts | 8 + src/core/task-persistence/taskMetadata.ts | 59 +---- src/shared/__tests__/messageUtils.spec.ts | 259 ++++++++++++++++++++++ src/shared/__tests__/typeGuards.spec.ts | 70 ++++++ src/shared/messageUtils.ts | 55 +++++ src/shared/typeGuards.ts | 22 ++ 6 files changed, 415 insertions(+), 58 deletions(-) create mode 100644 src/shared/__tests__/messageUtils.spec.ts create mode 100644 src/shared/__tests__/typeGuards.spec.ts create mode 100644 src/shared/messageUtils.ts create mode 100644 src/shared/typeGuards.ts diff --git a/packages/types/src/tool-params.ts b/packages/types/src/tool-params.ts index f8708b0c2b4..8037f9a55ef 100644 --- a/packages/types/src/tool-params.ts +++ b/packages/types/src/tool-params.ts @@ -36,3 +36,11 @@ export interface GenerateImageParams { path: string image?: string } + +/** + * Statistics about code changes from a diff operation + */ +export interface DiffStats { + added: number + removed: number +} diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 92607595a98..ed6858a766c 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -8,68 +8,11 @@ import { combineCommandSequences } from "../../shared/combineCommandSequences" import { getApiMetrics } from "../../shared/getApiMetrics" import { findLastIndex } from "../../shared/array" import { getTaskDirectoryPath } from "../../utils/storage" +import { getLineStatsFromToolApprovalMessages } from "../../shared/messageUtils" import { t } from "../../i18n" const taskSizeCache = new NodeCache({ stdTTL: 30, checkperiod: 5 * 60 }) -type DiffStats = { added: number; removed: number } - -function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) -} - -function isDiffStats(value: unknown): value is DiffStats { - if (!value || typeof value !== "object") return false - - const v = value as { added?: unknown; removed?: unknown } - return isFiniteNumber(v.added) && isFiniteNumber(v.removed) -} - -function getLineStatsFromToolApprovalMessages(messages: ClineMessage[]): { - linesAdded: number - linesRemoved: number - foundAnyStats: boolean -} { - let linesAdded = 0 - let linesRemoved = 0 - let foundAnyStats = false - - for (const m of messages) { - // Only count complete tool approval asks (avoid double-counting partial/streaming updates) - if (!(m.type === "ask" && m.ask === "tool" && m.partial !== true)) continue - if (typeof m.text !== "string" || m.text.length === 0) continue - - let payload: unknown - try { - payload = JSON.parse(m.text) - } catch { - continue - } - - if (!payload || typeof payload !== "object") continue - const p = payload as { diffStats?: unknown; batchDiffs?: unknown } - - if (isDiffStats(p.diffStats)) { - linesAdded += p.diffStats.added - linesRemoved += p.diffStats.removed - foundAnyStats = true - } - - if (Array.isArray(p.batchDiffs)) { - for (const batchDiff of p.batchDiffs) { - if (!batchDiff || typeof batchDiff !== "object") continue - const bd = batchDiff as { diffStats?: unknown } - if (!isDiffStats(bd.diffStats)) continue - linesAdded += bd.diffStats.added - linesRemoved += bd.diffStats.removed - foundAnyStats = true - } - } - } - - return { linesAdded, linesRemoved, foundAnyStats } -} - export type TaskMetadataOptions = { taskId: string rootTaskId?: string diff --git a/src/shared/__tests__/messageUtils.spec.ts b/src/shared/__tests__/messageUtils.spec.ts new file mode 100644 index 00000000000..f1d16857eb4 --- /dev/null +++ b/src/shared/__tests__/messageUtils.spec.ts @@ -0,0 +1,259 @@ +// npx vitest run src/shared/__tests__/messageUtils.spec.ts + +import type { ClineMessage } from "@roo-code/types" +import { getLineStatsFromToolApprovalMessages } from "../messageUtils" + +describe("messageUtils", () => { + describe("getLineStatsFromToolApprovalMessages", () => { + it("should return zero stats for empty messages array", () => { + const result = getLineStatsFromToolApprovalMessages([]) + + expect(result).toEqual({ + linesAdded: 0, + linesRemoved: 0, + foundAnyStats: false, + }) + }) + + it("should ignore non-tool ask messages", () => { + const messages: ClineMessage[] = [ + { type: "say", say: "text", text: "hello", ts: 1000 }, + { type: "ask", ask: "followup", text: '{"diffStats":{"added":10,"removed":5}}', ts: 1001 }, + ] + + const result = getLineStatsFromToolApprovalMessages(messages) + + expect(result).toEqual({ + linesAdded: 0, + linesRemoved: 0, + foundAnyStats: false, + }) + }) + + it("should ignore partial tool ask messages", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "tool", + partial: true, + text: '{"diffStats":{"added":10,"removed":5}}', + ts: 1000, + }, + ] + + const result = getLineStatsFromToolApprovalMessages(messages) + + expect(result).toEqual({ + linesAdded: 0, + linesRemoved: 0, + foundAnyStats: false, + }) + }) + + it("should extract stats from a valid tool ask with diffStats", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "tool", + text: '{"diffStats":{"added":10,"removed":5}}', + ts: 1000, + }, + ] + + const result = getLineStatsFromToolApprovalMessages(messages) + + expect(result).toEqual({ + linesAdded: 10, + linesRemoved: 5, + foundAnyStats: true, + }) + }) + + it("should extract stats from batchDiffs array", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "tool", + text: JSON.stringify({ + batchDiffs: [{ diffStats: { added: 5, removed: 3 } }, { diffStats: { added: 15, removed: 7 } }], + }), + ts: 1000, + }, + ] + + const result = getLineStatsFromToolApprovalMessages(messages) + + expect(result).toEqual({ + linesAdded: 20, + linesRemoved: 10, + foundAnyStats: true, + }) + }) + + it("should handle both diffStats and batchDiffs in the same message", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "tool", + text: JSON.stringify({ + diffStats: { added: 10, removed: 5 }, + batchDiffs: [{ diffStats: { added: 5, removed: 3 } }, { diffStats: { added: 15, removed: 7 } }], + }), + ts: 1000, + }, + ] + + const result = getLineStatsFromToolApprovalMessages(messages) + + expect(result).toEqual({ + linesAdded: 30, + linesRemoved: 15, + foundAnyStats: true, + }) + }) + + it("should accumulate stats from multiple messages", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "tool", + text: '{"diffStats":{"added":10,"removed":5}}', + ts: 1000, + }, + { + type: "ask", + ask: "tool", + text: '{"diffStats":{"added":20,"removed":15}}', + ts: 1001, + }, + ] + + const result = getLineStatsFromToolApprovalMessages(messages) + + expect(result).toEqual({ + linesAdded: 30, + linesRemoved: 20, + foundAnyStats: true, + }) + }) + + it("should ignore messages with invalid JSON", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "tool", + text: "not valid json", + ts: 1000, + }, + { + type: "ask", + ask: "tool", + text: '{"diffStats":{"added":10,"removed":5}}', + ts: 1001, + }, + ] + + const result = getLineStatsFromToolApprovalMessages(messages) + + expect(result).toEqual({ + linesAdded: 10, + linesRemoved: 5, + foundAnyStats: true, + }) + }) + + it("should ignore messages with empty or missing text", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "tool", + text: "", + ts: 1000, + }, + { + type: "ask", + ask: "tool", + ts: 1001, + }, + ] + + const result = getLineStatsFromToolApprovalMessages(messages) + + expect(result).toEqual({ + linesAdded: 0, + linesRemoved: 0, + foundAnyStats: false, + }) + }) + + it("should ignore invalid diffStats (non-finite numbers)", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "tool", + text: '{"diffStats":{"added":"10","removed":5}}', + ts: 1000, + }, + { + type: "ask", + ask: "tool", + text: '{"diffStats":{"added":10,"removed":null}}', + ts: 1001, + }, + ] + + const result = getLineStatsFromToolApprovalMessages(messages) + + expect(result).toEqual({ + linesAdded: 0, + linesRemoved: 0, + foundAnyStats: false, + }) + }) + + it("should skip invalid items in batchDiffs array", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "tool", + text: JSON.stringify({ + batchDiffs: [ + { diffStats: { added: 5, removed: 3 } }, + null, + { diffStats: { added: "invalid", removed: 7 } }, + { diffStats: { added: 15, removed: 10 } }, + ], + }), + ts: 1000, + }, + ] + + const result = getLineStatsFromToolApprovalMessages(messages) + + expect(result).toEqual({ + linesAdded: 20, + linesRemoved: 13, + foundAnyStats: true, + }) + }) + + it("should handle messages with no diffStats field", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "tool", + text: '{"someOtherField":"value"}', + ts: 1000, + }, + ] + + const result = getLineStatsFromToolApprovalMessages(messages) + + expect(result).toEqual({ + linesAdded: 0, + linesRemoved: 0, + foundAnyStats: false, + }) + }) + }) +}) diff --git a/src/shared/__tests__/typeGuards.spec.ts b/src/shared/__tests__/typeGuards.spec.ts new file mode 100644 index 00000000000..837e1d8df7b --- /dev/null +++ b/src/shared/__tests__/typeGuards.spec.ts @@ -0,0 +1,70 @@ +// npx vitest run src/shared/__tests__/typeGuards.spec.ts + +import { isFiniteNumber, isDiffStats } from "../typeGuards" + +describe("typeGuards", () => { + describe("isFiniteNumber", () => { + it("should return true for finite numbers", () => { + expect(isFiniteNumber(0)).toBe(true) + expect(isFiniteNumber(42)).toBe(true) + expect(isFiniteNumber(-10)).toBe(true) + expect(isFiniteNumber(3.14)).toBe(true) + expect(isFiniteNumber(-0.5)).toBe(true) + }) + + it("should return false for non-finite numbers", () => { + expect(isFiniteNumber(Infinity)).toBe(false) + expect(isFiniteNumber(-Infinity)).toBe(false) + expect(isFiniteNumber(NaN)).toBe(false) + }) + + it("should return false for non-number types", () => { + expect(isFiniteNumber("42")).toBe(false) + expect(isFiniteNumber(null)).toBe(false) + expect(isFiniteNumber(undefined)).toBe(false) + expect(isFiniteNumber(true)).toBe(false) + expect(isFiniteNumber({})).toBe(false) + expect(isFiniteNumber([])).toBe(false) + }) + }) + + describe("isDiffStats", () => { + it("should return true for valid DiffStats objects", () => { + expect(isDiffStats({ added: 0, removed: 0 })).toBe(true) + expect(isDiffStats({ added: 10, removed: 5 })).toBe(true) + expect(isDiffStats({ added: 100, removed: 200 })).toBe(true) + }) + + it("should return false for objects with non-finite numbers", () => { + expect(isDiffStats({ added: Infinity, removed: 5 })).toBe(false) + expect(isDiffStats({ added: 10, removed: NaN })).toBe(false) + expect(isDiffStats({ added: NaN, removed: Infinity })).toBe(false) + }) + + it("should return false for objects with non-number properties", () => { + expect(isDiffStats({ added: "10", removed: 5 })).toBe(false) + expect(isDiffStats({ added: 10, removed: "5" })).toBe(false) + expect(isDiffStats({ added: null, removed: 5 })).toBe(false) + expect(isDiffStats({ added: 10, removed: undefined })).toBe(false) + }) + + it("should return false for objects missing required properties", () => { + expect(isDiffStats({ added: 10 })).toBe(false) + expect(isDiffStats({ removed: 5 })).toBe(false) + expect(isDiffStats({})).toBe(false) + }) + + it("should return false for non-object types", () => { + expect(isDiffStats(null)).toBe(false) + expect(isDiffStats(undefined)).toBe(false) + expect(isDiffStats("string")).toBe(false) + expect(isDiffStats(42)).toBe(false) + expect(isDiffStats([])).toBe(false) + expect(isDiffStats(true)).toBe(false) + }) + + it("should ignore extra properties on valid objects", () => { + expect(isDiffStats({ added: 10, removed: 5, extra: "value" })).toBe(true) + }) + }) +}) diff --git a/src/shared/messageUtils.ts b/src/shared/messageUtils.ts new file mode 100644 index 00000000000..048248727b6 --- /dev/null +++ b/src/shared/messageUtils.ts @@ -0,0 +1,55 @@ +import type { ClineMessage } from "@roo-code/types" +import { isDiffStats } from "./typeGuards" + +/** + * Extract line statistics (added/removed) from tool approval messages in the message history. + * This function scans messages for diff statistics from completed tool approval requests, + * including both single file operations and batch operations. + * + * @param messages - Array of ClineMessage objects to analyze + * @returns Object containing total lines added, removed, and whether any stats were found + */ +export function getLineStatsFromToolApprovalMessages(messages: ClineMessage[]): { + linesAdded: number + linesRemoved: number + foundAnyStats: boolean +} { + let linesAdded = 0 + let linesRemoved = 0 + let foundAnyStats = false + + for (const m of messages) { + // Only count complete tool approval asks (avoid double-counting partial/streaming updates) + if (!(m.type === "ask" && m.ask === "tool" && m.partial !== true)) continue + if (typeof m.text !== "string" || m.text.length === 0) continue + + let payload: unknown + try { + payload = JSON.parse(m.text) + } catch { + continue + } + + if (!payload || typeof payload !== "object") continue + const p = payload as { diffStats?: unknown; batchDiffs?: unknown } + + if (isDiffStats(p.diffStats)) { + linesAdded += p.diffStats.added + linesRemoved += p.diffStats.removed + foundAnyStats = true + } + + if (Array.isArray(p.batchDiffs)) { + for (const batchDiff of p.batchDiffs) { + if (!batchDiff || typeof batchDiff !== "object") continue + const bd = batchDiff as { diffStats?: unknown } + if (!isDiffStats(bd.diffStats)) continue + linesAdded += bd.diffStats.added + linesRemoved += bd.diffStats.removed + foundAnyStats = true + } + } + } + + return { linesAdded, linesRemoved, foundAnyStats } +} diff --git a/src/shared/typeGuards.ts b/src/shared/typeGuards.ts new file mode 100644 index 00000000000..ffd14ecb417 --- /dev/null +++ b/src/shared/typeGuards.ts @@ -0,0 +1,22 @@ +import type { DiffStats } from "@roo-code/types" + +/** + * Type guard to check if a value is a finite number + * @param value - The value to check + * @returns true if the value is a number and is finite + */ +export function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) +} + +/** + * Type guard to check if a value conforms to the DiffStats interface + * @param value - The value to check + * @returns true if the value has valid `added` and `removed` properties that are finite numbers + */ +export function isDiffStats(value: unknown): value is DiffStats { + if (!value || typeof value !== "object") return false + + const v = value as { added?: unknown; removed?: unknown } + return isFiniteNumber(v.added) && isFiniteNumber(v.removed) +}