Skip to content
Merged
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
206 changes: 206 additions & 0 deletions docs/analysis/zoo-code-import-decision.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,13 @@ export const clineMessageSchema = z.object({
* Present when `say: "sliding_window_truncation"`.
*/
contextTruncation: contextTruncationSchema.optional(),
/**
* Id of the child task this delegation row refers to, stamped when the child is
* created (`ask: "tool"` / `newTask`) or when its result is injected
* (`say: "subtask_result"`). Absent on history written before this field existed,
* which falls back to positional matching against `HistoryItem.childIds`.
*/
childTaskId: z.string().optional(),
isProtected: z.boolean().optional(),
apiProtocol: z.union([z.literal("openai"), z.literal("anthropic")]).optional(),
isAnswered: z.boolean().optional(),
Expand Down
53 changes: 53 additions & 0 deletions src/__tests__/history-resume-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,4 +784,57 @@ describe("History resume delegation - parent metadata transitions", () => {
}),
)
})

it("stamps each injected subtask_result with its own child id across sequential delegations", async () => {
const makeProvider = () =>
({
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "p1",
status: "delegated",
childIds: ["c1", "c2"],
ts: 100,
task: "Parent",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}),
emit: vi.fn(),
getCurrentTask: vi.fn(() => ({ taskId: "c1" })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTaskWithHistoryItem: vi.fn().mockResolvedValue({
taskId: "p1",
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
}),
updateTaskHistory: vi.fn().mockResolvedValue([]),
}) as unknown as ClineProvider

vi.mocked(readApiMessages).mockResolvedValue([])

vi.mocked(readTaskMessages).mockResolvedValue([])
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(makeProvider(), {
parentTaskId: "p1",
childTaskId: "c1",
completionResultSummary: "First result",
})

const firstSavedMessages = vi.mocked(saveTaskMessages).mock.calls.at(-1)![0].messages
expect(firstSavedMessages.at(-1)).toEqual(expect.objectContaining({ say: "subtask_result", childTaskId: "c1" }))

vi.mocked(readTaskMessages).mockResolvedValue(firstSavedMessages)
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(makeProvider(), {
parentTaskId: "p1",
childTaskId: "c2",
completionResultSummary: "Second result",
})

const secondSavedMessages = vi.mocked(saveTaskMessages).mock.calls.at(-1)![0].messages
const stampedChildIds = secondSavedMessages
.filter((message) => message.say === "subtask_result")
.map((message) => message.childTaskId)

expect(stampedChildIds).toEqual(["c1", "c2"])
})
})
84 changes: 84 additions & 0 deletions src/__tests__/provider-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@

import { describe, it, expect, vi } from "vitest"
import { RooCodeEventName } from "@roo-code/types"

vi.mock("../core/task-persistence/taskMessages", () => ({
readTaskMessages: vi.fn().mockResolvedValue([]),
}))
vi.mock("../core/task-persistence", async (importOriginal) => ({
...(await importOriginal<object>()),
saveTaskMessages: vi.fn().mockResolvedValue(undefined),
}))

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

describe("ClineProvider.delegateParentAndOpenChild()", () => {
it("persists parent delegation metadata and emits TaskDelegated", async () => {
Expand Down Expand Up @@ -142,4 +153,77 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
// Verify ordering: createTask → updateTaskHistory → child.start
expect(callOrder).toEqual(["createTask", "updateTaskHistory", "child.start"])
})

it("stamps the parent's pending newTask message with the created child id", async () => {
const pendingNewTaskMessage = {
ts: 1000,
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "newTask", mode: "code", content: "Do something" }),
}
vi.mocked(readTaskMessages).mockResolvedValue([pendingNewTaskMessage] as any)

const provider = {
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
emit: vi.fn(),
getCurrentTask: vi.fn(() => ({ taskId: "parent-1", emit: vi.fn() })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTask: vi.fn().mockResolvedValue({ taskId: "child-1", start: vi.fn() }),
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: { id: "parent-1", task: "Parent", tokensIn: 0, tokensOut: 0, totalCost: 0, childIds: [] },
}),
updateTaskHistory: vi.fn(),
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
log: vi.fn(),
} as unknown as ClineProvider

await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, {
parentTaskId: "parent-1",
message: "Do something",
initialTodos: [],
mode: "code",
})

expect(saveTaskMessages).toHaveBeenCalledWith(
expect.objectContaining({
taskId: "parent-1",
messages: [expect.objectContaining({ ask: "tool", childTaskId: "child-1" })],
}),
)
})

it("does not re-stamp an earlier newTask message when the newest one is already stamped", async () => {
const newTaskMessage = (ts: number, childTaskId?: string) => ({
ts,
type: "ask",
ask: "tool",
text: JSON.stringify({ tool: "newTask", mode: "code", content: `Subtask ${ts}` }),
...(childTaskId ? { childTaskId } : {}),
})
vi.mocked(saveTaskMessages).mockClear()
vi.mocked(readTaskMessages).mockResolvedValue([newTaskMessage(1000), newTaskMessage(2000, "child-2")] as any)

const provider = {
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
emit: vi.fn(),
getCurrentTask: vi.fn(() => ({ taskId: "parent-1", emit: vi.fn() })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTask: vi.fn().mockResolvedValue({ taskId: "child-3", start: vi.fn() }),
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: { id: "parent-1", task: "Parent", tokensIn: 0, tokensOut: 0, totalCost: 0, childIds: [] },
}),
updateTaskHistory: vi.fn(),
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
log: vi.fn(),
} as unknown as ClineProvider

await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, {
parentTaskId: "parent-1",
message: "Do something",
initialTodos: [],
mode: "code",
})

expect(saveTaskMessages).not.toHaveBeenCalled()
})
})
54 changes: 53 additions & 1 deletion src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2879,7 +2879,58 @@ export class ClineProvider
startTask: false,
})

// 5) Persist parent delegation metadata BEFORE the child starts writing.
// 5) Stamp the child id onto the parent's pending `newTask` row so the UI can resolve
// the link from the message itself. Positional matching against `childIds` drifts
// whenever a delegation is rejected, aborted, or re-issued.
try {
const delegationStoragePath = this.contextProxy.globalStorageUri.fsPath
const parentMessages = await readTaskMessages({
taskId: parentTaskId,
globalStoragePath: delegationStoragePath,
})

if (Array.isArray(parentMessages)) {
for (let messageIndex = parentMessages.length - 1; messageIndex >= 0; messageIndex--) {
const candidate = parentMessages[messageIndex]

if (candidate.type !== "ask" || candidate.ask !== "tool") {
continue
}

let parsedTool: { tool?: string } | undefined
try {
parsedTool = candidate.text ? JSON.parse(candidate.text) : undefined
} catch {
parsedTool = undefined
}

if (parsedTool?.tool !== "newTask") {
continue
}

// Stop at the newest `newTask` row: it is the one being delegated now. Walking
// further back would stamp an earlier delegation with this child's id.
if (!candidate.childTaskId) {
candidate.childTaskId = child.taskId
await saveTaskMessages({
messages: parentMessages,
taskId: parentTaskId,
globalStoragePath: delegationStoragePath,
})
}

break
}
}
} catch (err) {
this.log(
`[delegateParentAndOpenChild] Failed to stamp childTaskId on parent ${parentTaskId}: ${
(err as Error)?.message ?? String(err)
}`,
)
}

// 6) Persist parent delegation metadata BEFORE the child starts writing.
try {
const { historyItem } = await this.getTaskWithId(parentTaskId)
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId]))
Expand Down Expand Up @@ -2958,6 +3009,7 @@ export class ClineProvider
say: "subtask_result",
text: completionResultSummary,
ts,
childTaskId,
}
parentClineMessages.push(subtaskUiMessage)
await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath })
Expand Down
4 changes: 2 additions & 2 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
"displayName": "%extension.displayName%",
"description": "%extension.description%",
"publisher": "RooVeterinaryInc",
"version": "3.53.3",
"version": "3.53.5",
"icon": "assets/icons/icon.png",
"galleryBanner": {
"color": "#617A91",
"theme": "dark"
},
"engines": {
"vscode": "^1.106.0",
"vscode": "^1.120.0",
"node": "20.19.2"
},
"author": {
Expand Down
84 changes: 69 additions & 15 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,67 @@ function getPreviousTodos(messages: ClineMessage[], currentMessageTs: number): a
return []
}

function isNewTaskAsk(message: ClineMessage): boolean {
if (message.type !== "ask" || message.ask !== "tool") {
return false
}

return safeJsonParse<ClineSayTool>(message.text)?.tool === "newTask"
}

// Resolves which child task a delegation row refers to.
//
// The message's own `childTaskId` is authoritative: it is stamped when the child is
// created. Positional matching against `childIds` is only a fallback for history
// written before that stamp existed, and drifts when a delegation was rejected or
// re-issued because `childIds` then holds fewer entries than there are `newTask` rows.
//
// In the fallback path, `childIds` is appended in delegation order, so the Nth
// `newTask` row maps to `childIds[N]`. A `subtask_result` row reports the outcome of
// the most recent preceding `newTask` row, so it resolves to that same id. Counting
// `newTask` rows up to (and including) the current row handles both cases, and stays
// correct when a `subtask_result` is separated from its `newTask` row by other
// messages such as `resume_task` or `user_feedback`.
function getDelegatedChildTaskId(
messages: ClineMessage[],
currentMessageTs: number,
childIds: string[],
): string | undefined {
const currentMessageIndex = messages.findIndex((msg) => msg.ts === currentMessageTs)

if (currentMessageIndex === -1) {
return undefined
}

const stampedChildTaskId = messages[currentMessageIndex].childTaskId

if (stampedChildTaskId) {
return stampedChildTaskId
}

// A `subtask_result` predating the stamp still resolves via its originating
// `newTask` row, which may itself carry the stamp.
let newTaskCount = 0
let precedingNewTaskChildId: string | undefined

for (let messageIndex = 0; messageIndex <= currentMessageIndex; messageIndex++) {
if (isNewTaskAsk(messages[messageIndex])) {
newTaskCount++
// Deliberately not retained across rows: an older row's stamp says nothing
// about this one, so an unstamped `newTask` row must fall through to `childIds`.
precedingNewTaskChildId = messages[messageIndex].childTaskId
}
}

if (newTaskCount === 0) {
return undefined
}

return messages[currentMessageIndex].say === "subtask_result" && precedingNewTaskChildId
? precedingNewTaskChildId
: childIds[newTaskCount - 1]
}

interface ChatRowProps {
message: ClineMessage
lastModifiedMessage?: ClineMessage
Expand Down Expand Up @@ -831,23 +892,11 @@ export const ChatRowContent = ({
</>
)
case "newTask":
// Find all newTask messages to determine which child task ID corresponds to this message
const newTaskMessages = clineMessages.filter((msg) => {
if (msg.type === "ask" && msg.ask === "tool") {
const t = safeJsonParse<ClineSayTool>(msg.text)
return t?.tool === "newTask"
}
return false
})
const thisNewTaskIndex = newTaskMessages.findIndex((msg) => msg.ts === message.ts)
const childIds = currentTaskItem?.childIds || []

// Only get the child task ID if this newTask has been approved (has a corresponding entry in childIds)
// This prevents showing a link to a previous task when the current newTask is still awaiting approval
// Note: We don't use delegatedToId here because it persists after child tasks complete and would
// incorrectly point to the previous task when a new newTask is awaiting approval
const childTaskId =
thisNewTaskIndex >= 0 && thisNewTaskIndex < childIds.length ? childIds[thisNewTaskIndex] : undefined
const childTaskId = getDelegatedChildTaskId(clineMessages, message.ts, currentTaskItem?.childIds || [])

// Check if the next message is a subtask_result - if so, don't show the button
// since the result is displayed right after this message
Expand Down Expand Up @@ -1021,8 +1070,13 @@ export const ChatRowContent = ({
/>
)
case "subtask_result":
// Get the child task ID that produced this result
const completedChildTaskId = currentTaskItem?.completedByChildId
// Get the child task ID that produced this result.
// `completedByChildId` only ever holds the most recently completed child, so it is
// used as a fallback for legacy history items that predate `childIds`. Relying on it
// alone made every subtask result in a task link to the last child task.
const completedChildTaskId =
getDelegatedChildTaskId(clineMessages, message.ts, currentTaskItem?.childIds || []) ??
currentTaskItem?.completedByChildId
return (
<div className="border-l border-muted-foreground/80 ml-2 pl-4 pt-2 pb-1 -mt-5">
<div style={headerStyle}>
Expand Down
Loading
Loading