Roo-Code/src/__tests__/removeClineFromStack-delegation.spec.ts
davincidreams 862d263cb8 feat(swarm-p1): replace clineStack with tasks Map + concurrent spawn path
Replace the LIFO `clineStack: Task[]` with a flat `tasks: Map<string, Task>`
(+ `focusedTaskId`, `leaderTaskId`, `childCompletionHandlers`) so multiple
tasks can be registered simultaneously — the prerequisite for true concurrent
fan-out.

Key changes:
- ClineProvider: swap all 19 clineStack usages; add `getTaskById()`,
  `spawnConcurrentChildren()`, `resolveChildCompletion()`
- SpawnParallelTasksTool: add `concurrent?: boolean` flag; concurrent path
  awaits Promise.all via spawnConcurrentChildren instead of queue drain
- AttemptCompletionTool: detect concurrent vs sequential by checking whether
  parent is still alive in the tasks map; route to resolveChildCompletion
  (concurrent) or reopenParentFromDelegation (sequential)
- Tests: update three spec files that accessed clineStack directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 03:05:14 -04:00

287 lines
8.8 KiB
TypeScript

// npx vitest run __tests__/removeClineFromStack-delegation.spec.ts
import { describe, it, expect, vi } from "vitest"
import { ClineProvider } from "../core/webview/ClineProvider"
describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
/**
* Helper to build a minimal mock provider with a single task in the map.
* The task's parentTaskId and taskId are configurable.
*/
function buildMockProvider(opts: {
childTaskId: string
parentTaskId?: string
parentHistoryItem?: Record<string, any>
getTaskWithIdError?: Error
}) {
const childTask = {
taskId: opts.childTaskId,
instanceId: "inst-1",
parentTaskId: opts.parentTaskId,
emit: vi.fn(),
abortTask: vi.fn().mockResolvedValue(undefined),
}
const updateTaskHistory = vi.fn().mockResolvedValue([])
const getTaskWithId = opts.getTaskWithIdError
? vi.fn().mockRejectedValue(opts.getTaskWithIdError)
: vi.fn().mockImplementation(async (id: string) => {
if (id === opts.parentTaskId && opts.parentHistoryItem) {
return { historyItem: { ...opts.parentHistoryItem } }
}
throw new Error("Task not found")
})
const provider = {
tasks: new Map([[opts.childTaskId, childTask as any]]),
focusedTaskId: opts.childTaskId as string | undefined,
leaderTaskId: opts.childTaskId as string | undefined,
taskEventListeners: new Map(),
log: vi.fn(),
getTaskWithId,
updateTaskHistory,
}
return { provider, childTask, updateTaskHistory, getTaskWithId }
}
it("repairs parent metadata (delegated → active) when a delegated child is removed", async () => {
const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({
childTaskId: "child-1",
parentTaskId: "parent-1",
parentHistoryItem: {
id: "parent-1",
task: "Parent task",
ts: 1000,
number: 1,
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
status: "delegated",
awaitingChildId: "child-1",
delegatedToId: "child-1",
childIds: ["child-1"],
},
})
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
// Map should be empty after removal
expect(provider.tasks.size).toBe(0)
// Parent lookup should have been called
expect(getTaskWithId).toHaveBeenCalledWith("parent-1")
// Parent metadata should be repaired
expect(updateTaskHistory).toHaveBeenCalledTimes(1)
const updatedParent = updateTaskHistory.mock.calls[0][0]
expect(updatedParent).toEqual(
expect.objectContaining({
id: "parent-1",
status: "active",
awaitingChildId: undefined,
}),
)
// Log the repair
expect(provider.log).toHaveBeenCalledWith(expect.stringContaining("Repaired parent parent-1 metadata"))
})
it("does NOT modify parent metadata when the task has no parentTaskId (non-delegated)", async () => {
const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({
childTaskId: "standalone-1",
// No parentTaskId — this is a top-level task
})
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
// Map should be empty
expect(provider.tasks.size).toBe(0)
// No parent lookup or update should happen
expect(getTaskWithId).not.toHaveBeenCalled()
expect(updateTaskHistory).not.toHaveBeenCalled()
})
it("does NOT modify parent metadata when awaitingChildId does not match the popped child", async () => {
const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({
childTaskId: "child-1",
parentTaskId: "parent-1",
parentHistoryItem: {
id: "parent-1",
task: "Parent task",
ts: 1000,
number: 1,
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
status: "delegated",
awaitingChildId: "child-OTHER", // different child
delegatedToId: "child-OTHER",
childIds: ["child-OTHER"],
},
})
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
// Parent was looked up but should NOT be updated
expect(getTaskWithId).toHaveBeenCalledWith("parent-1")
expect(updateTaskHistory).not.toHaveBeenCalled()
})
it("does NOT modify parent metadata when parent status is not 'delegated'", async () => {
const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({
childTaskId: "child-1",
parentTaskId: "parent-1",
parentHistoryItem: {
id: "parent-1",
task: "Parent task",
ts: 1000,
number: 1,
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
status: "completed", // already completed
awaitingChildId: "child-1",
childIds: ["child-1"],
},
})
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
expect(getTaskWithId).toHaveBeenCalledWith("parent-1")
expect(updateTaskHistory).not.toHaveBeenCalled()
})
it("catches and logs errors during parent metadata repair without blocking the pop", async () => {
const { provider, childTask, updateTaskHistory, getTaskWithId } = buildMockProvider({
childTaskId: "child-1",
parentTaskId: "parent-1",
getTaskWithIdError: new Error("Storage unavailable"),
})
// Should NOT throw
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
// Map should still be empty (removal was not blocked)
expect(provider.tasks.size).toBe(0)
// The abort should still have been called
expect(childTask.abortTask).toHaveBeenCalledWith(true)
// Error should be logged as non-fatal
expect(provider.log).toHaveBeenCalledWith(
expect.stringContaining("Failed to repair parent metadata for parent-1 (non-fatal)"),
)
// No update should have been attempted
expect(updateTaskHistory).not.toHaveBeenCalled()
})
it("handles empty map gracefully", async () => {
const provider = {
tasks: new Map() as Map<string, any>,
focusedTaskId: undefined as string | undefined,
leaderTaskId: undefined as string | undefined,
taskEventListeners: new Map(),
log: vi.fn(),
getTaskWithId: vi.fn(),
updateTaskHistory: vi.fn(),
}
// Should not throw
await (ClineProvider.prototype as any).removeClineFromStack.call(provider)
expect(provider.tasks.size).toBe(0)
expect(provider.getTaskWithId).not.toHaveBeenCalled()
expect(provider.updateTaskHistory).not.toHaveBeenCalled()
})
it("skips delegation repair when skipDelegationRepair option is true", async () => {
const { provider, updateTaskHistory, getTaskWithId } = buildMockProvider({
childTaskId: "child-1",
parentTaskId: "parent-1",
parentHistoryItem: {
id: "parent-1",
task: "Parent task",
ts: 1000,
number: 1,
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
status: "delegated",
awaitingChildId: "child-1",
delegatedToId: "child-1",
childIds: ["child-1"],
},
})
// Call with skipDelegationRepair: true (as delegateParentAndOpenChild would)
await (ClineProvider.prototype as any).removeClineFromStack.call(provider, { skipDelegationRepair: true })
// Map should be empty after removal
expect(provider.tasks.size).toBe(0)
// Parent lookup should NOT have been called — repair was skipped entirely
expect(getTaskWithId).not.toHaveBeenCalled()
expect(updateTaskHistory).not.toHaveBeenCalled()
})
it("does NOT reset grandparent during A→B→C nested delegation transition", async () => {
// Scenario: A delegated to B, B is now delegating to C.
// delegateParentAndOpenChild() pops B via removeClineFromStack({ skipDelegationRepair: true }).
// Grandparent A should remain "delegated" — its metadata must not be repaired.
const grandparentHistory = {
id: "task-A",
task: "Grandparent task",
ts: 1000,
number: 1,
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
status: "delegated",
awaitingChildId: "task-B",
delegatedToId: "task-B",
childIds: ["task-B"],
}
const taskB = {
taskId: "task-B",
instanceId: "inst-B",
parentTaskId: "task-A",
emit: vi.fn(),
abortTask: vi.fn().mockResolvedValue(undefined),
}
const getTaskWithId = vi.fn().mockImplementation(async (id: string) => {
if (id === "task-A") {
return { historyItem: { ...grandparentHistory } }
}
throw new Error("Task not found")
})
const updateTaskHistory = vi.fn().mockResolvedValue([])
const provider = {
tasks: new Map([["task-B", taskB as any]]),
focusedTaskId: "task-B" as string | undefined,
leaderTaskId: "task-B" as string | undefined,
taskEventListeners: new Map(),
log: vi.fn(),
getTaskWithId,
updateTaskHistory,
}
// Simulate what delegateParentAndOpenChild does: pop B with skipDelegationRepair
await (ClineProvider.prototype as any).removeClineFromStack.call(provider, { skipDelegationRepair: true })
// B was removed
expect(provider.tasks.size).toBe(0)
// Grandparent A should NOT have been looked up or modified
expect(getTaskWithId).not.toHaveBeenCalled()
expect(updateTaskHistory).not.toHaveBeenCalled()
// Grandparent A's metadata remains intact (delegated, awaitingChildId: task-B)
// The caller (delegateParentAndOpenChild) will update A to point to C separately.
})
})