Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
774a8f9
feat: display per-subtask cost breakdown in todos list
taltas Jan 16, 2026
e18382d
feat: link delegated subtasks to todos for cost breakdown
taltas Jan 16, 2026
6b3ec95
Add task cost breakdown utilities
taltas Jan 16, 2026
4c9dd28
fix: improve todo-subtask linking reliability with fallback anchors
taltas Jan 17, 2026
0a245c4
fix: eliminate spurious user edits and remove edit button from notifi…
taltas Jan 17, 2026
25848e6
fix: compute subtask tooltip total from todos
taltas Jan 17, 2026
5bead50
fix: hide system_update_todos messages from chat UI
taltas Jan 17, 2026
188a1c6
fix: preserve todo metadata on updateTodoList
taltas Jan 18, 2026
788a21a
feat(webview): add edit toggle for todo updates
taltas Jan 18, 2026
3445ca1
fix: await subtask usage persistence before roll-up
taltas Jan 19, 2026
235cffa
feat: track line changes for delegated subtasks
taltas Jan 19, 2026
ecadf2c
fix: include line totals in aggregatedCosts message
taltas Jan 19, 2026
13e2790
fix: preserve todo metrics and line-change display
taltas Jan 20, 2026
3900b3c
fix: preserve todo metadata across subtask updates
taltas Jan 20, 2026
4be5688
chore(i18n): set costs.own label to Task
taltas Jan 20, 2026
249737d
chore: remove debug logging from PR #10765
taltas Jan 21, 2026
e8bcb8c
refactor: extract utility functions from taskMetadata into shared mod…
taltas Jan 21, 2026
5750f0f
refactor: remove line change tracking from subtask system
taltas Jan 21, 2026
b2fed26
test: remove obsolete taskMetadata spec file
taltas Jan 21, 2026
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
Copy Markdown
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
171 changes: 171 additions & 0 deletions src/__tests__/history-resume-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
})
})
76 changes: 76 additions & 0 deletions src/__tests__/new-task-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
Loading
Loading