Skip to content
2 changes: 2 additions & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ export const clineMessageSchema = z.object({
* Present when `say: "sliding_window_truncation"`.
*/
contextTruncation: contextTruncationSchema.optional(),
// Older delegation messages lack this stamp and require positional link resolution.
childTaskId: z.string().optional(),
isProtected: z.boolean().optional(),
apiProtocol: z.union([z.literal("openai"), z.literal("anthropic")]).optional(),
isAnswered: z.boolean().optional(),
Expand Down
254 changes: 254 additions & 0 deletions src/__tests__/ClineProvider.subtask-links.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { clineMessageSchema, type ClineMessage, type HistoryItem } from "@roo-code/types"

vi.mock("../core/task-persistence/taskMessages", async (importOriginal) => ({
...(await importOriginal<typeof import("../core/task-persistence/taskMessages")>()),
readTaskMessages: vi.fn(),
}))
vi.mock("../core/task-persistence", async (importOriginal) => ({
...(await importOriginal<typeof import("../core/task-persistence")>()),
readApiMessages: vi.fn().mockResolvedValue([]),
saveApiMessages: vi.fn(async ({ messages }: { messages: unknown[] }) => messages),
saveTaskMessages: vi.fn(async ({ messages }: { messages: ClineMessage[] }) => messages),
}))
Comment thread
simurg79 marked this conversation as resolved.
Resolved

import { ClineProvider } from "../core/webview/ClineProvider"
import { readTaskMessages } from "../core/task-persistence/taskMessages"
import { saveTaskMessages } from "../core/task-persistence"
import { makeProviderStub } from "./helpers/provider-stub"

function newTask(ts: number, childTaskId?: string, content = "Do work"): ClineMessage {
return {
ts,
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "newTask", mode: "code", content }),
...(childTaskId ? { childTaskId } : {}),
}
}

function makeLinkProvider() {
const parentHistory: HistoryItem = {
id: "parent",
number: 1,
ts: 1,
task: "Parent",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
status: "active",
}
const records = new Map<string, HistoryItem>([["parent", parentHistory]])
const taskHistoryStore = {
get: vi.fn((taskId: string) => records.get(taskId)),
invalidate: vi.fn().mockResolvedValue(undefined),
atomicReadAndUpdate: vi.fn(async (taskId: string, update: (item: HistoryItem) => HistoryItem) => {
records.set(taskId, update(records.get(taskId)!))
}),
atomicUpdatePair: vi.fn(
async (
firstId: string,
secondId: string,
firstUpdate: (item: HistoryItem) => HistoryItem,
secondUpdate: (item: HistoryItem) => HistoryItem,
) => {
records.set(firstId, firstUpdate(records.get(firstId)!))
records.set(secondId, secondUpdate(records.get(secondId)!))
},
),
}
const parent = {
taskId: "parent",
apiConfiguration: {},
getTaskApiConfigName: vi.fn().mockResolvedValue("default"),
getTaskMode: vi.fn().mockResolvedValue("code"),
flushPendingToolResultsToHistory: vi.fn().mockResolvedValue(true),
}
const child = { taskId: "child", run: vi.fn().mockResolvedValue(undefined) }
const reopenedParent = {
taskId: "parent",
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}
const provider = makeProviderStub({
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
getCurrentTask: vi.fn(() => parent),
getTaskWithId: vi.fn(async (taskId: string) => ({ historyItem: records.get(taskId) })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTask: vi.fn().mockResolvedValue(child),
createTaskWithHistoryItem: vi.fn().mockResolvedValue(reopenedParent),
deleteTaskWithId: vi.fn().mockResolvedValue(undefined),
emit: vi.fn(),
log: vi.fn(),
taskHistoryStore,
})
return { provider, records, taskHistoryStore, child, reopenedParent }
}

const delegation = { parentTaskId: "parent", message: "Do work", initialTodos: [], mode: "code" }

describe("persisted subtask links", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(readTaskMessages).mockResolvedValue([])
})

it.each([undefined, "child"])("keeps optional child identity through schema validation: %s", (childTaskId) => {
expect(clineMessageSchema.parse(newTask(1, childTaskId)).childTaskId).toBe(childTaskId)
})

it("stamps only the latest ask after commit and before the child runs", async () => {
const messages: ClineMessage[] = [
newTask(1),
newTask(2),
{ ts: 3, type: "say", say: "text", text: "Working" },
{ ts: 4, type: "ask", ask: "tool", text: "invalid JSON" },
{ ts: 5, type: "ask", ask: "tool", text: JSON.stringify({ tool: "readFile" }) },
]
vi.mocked(readTaskMessages).mockResolvedValue(messages)
const { provider, taskHistoryStore, child } = makeLinkProvider()
await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, delegation)

expect(messages[0].childTaskId).toBeUndefined()
expect(messages[1].childTaskId).toBe("child")
expect(saveTaskMessages).toHaveBeenCalledWith({
messages,
taskId: "parent",
globalStoragePath: "/storage",
merge: true,
})
const saveOrder = vi.mocked(saveTaskMessages).mock.invocationCallOrder[0]
expect(taskHistoryStore.atomicReadAndUpdate.mock.invocationCallOrder[0]).toBeLessThan(saveOrder)
expect(saveOrder).toBeLessThan(child.run.mock.invocationCallOrder[0])
expect(readTaskMessages).toHaveBeenCalledWith({ taskId: "parent", globalStoragePath: "/storage" })
})

it.each<ClineMessage>([
{ ts: 3, type: "say", say: "text", text: newTask(0).text },
{ ts: 3, type: "ask", ask: "followup", text: newTask(0).text },
{ ts: 3, type: "ask", ask: "tool", text: "null" },
{ ts: 3, type: "ask", ask: "tool", text: JSON.stringify({ tool: "readFile", content: "Do work" }) },
])("skips a newer non-delegation message and stamps the delegation ask: %j", async (newer) => {
const messages = [newTask(1), newer]
vi.mocked(readTaskMessages).mockResolvedValue(messages)
const { provider } = makeLinkProvider()
await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, delegation)
expect(messages[0].childTaskId).toBe("child")
expect(newer.childTaskId).toBeUndefined()
})

it.each([{ tool: "newTask" }, { tool: "newTask", content: 42 }])(
"does not stamp or log a newest ask without string content: %j",
async (tool) => {
const messages: ClineMessage[] = [{ ts: 1, type: "ask", ask: "tool", text: JSON.stringify(tool) }]
vi.mocked(readTaskMessages).mockResolvedValue(messages)
const { provider } = makeLinkProvider()
await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, delegation)
expect(messages[0].childTaskId).toBeUndefined()
expect(provider.log).not.toHaveBeenCalledWith(expect.stringContaining("Failed to save child link"))
},
)

it.each([{ messages: [] }, { messages: [newTask(1), newTask(2, "existing-child")] }])(
"does not write or stamp older asks when no pending delegation exists: %j",
async ({ messages }) => {
vi.mocked(readTaskMessages).mockResolvedValue(messages)
const { provider } = makeLinkProvider()
await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, delegation)
expect(saveTaskMessages).not.toHaveBeenCalled()
expect(messages[0]?.childTaskId).toBeUndefined()
},
)

it.each([
{ askContent: "Do work", delegatedMessage: "Do work", stamped: true },
{ askContent: "See \\\\@file", delegatedMessage: "See \\@file", stamped: true },
{ askContent: "Other work", delegatedMessage: "Do work", stamped: false },
])("stamps the newest ask only when it matches the delegation: %j", async (scenario) => {
const { askContent, delegatedMessage, stamped } = scenario
const messages = [newTask(1, undefined, delegatedMessage), newTask(2, undefined, askContent)]
vi.mocked(readTaskMessages).mockResolvedValue(messages)
const { provider, child } = makeLinkProvider()
await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, {
...delegation,
message: delegatedMessage,
})

expect(messages[0].childTaskId).toBeUndefined()
expect(messages[1].childTaskId).toBe(stamped ? "child" : undefined)
expect(vi.mocked(saveTaskMessages).mock.calls).toHaveLength(stamped ? 1 : 0)
expect(child.run).toHaveBeenCalledOnce()
})

it("does not stamp a rejected delegation", async () => {
const { provider, taskHistoryStore } = makeLinkProvider()
taskHistoryStore.atomicReadAndUpdate.mockRejectedValueOnce(new Error("commit rejected"))
await expect(ClineProvider.prototype.delegateParentAndOpenChild.call(provider, delegation)).rejects.toThrow(
"commit rejected",
)
expect(readTaskMessages).not.toHaveBeenCalled()
expect(saveTaskMessages).not.toHaveBeenCalled()
})

it.each(["read", "write"])("starts the committed child even if link %s fails", async (operation) => {
vi.mocked(readTaskMessages).mockResolvedValue([newTask(1)])
if (operation === "read") vi.mocked(readTaskMessages).mockRejectedValueOnce(new Error("read failed"))
else vi.mocked(saveTaskMessages).mockRejectedValueOnce(new Error("write failed"))
const { provider, child } = makeLinkProvider()
await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, delegation)
expect(child.run).toHaveBeenCalledOnce()
expect(provider.log).toHaveBeenCalledWith(expect.stringContaining("Failed to save child link"))
})

it.each([undefined, "child", "earlier-child"])(
"stamps resumed results without conflating identical summaries from child %s",
async (previousChildId) => {
const { provider, records, reopenedParent } = makeLinkProvider()
const parent = records.get("parent")!
records.set("parent", { ...parent, status: "delegated", awaitingChildId: "child", childIds: ["child"] })
records.set("child", { ...parent, id: "child", parentTaskId: "parent" })
const previous: ClineMessage = {
ts: 1,
type: "say",
say: "subtask_result",
text: "Done",
childTaskId: previousChildId,
}
vi.mocked(readTaskMessages).mockResolvedValue([previous])

await ClineProvider.prototype.reopenParentFromDelegation.call(provider, {
parentTaskId: "parent",
childTaskId: "child",
completionResultSummary: "Done",
})

const saved = vi.mocked(saveTaskMessages).mock.calls[0][0].messages
expect(saved).toHaveLength(previousChildId === "earlier-child" ? 2 : 1)
expect(saved.at(-1)).toMatchObject({ say: "subtask_result", childTaskId: "child" })
if (previousChildId === "earlier-child") expect(saved[0].childTaskId).toBe("earlier-child")
expect(reopenedParent.overwriteClineMessages).toHaveBeenCalledWith(saved, false)
},
)

it.each<ClineMessage>([
{ ts: 1, type: "say", say: "text", text: "Done" },
{ ts: 1, type: "say", say: "subtask_result", text: "Earlier result", childTaskId: "child" },
])("appends the result after a non-duplicate last message: %j", async (previous) => {
const { provider, records } = makeLinkProvider()
const parent = records.get("parent")!
records.set("parent", { ...parent, status: "delegated", awaitingChildId: "child", childIds: ["child"] })
records.set("child", { ...parent, id: "child", parentTaskId: "parent" })
vi.mocked(readTaskMessages).mockResolvedValue([previous])

await ClineProvider.prototype.reopenParentFromDelegation.call(provider, {
parentTaskId: "parent",
childTaskId: "child",
completionResultSummary: "Done",
})

const saved = vi.mocked(saveTaskMessages).mock.calls[0][0].messages
expect(saved).toHaveLength(2)
expect(saved.at(-1)).toMatchObject({ say: "subtask_result", childTaskId: "child" })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ describe("mergeClineMessageSnapshots", () => {
])
})

it("keeps a disk childTaskId when the matching incoming message omits the key", () => {
const result = mergeClineMessageSnapshots(
[{ messageId: "msg-1", ts: 1, type: "say", say: "subtask_result", text: "done", childTaskId: "child-1" }],
[{ messageId: "msg-1", ts: 1, type: "say", say: "subtask_result", text: "done (updated)" }],
)

expect(result).toEqual([expect.objectContaining({ ts: 1, text: "done (updated)", childTaskId: "child-1" })])
})

it("returns incoming data when either snapshot is not an array", () => {
expect(mergeClineMessageSnapshots(null, [{ ts: 1 }])).toEqual([{ ts: 1 }])
expect(mergeClineMessageSnapshots([], "invalid")).toBe("invalid")
Expand Down
44 changes: 43 additions & 1 deletion src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4259,6 +4259,44 @@ export class ClineProvider
throw err
}

// Stamp only committed delegations; rejected ones must not link to a rolled-back child.
try {
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
const parentMessages = await readTaskMessages({ taskId: parentTaskId, globalStoragePath })
for (let messageIndex = parentMessages.length - 1; messageIndex >= 0; messageIndex--) {
const candidate = parentMessages[messageIndex]
if (candidate.ask !== "tool" || !candidate.text) continue

let tool: { tool?: unknown; content?: unknown } | null
try {
tool = JSON.parse(candidate.text) as { tool?: unknown; content?: unknown } | null
} catch {
continue
}
if (tool?.tool !== "newTask") continue

// Never walk past the newest delegation and stamp an earlier rejected ask.
// startSubtask delegates without an ask, so the newest ask may belong to another delegation.
// NewTaskTool shows the raw content but delegates with one level of "\\@" unescaped.
const matchesDelegation =
typeof tool.content === "string" && tool.content.replace(/\\\\@/g, "\\@") === message
if (matchesDelegation && !candidate.childTaskId) {
candidate.childTaskId = child.taskId
await saveTaskMessages({
messages: parentMessages,
taskId: parentTaskId,
globalStoragePath,
merge: true,
})
}
break
}
} catch (error) {
this.log(
`[delegateParentAndOpenChild] Failed to save child link for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`,
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// 6) Start the child task now that parent metadata is safely persisted.
scheduleTask(this.taskScheduler, child, "delegateParentAndOpenChild")

Expand Down Expand Up @@ -4351,14 +4389,18 @@ export class ClineProvider
say: "subtask_result",
text: completionResultSummary,
ts,
childTaskId,
}
const lastParentClineMessage = parentClineMessages.at(-1)
if (
lastParentClineMessage?.type !== "say" ||
lastParentClineMessage.say !== "subtask_result" ||
lastParentClineMessage.text !== completionResultSummary
lastParentClineMessage.text !== completionResultSummary ||
(lastParentClineMessage.childTaskId !== undefined && lastParentClineMessage.childTaskId !== childTaskId)
) {
parentClineMessages.push(subtaskUiMessage)
} else {
lastParentClineMessage.childTaskId = childTaskId
}
parentClineMessages = await saveTaskMessages({
messages: parentClineMessages,
Expand Down
Loading
Loading