feat: link delegated subtasks to todos for cost breakdown

This commit is contained in:
Toray Altas 2026-01-16 10:04:19 -05:00
parent 774a8f9471
commit e18382d9ff
5 changed files with 218 additions and 166 deletions

View file

@ -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<typeof todoItemSchema>

View file

@ -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<void> {
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<string, TodoItem[]>()
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

View file

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

View file

@ -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<HTMLUListElement>(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 && (
<ul ref={ulRef} className="list-none max-h-[300px] overflow-y-auto mt-2 -mb-1 pb-0 px-2 cursor-default">
{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 (
<li
@ -154,17 +125,19 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL
{icon}
<span
className={cn("flex-1", isClickable && "cursor-pointer hover:underline")}
onClick={isClickable ? () => onSubtaskClick(matchingSubtask.id) : undefined}>
onClick={
isClickable ? () => onSubtaskClick?.(todo.subtaskId as string) : undefined
}>
{todo.content}
</span>
{/* Token count and cost display */}
{matchingSubtask && (
{shouldShowCost && (
<span className="flex items-center gap-2 text-xs text-vscode-descriptionForeground shrink-0">
<span className="tabular-nums opacity-70">
{formatLargeNumber(matchingSubtask.tokens)}
{formatLargeNumber(displayTokens)}
</span>
<span className="tabular-nums min-w-[45px] text-right">
${matchingSubtask.cost.toFixed(2)}
${displayCost.toFixed(2)}
</span>
</span>
)}

View file

@ -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(<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} />)
// 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(<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} />)
// 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(<TodoListDisplay todos={todosWithSlightlyDifferentNames} subtaskDetails={subtaskWithFullName} />)
// 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(<TodoListDisplay todos={todosLowercase} subtaskDetails={subtaskUppercase} />)
// 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(<TodoListDisplay todos={todosWithSubtaskPrefix} subtaskDetails={subtaskWithMarkdownPrefix} />)
render(<TodoListDisplay todos={todosWithDirectCost} />)
// 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[] = [
it("should fall back to subtaskDetails by ID when todo.tokens/cost are missing", () => {
const todosMissingCostFields = [
{
id: "subtask-1",
name: "## Task: Add Timestamp to the Bottom of the Pag...",
tokens: 95000,
cost: 0.24,
id: "1",
content: "Task 1: Change background colour",
status: "completed",
hasNestedChildren: false,
subtaskId: "subtask-1",
},
]
render(<TodoListDisplay todos={todos} subtaskDetails={subtaskWithTruncation} />)
render(<TodoListDisplay todos={todosMissingCostFields} subtaskDetails={subtaskDetails} />)
// 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(<TodoListDisplay todos={todosWithNumberedPrefix} subtaskDetails={subtaskWithoutPrefix} />)
// 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(
<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} onSubtaskClick={onSubtaskClick} />,
@ -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(
<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} onSubtaskClick={onSubtaskClick} />,