Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/types/src/todo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.

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>
8 changes: 8 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
47 changes: 46 additions & 1 deletion src/core/tools/UpdateTodoListTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -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++
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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[]
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
139 changes: 136 additions & 3 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -99,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"
Expand Down Expand Up @@ -1717,7 +1723,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) {
Expand Down Expand Up @@ -3177,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)
Expand Down Expand Up @@ -3218,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)

Expand Down Expand Up @@ -3255,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
Expand Down Expand Up @@ -3333,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",
Expand Down
Loading
Loading