Skip to content

Conversation

@taltas
Copy link
Contributor

@taltas taltas commented Jan 16, 2026

Summary

This PR implements the original feature: display token count and cost per delegated subtask inline in the parent task’s todo list.

It also replaces the unreliable todo↔subtask matching with deterministic ID-based linking, so each delegated subtask can write its tokens + cost back onto the exact todo record it belongs to.

Screenshots

ezgif-34019eabb0f25160.mov
Screenshot 2026-01-15 at 10 32 35 pm

Problem (why deterministic linking is required)

1) Why we can’t rely on TodoItem.id

TodoItem.id is not stable. It’s derived from content + status, so it changes exactly when a todo transitions between [ ][-][x].

The ID is computed here:

  • id = md5(content + status) in parseMarkdownChecklist:
    • export function parseMarkdownChecklist(md: string): TodoItem[] {
      if (typeof md !== "string") return []
      const lines = md
      .split(/\r?\n/)
      .map((l) => l.trim())
      .filter(Boolean)
      const todos: TodoItem[] = []
      for (const line of lines) {
      const match = line.match(/^(?:-\s*)?\[\s*([ xX\-~])\s*\]\s+(.+)$/)
      if (!match) continue
      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")
      todos.push({
      id,
      content: match[2],
      status,
      })
      }
      return todos
      }
    • specifically the hash input uses match[2] + status:
      const id = crypto
      .createHash("md5")
      .update(match[2] + status)
      .digest("hex")
      todos.push({

This means:

  • [ ] Write tests and [-] Write tests produce different ids
  • linking by todo.id would break during normal execution

2) Why we can’t rely on fuzzy matching

The UI previously matched a todo’s content to a subtask’s name via fuzzy string matching, which can collide and is not robust to truncation or user edits.

Solution

1) Add a stable linkage field: TodoItem.subtaskId

We add subtaskId?: string to TodoItem as a stable foreign key to the delegated child task ID.

  • Schema:
    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>

2) Provider-owned lifecycle hooks (delegation + completion)

We link and update todos at the two existing delegation chokepoints:

Delegation-time: provider knows child.taskId immediately

  • Set todo.subtaskId = childTaskId and persist into the parent’s message stream
  • // 4) Create child as sole active (parent reference preserved for lineage)
    // Pass initialStatus: "active" to ensure the child task's historyItem is created
    // with status from the start, avoiding race conditions where the task might
    // call attempt_completion before status is persisted separately.
    const child = await this.createTask(message, undefined, parent as any, {
    initialTodos,
    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)
    }`,
    )
    }

Completion-time: provider knows { parentTaskId, childTaskId }

  • Load child HistoryItem, compute tokens/cost, find todo by subtaskId, and write back:
    • todo.tokens = tokensIn + tokensOut
    • todo.cost = totalCost
  • public async reopenParentFromDelegation(params: {
    parentTaskId: string
    childTaskId: string
    completionResultSummary: string
    }): Promise<void> {
    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)
    let parentClineMessages: ClineMessage[] = []
    try {
    parentClineMessages = await readTaskMessages({
    taskId: parentTaskId,
    globalStoragePath,
    })
    } catch {
    parentClineMessages = []
    }
    let parentApiMessages: any[] = []
    try {
    parentApiMessages = (await readApiMessages({
    taskId: parentTaskId,
    globalStoragePath,
    })) as any[]
    } catch {
    parentApiMessages = []
    }
    // 2) Inject synthetic records: UI subtask_result and update API tool_result
    const ts = Date.now()
    // Defensive: ensure arrays
    if (!Array.isArray(parentClineMessages)) parentClineMessages = []
    if (!Array.isArray(parentApiMessages)) parentApiMessages = []
    const subtaskUiMessage: ClineMessage = {
    type: "say",
    say: "subtask_result",
    text: completionResultSummary,
    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)
    }`,
    )
    }

3) Preserve metadata across subsequent update_todo_list calls

Because update_todo_list reparses markdown and re-materializes todo objects, we preserve metadata fields across updates by exact content match (duplicates matched in order).

  • preserveTodoMetadata:
    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,
    }
    })
    }

4) UI now renders from the todo record

TodoListDisplay now:

  • navigates via todo.subtaskId
  • renders token/cost directly from todo.tokens / todo.cost (with optional fallback to subtaskDetails-by-id)
  • import { cn } from "@/lib/utils"
    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"
    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":
    return <Check className={`size-3 mt-1 shrink-0`} />
    case "in_progress":
    return <ArrowRight className="size-3 mt-1 shrink-0" />
    default:
    return <SquareDashed className="size-3 mt-1 shrink-0" />
    }
    }
    export interface TodoListDisplayProps {
    todos: TodoItem[]
    subtaskDetails?: SubtaskDetail[]
    onSubtaskClick?: (subtaskId: string) => void
    }
    export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoListDisplayProps) {
    const [isCollapsed, setIsCollapsed] = useState(true)
    const ulRef = useRef<HTMLUListElement>(null)
    const itemRefs = useRef<(HTMLLIElement | null)[]>([])
    const scrollIndex = useMemo(() => {
    const inProgressIdx = todos.findIndex((todo) => todo.status === "in_progress")
    if (inProgressIdx !== -1) return inProgressIdx
    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) => todo.status === "in_progress")
    if (inProgress) return inProgress
    return todos.find((todo) => todo.status !== "completed")
    }, [todos])
    useEffect(() => {
    if (isCollapsed) return
    if (!ulRef.current) return
    if (scrollIndex === -1) return
    const target = itemRefs.current[scrollIndex]
    if (target && ulRef.current) {
    const ul = ulRef.current
    const targetTop = target.offsetTop - ul.offsetTop
    const targetHeight = target.offsetHeight
    const ulHeight = ul.clientHeight
    const scrollTo = targetTop - (ulHeight / 2 - targetHeight / 2)
    ul.scrollTop = scrollTo
    }
    }, [todos, isCollapsed, scrollIndex])
    if (!Array.isArray(todos) || todos.length === 0) return null
    const totalCount = todos.length
    const completedCount = todos.filter((todo) => todo.status === "completed").length
    const allCompleted = completedCount === totalCount && totalCount > 0
    return (
    <div data-todo-list className="mt-1 -mx-2.5 border-t border-vscode-sideBar-background overflow-hidden">
    <div
    className={cn(
    "flex items-center gap-2 pt-2 px-2.5 cursor-pointer select-none",
    mostImportantTodo?.status === "in_progress" && isCollapsed
    ? "text-vscode-charts-yellow"
    : "text-vscode-foreground",
    )}
    onClick={() => setIsCollapsed((v) => !v)}>
    <ListChecks className="size-3 shrink-0" />
    <span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
    {isCollapsed
    ? allCompleted
    ? t("chat:todo.complete", { total: completedCount })
    : mostImportantTodo?.content // show current todo while not done
    : t("chat:todo.partial", { completed: completedCount, total: totalCount })}
    </span>
    {isCollapsed && completedCount < totalCount && (
    <div className="shrink-0 text-vscode-descriptionForeground text-xs">
    {completedCount}/{totalCount}
    </div>
    )}
    </div>
    {/* 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, idx: number) => {
    const icon = getTodoIcon(todo.status as TodoStatus)
    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
    key={todo.id || todo.content}
    ref={(el) => (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",
    )}>
    {icon}
    <span
    className={cn("flex-1", isClickable && "cursor-pointer hover:underline")}
    onClick={
    isClickable ? () => onSubtaskClick?.(todo.subtaskId as string) : undefined
    }>
    {todo.content}
    </span>
    {/* Token count and cost display */}
    {shouldShowCost && (
    <span className="flex items-center gap-2 text-xs text-vscode-descriptionForeground shrink-0">
    <span className="tabular-nums opacity-70">
    {formatLargeNumber(displayTokens)}
    </span>
    <span className="tabular-nums min-w-[45px] text-right">
    ${displayCost.toFixed(2)}
    </span>
    </span>
    )}
    </li>
    )
    })}
    </ul>
    )}
    </div>
    )
    }

Flow diagrams

Delegation → completion → todo write-back → UI

sequenceDiagram
  participant Model as Model
  participant Parent as Parent Task
  participant Provider as ClineProvider
  participant Child as Child Task
  participant UI as Webview UI

  Model->>Parent: tool_use new_task
  Parent->>Provider: delegateParentAndOpenChild(parentTaskId, ...)
  Provider->>Child: createTask() -> childTaskId
  Provider->>Provider: load todos (getLatestTodo)
  Provider->>Provider: set chosenTodo.subtaskId = childTaskId
  Provider->>Provider: persist user_edit_todos (saveTaskMessages)

  Model->>Child: tool_use attempt_completion
  Child->>Provider: reopenParentFromDelegation(parentTaskId, childTaskId)
  Provider->>Provider: load child HistoryItem (tokensIn/tokensOut/totalCost)
  Provider->>Provider: find todo where todo.subtaskId == childTaskId
  Provider->>Provider: write back todo.tokens + todo.cost
  Provider->>Provider: persist user_edit_todos (saveTaskMessages)

  UI->>UI: render TodoListDisplay
  UI->>UI: show todo.tokens + todo.cost
  UI->>UI: click navigates via todo.subtaskId
Loading

Why subtaskId (and not id) must exist

flowchart TD
  A[update_todo_list markdown] --> B[parseMarkdownChecklist]
  B --> C["id = md5(content + status)"]
  C --> D{status changes?}
  D -- yes --> E[id changes]
  E --> F[link by todo.id breaks]
  F --> G[Need stable field: todo.subtaskId = childTaskId]
Loading

Testing

  • UI: TodoListDisplay.spec.tsx
  • Tool: updateTodoListTool.spec.ts
  • Delegation/resume flows:
    • provider-delegation.spec.ts
    • history-resume-delegation.spec.ts

Related to: #5376

- 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: RooCodeInc#5376
@taltas taltas requested review from cte, jr and mrubens as code owners January 16, 2026 03:36
@dosubot dosubot bot added size:XL This PR changes 500-999 lines, ignoring generated files. Enhancement New feature or request labels Jan 16, 2026
@roomote
Copy link
Contributor

roomote bot commented Jan 16, 2026

Rooviewer Clock   See task on Roo Cloud

Review complete. All previously flagged issues have been resolved. The new task cost breakdown utilities use integer micros for stable aggregation, which is a solid approach. No new issues found.

  • Remove investigation/analysis markdown files (ROOCODE_ARCHITECTURE_ANALYSIS.md, SUBTASK_COST_AGGREGATION_ANALYSIS.md) from the repository root
Previous reviews

Mention @roomote in a comment to request specific changes to this pull request or fix all unresolved issues.

* - Stripping common task prefixes (Subtask N:, ## Task:, Task N:)
* - Removing trailing ellipsis from truncated strings
*/
function normalizeForComparison(str: string): string {
Copy link
Contributor Author

@taltas taltas Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like a bit of bad hack to try and match the task's that already are printed, I need to look into this deeper to find a better way

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ha, jinx!

@dosubot dosubot bot added size:XXL This PR changes 1000+ lines, ignoring generated files. and removed size:XL This PR changes 500-999 lines, ignoring generated files. labels Jan 16, 2026
@taltas taltas force-pushed the feat/issue-5376-subtask-cost-breakdown branch from 02c2586 to e18382d Compare January 16, 2026 15:07
@dosubot dosubot bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:XXL This PR changes 1000+ lines, ignoring generated files. labels Jan 16, 2026
@taltas
Copy link
Contributor Author

taltas commented Jan 16, 2026

@roomote give me some feedback on this PR

@roomote
Copy link
Contributor

roomote bot commented Jan 16, 2026

Fixaroo Clock   See task on Roo Cloud

Verified the reported issue. The investigation/analysis markdown files (ROOCODE_ARCHITECTURE_ANALYSIS.md, SUBTASK_COST_AGGREGATION_ANALYSIS.md) do not exist in this branch, the PR diff, or the git history. No action needed - the issue appears to have already been resolved or was a false positive from the reviewer.

@dosubot dosubot bot added size:XXL This PR changes 1000+ lines, ignoring generated files. and removed size:XL This PR changes 500-999 lines, ignoring generated files. labels Jan 16, 2026
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
Copy link
Contributor Author

@taltas taltas Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added subtaskId to the todo item so we have a stable link to the spawned child task for cost/token attribution when returning to the parent. The todo item id is not stable across status transitions because it’s derived from the todo text + status (see parseMarkdownChecklist() and the hash input at src/core/tools/UpdateTodoListTool.ts:242-245). As a result, using the todo id to reconnect to the subtask would break when the checkbox state changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement New feature or request size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

Status: Triage

Development

Successfully merging this pull request may close these issues.

2 participants