Skip to content

Commit 677fa60

Browse files
allquixoticoz-agent
andcommitted
Fix delegated task parent resolution
Resolve delegated new_task parents by invoking task id instead of visible task state, scope delegation mode/profile switches away from unrelated current tasks, and bump CRC to 3.53.14. Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent eb2b902 commit 677fa60

4 files changed

Lines changed: 129 additions & 21 deletions

File tree

src/__tests__/provider-delegation.spec.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
4242
const provider = {
4343
emit: providerEmit,
4444
getCurrentTask: vi.fn(() => parentTask),
45+
getTaskById: vi.fn((id: string) => (id === "parent-1" ? parentTask : undefined)),
4546
removeClineFromStack,
4647
createTask,
4748
getTaskWithId,
@@ -63,6 +64,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
6364

6465
// Invariant: parent closed before child creation
6566
expect(removeClineFromStack).toHaveBeenCalledTimes(1)
67+
expect(removeClineFromStack).toHaveBeenCalledWith({ taskId: "parent-1", skipDelegationRepair: true })
6668
// Child task is created with startTask: false and initialStatus: "active"
6769
expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, {
6870
initialTodos: [],
@@ -92,7 +94,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
9294
expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1")
9395

9496
// Mode switch
95-
expect(handleModeSwitch).toHaveBeenCalledWith("code")
97+
expect(handleModeSwitch).toHaveBeenCalledWith("code", { updateCurrentTask: false })
9698
})
9799

98100
it("calls child.start() only after parent metadata is persisted (no race condition)", async () => {
@@ -124,6 +126,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
124126
const provider = {
125127
emit: vi.fn(),
126128
getCurrentTask: vi.fn(() => parentTask),
129+
getTaskById: vi.fn((id: string) => (id === "parent-1" ? parentTask : undefined)),
127130
removeClineFromStack,
128131
createTask,
129132
getTaskWithId,
@@ -142,4 +145,80 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
142145
// Verify ordering: createTask → updateTaskHistory → child.start
143146
expect(callOrder).toEqual(["createTask", "updateTaskHistory", "child.start"])
144147
})
148+
149+
it("uses the invoking parent id when the current visible task differs", async () => {
150+
const parentTask = {
151+
taskId: "parent-1",
152+
emit: vi.fn(),
153+
flushPendingToolResultsToHistory: vi.fn().mockResolvedValue(true),
154+
} as any
155+
const visibleTask = { taskId: "visible-other", emit: vi.fn() } as any
156+
const childStart = vi.fn()
157+
const updateTaskHistory = vi.fn()
158+
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
159+
const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart })
160+
const handleModeSwitch = vi.fn().mockResolvedValue(undefined)
161+
const getTaskWithId = vi.fn().mockImplementation(async (id: string) => {
162+
if (id === "parent-1") {
163+
return {
164+
historyItem: {
165+
id: "parent-1",
166+
task: "Parent",
167+
tokensIn: 0,
168+
tokensOut: 0,
169+
totalCost: 0,
170+
childIds: [],
171+
},
172+
}
173+
}
174+
return {
175+
historyItem: {
176+
id: "child-1",
177+
task: "Do something",
178+
tokensIn: 0,
179+
tokensOut: 0,
180+
totalCost: 0,
181+
},
182+
}
183+
})
184+
185+
const provider = {
186+
emit: vi.fn(),
187+
getCurrentTask: vi.fn(() => visibleTask),
188+
getTaskById: vi.fn((id: string) =>
189+
id === "parent-1" ? parentTask : id === "visible-other" ? visibleTask : undefined,
190+
),
191+
removeClineFromStack,
192+
createTask,
193+
getTaskWithId,
194+
updateTaskHistory,
195+
handleModeSwitch,
196+
log: vi.fn(),
197+
} as unknown as ClineProvider
198+
199+
const child = await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, {
200+
parentTaskId: "parent-1",
201+
message: "Do something",
202+
initialTodos: [],
203+
mode: "code",
204+
})
205+
206+
expect(child.taskId).toBe("child-1")
207+
expect(removeClineFromStack).toHaveBeenCalledWith({ taskId: "parent-1", skipDelegationRepair: true })
208+
expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, {
209+
initialTodos: [],
210+
initialStatus: "active",
211+
startTask: false,
212+
})
213+
expect(updateTaskHistory).toHaveBeenCalledWith(
214+
expect.objectContaining({
215+
id: "parent-1",
216+
status: "delegated",
217+
delegatedToId: "child-1",
218+
awaitingChildId: "child-1",
219+
}),
220+
)
221+
expect(handleModeSwitch).toHaveBeenCalledWith("code", { updateCurrentTask: false })
222+
expect(childStart).toHaveBeenCalledTimes(1)
223+
})
145224
})

src/core/webview/ClineProvider.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1635,8 +1635,9 @@ export class ClineProvider
16351635
* Handle switching to a new mode, including updating the associated API configuration
16361636
* @param newMode The mode to switch to
16371637
*/
1638-
public async handleModeSwitch(newMode: Mode) {
1639-
const task = this.getCurrentTask()
1638+
public async handleModeSwitch(newMode: Mode, options: { updateCurrentTask?: boolean } = {}) {
1639+
const { updateCurrentTask = true } = options
1640+
const task = updateCurrentTask ? this.getCurrentTask() : undefined
16401641

16411642
if (task) {
16421643
task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode)
@@ -1698,7 +1699,7 @@ export class ClineProvider
16981699
const hasActualSettings = !!fullProfile.apiProvider
16991700

17001701
if (hasActualSettings) {
1701-
await this.activateProviderProfile({ name: profile.name })
1702+
await this.activateProviderProfile({ name: profile.name }, { updateCurrentTask })
17021703
} else {
17031704
// The task will continue with the current/default configuration.
17041705
}
@@ -1881,12 +1882,13 @@ export class ClineProvider
18811882

18821883
async activateProviderProfile(
18831884
args: { name: string } | { id: string },
1884-
options?: { persistModeConfig?: boolean; persistTaskHistory?: boolean },
1885+
options?: { persistModeConfig?: boolean; persistTaskHistory?: boolean; updateCurrentTask?: boolean },
18851886
) {
18861887
const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args)
18871888

18881889
const persistModeConfig = options?.persistModeConfig ?? true
18891890
const persistTaskHistory = options?.persistTaskHistory ?? true
1891+
const updateCurrentTask = options?.updateCurrentTask ?? true
18901892

18911893
// See `upsertProviderProfile` for a description of what this is doing.
18921894
await Promise.all([
@@ -1901,13 +1903,15 @@ export class ClineProvider
19011903
await this.providerSettingsManager.setModeConfig(mode, id)
19021904
}
19031905

1904-
// Change the provider for the current task.
1905-
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
1906+
if (updateCurrentTask) {
1907+
// Change the provider for the current task.
1908+
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
19061909

1907-
// Update the current task's sticky provider profile, unless this activation is
1908-
// being used purely as a non-persisting restoration (e.g., reopening a task from history).
1909-
if (persistTaskHistory) {
1910-
await this.persistStickyProviderProfileToCurrentTask(name)
1910+
// Update the current task's sticky provider profile, unless this activation is
1911+
// being used purely as a non-persisting restoration (e.g., reopening a task from history).
1912+
if (persistTaskHistory) {
1913+
await this.persistStickyProviderProfileToCurrentTask(name)
1914+
}
19111915
}
19121916

19131917
await this.postStateToWebview()
@@ -3515,7 +3519,7 @@ export class ClineProvider
35153519
/**
35163520
* Delegate parent task and open child task.
35173521
*
3518-
* - Enforce single-open invariant
3522+
* - Resolve the invoking parent by id, independent of current visible selection
35193523
* - Persist parent delegation metadata
35203524
* - Emit TaskDelegated (task-level; API forwards to provider/bridge)
35213525
* - Create child as sole active and switch mode to child's mode
@@ -3530,14 +3534,14 @@ export class ClineProvider
35303534

35313535
// Metadata-driven delegation is always enabled
35323536

3533-
// 1) Get parent (must be current task)
3534-
const parent = this.getCurrentTask()
3537+
// 1) Get the exact live parent that invoked new_task. Do not use getCurrentTask()
3538+
// here: the visible/current task can change while approval or async state work is
3539+
// pending, and rejecting on that stale selection blocks valid delegation.
3540+
const parent = this.getTaskById(parentTaskId)
35353541
if (!parent) {
3536-
throw new Error("[delegateParentAndOpenChild] No current task")
3537-
}
3538-
if (parent.taskId !== parentTaskId) {
3542+
const current = this.getCurrentTask()
35393543
throw new Error(
3540-
`[delegateParentAndOpenChild] Parent mismatch: expected ${parentTaskId}, current ${parent.taskId}`,
3544+
`[delegateParentAndOpenChild] Parent task not open: expected ${parentTaskId}, current ${current?.taskId ?? "none"}`,
35413545
)
35423546
}
35433547
// 2) Flush pending tool results to API history BEFORE disposing the parent.
@@ -3578,7 +3582,7 @@ export class ClineProvider
35783582
// This ensures we never have >1 tasks open at any time during delegation.
35793583
// Await abort completion to ensure clean disposal and prevent unhandled rejections.
35803584
try {
3581-
await this.removeClineFromStack({ skipDelegationRepair: true })
3585+
await this.removeClineFromStack({ taskId: parentTaskId, skipDelegationRepair: true })
35823586
} catch (error) {
35833587
this.log(
35843588
`[delegateParentAndOpenChild] Error during parent disposal (non-fatal): ${
@@ -3593,7 +3597,7 @@ export class ClineProvider
35933597
// The mode switch must happen before createTask() because the Task constructor
35943598
// initializes its mode from provider.getState() during initializeTaskMode().
35953599
try {
3596-
await this.handleModeSwitch(mode as any)
3600+
await this.handleModeSwitch(mode as any, { updateCurrentTask: false })
35973601
} catch (e) {
35983602
this.log(
35993603
`[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${

src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,31 @@ describe("ClineProvider - Sticky Mode", () => {
434434
}),
435435
)
436436
})
437+
438+
it("should skip current task metadata when requested", async () => {
439+
const mockTask = {
440+
taskId: "test-task-id",
441+
taskMode: "code",
442+
emit: vi.fn(),
443+
saveClineMessages: vi.fn(),
444+
clineMessages: [],
445+
apiConversationHistory: [],
446+
updateApiConfiguration: vi.fn(),
447+
}
448+
449+
await provider.addClineToStack(mockTask as any)
450+
451+
const updateTaskHistorySpy = vi
452+
.spyOn(provider, "updateTaskHistory")
453+
.mockImplementation(() => Promise.resolve([]))
454+
455+
await provider.handleModeSwitch("architect", { updateCurrentTask: false })
456+
457+
expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect")
458+
expect(updateTaskHistorySpy).not.toHaveBeenCalled()
459+
expect(mockTask.emit).not.toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect")
460+
expect((mockTask as any)._taskMode).toBeUndefined()
461+
})
437462
})
438463

439464
describe("createTaskWithHistoryItem", () => {

src/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "%extension.displayName%",
44
"description": "%extension.description%",
55
"publisher": "allquixotic",
6-
"version": "3.53.13",
6+
"version": "3.53.14",
77
"icon": "assets/icons/icon.png",
88
"galleryBanner": {
99
"color": "#617A91",

0 commit comments

Comments
 (0)