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
2 changes: 2 additions & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export const clineSays = [
"sliding_window_truncation",
"codebase_search_result",
"user_edit_todos",
"system_update_todos",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This handles automatic, system-generated todo list updates for subtask cost tracking.

] as const

export const clineSaySchema = z.enum(clineSays)
Expand Down
6 changes: 6 additions & 0 deletions packages/types/src/todo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ 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
added: z.number().optional(),
removed: z.number().optional(),
})

export type TodoItem = z.infer<typeof todoItemSchema>
8 changes: 8 additions & 0 deletions packages/types/src/tool-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
16 changes: 16 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,22 @@ export interface ExtensionMessage {
totalCost: number
ownCost: number
childrenCost: number
ownAdded?: number
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the meta data structure

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
}[]
}
historyItem?: HistoryItem
}
Expand Down
291 changes: 287 additions & 4 deletions src/__tests__/history-resume-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
})
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -491,4 +500,278 @@ 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: "system_update_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 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)

// 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: "system_update_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 === "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
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()
}
})

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)
})
})
Loading