Skip to content

Commit 17109ab

Browse files
committed
fix: isolate fan-out task state during delegation
1 parent 5e899f3 commit 17109ab

7 files changed

Lines changed: 85 additions & 34 deletions

File tree

apps/vscode-e2e/src/suite/subtasks.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,11 +254,13 @@ suite("Roo Code Subtasks", function () {
254254
const priorModeApiConfigs = api.getConfiguration().modeApiConfigs ?? {}
255255
const parentProfileId = await api.upsertProfile("subtask-fanout-parent-profile", parentProfile, true)
256256
const childProfileId = await api.upsertProfile("subtask-fanout-child-profile", childProfile, false)
257+
assert.ok(parentProfileId, "Failed to create parent profile")
258+
assert.ok(childProfileId, "Failed to create child profile")
257259
await api.setConfiguration({
258260
modeApiConfigs: {
259261
...priorModeApiConfigs,
260-
code: parentProfileId!,
261-
ask: childProfileId!,
262+
code: parentProfileId,
263+
ask: childProfileId,
262264
},
263265
})
264266

src/__tests__/helpers/provider-stub.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ type PrivateProviderMethods = {
2323
restoreParentOrReleasePermit: (this: unknown, ...args: unknown[]) => unknown
2424
}
2525

26+
export function bindRestoreParentOrReleasePermit(provider: ClineProvider): void {
27+
const s = provider as unknown as ProviderStubFields
28+
const proto = ClineProvider.prototype as unknown as PrivateProviderMethods
29+
s.restoreParentOrReleasePermit = proto.restoreParentOrReleasePermit.bind(s)
30+
}
31+
2632
/**
2733
* Augments a plain stub object with the instance fields and bound methods that
2834
* ClineProvider methods read from `this` (runDelegationTransition,
@@ -53,6 +59,8 @@ export function makeProviderStub<T extends object>(stub: T): ClineProvider {
5359
s.runDelegationTransition ??= proto.runDelegationTransition.bind(s)
5460
s.removeClineFromStack ??= proto.removeClineFromStack.bind(s)
5561
s.evictCurrentTask ??= proto.evictCurrentTask.bind(s)
56-
s.restoreParentOrReleasePermit ??= proto.restoreParentOrReleasePermit.bind(s)
62+
if (!s.restoreParentOrReleasePermit) {
63+
bindRestoreParentOrReleasePermit(s as unknown as ClineProvider)
64+
}
5765
return s as unknown as ClineProvider
5866
}

src/__tests__/provider-delegation.spec.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,7 @@ import type { HistoryItem } from "@roo-code/types"
55
import { RooCodeEventName } from "@roo-code/types"
66
import { ClineProvider } from "../core/webview/ClineProvider"
77
import { TaskScheduler } from "../core/task/TaskScheduler"
8-
9-
/**
10-
* restoreParentOrReleasePermit is a private prototype method that plain
11-
* object-literal provider stubs don't have unless bound explicitly (same
12-
* reason removeClineFromStack/evictCurrentTask need binding in provider-stub.ts).
13-
*/
14-
function bindRestoreParentOrReleasePermit(provider: ClineProvider): void {
15-
type WithRestore = {
16-
restoreParentOrReleasePermit: (
17-
parentTaskId: string,
18-
fanOut: boolean,
19-
childReservedRelease: (() => void) | undefined,
20-
) => Promise<void>
21-
}
22-
;(provider as unknown as WithRestore).restoreParentOrReleasePermit = (
23-
ClineProvider.prototype as unknown as WithRestore
24-
).restoreParentOrReleasePermit.bind(provider)
25-
}
8+
import { bindRestoreParentOrReleasePermit } from "./helpers/provider-stub"
269

2710
const parentHistoryItem: HistoryItem = {
2811
id: "parent-1",

src/core/task/Task.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4021,14 +4021,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
40214021
const state = await this.providerRef.deref()?.getState()
40224022

40234023
const {
4024-
apiConfiguration,
40254024
autoApprovalEnabled,
40264025
requestDelaySeconds,
4027-
mode,
40284026
autoCondenseContext = true,
40294027
autoCondenseContextPercent = 100,
40304028
profileThresholds = {},
40314029
} = state ?? {}
4030+
// mode/apiConfiguration come from this task's own fields, not shared
4031+
// provider state — see getSystemPrompt() for why.
4032+
const mode = await this.getTaskMode()
4033+
const apiConfiguration = this.apiConfiguration
40324034

40334035
// Get condensing configuration for automatic triggers.
40344036
const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE

src/core/task/__tests__/Task.spec.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,57 @@ describe("Cline", () => {
599599
expect(Object.keys(cleanConversationHistory[0]!)).toEqual(["role", "content"])
600600
})
601601

602+
it("uses task-local mode and apiConfiguration in request metadata when provider state diverges", async () => {
603+
const taskApiConfiguration = {
604+
...mockApiConfig,
605+
apiProvider: providerIdentifiers.gemini,
606+
} as ProviderSettings
607+
608+
vi.spyOn(mockProvider, "getState").mockResolvedValue({
609+
mode: "ask",
610+
apiConfiguration: taskApiConfiguration,
611+
autoApprovalEnabled: true,
612+
requestDelaySeconds: 0,
613+
})
614+
615+
const cline = new Task({
616+
provider: mockProvider,
617+
apiConfiguration: taskApiConfiguration,
618+
task: "test task",
619+
startTask: false,
620+
})
621+
await cline.getTaskMode()
622+
vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt")
623+
vi.spyOn(cline.api, "getModel").mockReturnValue({
624+
id: requireDefined(mockApiConfig.apiModelId),
625+
info: { contextWindow: 200000, maxTokens: 4096 } as ModelInfo,
626+
})
627+
628+
vi.spyOn(mockProvider, "getState").mockResolvedValue({
629+
mode: "code",
630+
apiConfiguration: {
631+
...mockApiConfig,
632+
apiProvider: providerIdentifiers.anthropic,
633+
},
634+
autoApprovalEnabled: true,
635+
requestDelaySeconds: 0,
636+
})
637+
638+
const mockStream = (async function* () {
639+
yield { type: "text", text: "response" } as ApiStreamChunk
640+
})()
641+
const createMessageSpy = vi.spyOn(cline.api, "createMessage").mockReturnValue(mockStream)
642+
cline.apiConversationHistory = [
643+
{ role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() },
644+
]
645+
646+
await cline.attemptApiRequest(0).next()
647+
648+
const [, , metadata] = requireDefined(createMessageSpy.mock.calls[0])
649+
expect(metadata?.mode).toBe("ask")
650+
expect(metadata?.allowedFunctionNames).toBeDefined()
651+
})
652+
602653
it("should shape image blocks for API compatibility before request construction", async () => {
603654
const conversationHistory = [
604655
{

src/core/task/__tests__/delegation-concurrent.spec.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe("delegateParentAndOpenChild — fan-out (Story 3.2b)", () => {
147147
void scheduler.schedule(makeParent({ taskId: "occupant-2" }), () => new Promise<void>(() => {}))
148148
// Poll the deterministic signal instead of assuming a fixed microtask
149149
// depth for sem.acquire() to settle.
150-
while (scheduler.available > 0) {
150+
for (let i = 0; i < 10 && scheduler.available > 0; i++) {
151151
await Promise.resolve()
152152
}
153153

@@ -263,12 +263,16 @@ describe("delegateParentAndOpenChild — fan-out (Story 3.2b)", () => {
263263
createTaskWithHistoryItem,
264264
})
265265

266-
await expect(callDelegate(provider)).rejects.toThrow(createTaskError)
266+
try {
267+
await expect(callDelegate(provider)).rejects.toThrow(createTaskError)
267268

268-
expect(createTaskWithHistoryItem).toHaveBeenCalledTimes(2)
269-
expect(showErrorMessage).toHaveBeenCalledWith(
270-
"Failed to restore the parent task after subtask creation failed. Reopen the task from history to continue.",
271-
)
269+
expect(createTaskWithHistoryItem).toHaveBeenCalledTimes(2)
270+
expect(showErrorMessage).toHaveBeenCalledWith(
271+
"Failed to restore the parent task after subtask creation failed. Reopen the task from history to continue.",
272+
)
273+
} finally {
274+
showErrorMessage.mockRestore()
275+
}
272276
})
273277

274278
it("fan-out path: both parent and child are tracked in the registry with no shared clineMessages reference", async () => {
@@ -313,7 +317,7 @@ describe("delegateParentAndOpenChild — fan-out (Story 3.2b)", () => {
313317
// Occupy one permit with the "parent's own request loop" so only one
314318
// permit remains — exactly the scenario fan-out is meant to handle.
315319
void scheduler.schedule(parent, parentRun)
316-
while (scheduler.available > 1) {
320+
for (let i = 0; i < 10 && scheduler.available > 1; i++) {
317321
await Promise.resolve()
318322
}
319323

src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -374,14 +374,15 @@ describe("ClineProvider - Lock API Config Across Modes", () => {
374374
apiProvider: "anthropic",
375375
})
376376

377-
const activateProviderProfileSpy = vi
378-
.spyOn(provider, "activateProviderProfile")
379-
.mockResolvedValue(undefined)
377+
const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
378+
name: "architect-profile",
379+
apiProvider: "anthropic",
380+
})
380381

381382
await provider.handleModeSwitch("architect")
382383

383384
expect(getModeConfigIdSpy).toHaveBeenCalledWith("architect")
384-
expect(activateProviderProfileSpy).toHaveBeenCalledWith({ name: "architect-profile" })
385+
expect(activateProfileSpy).toHaveBeenCalledWith({ name: "architect-profile" })
385386
})
386387
})
387388
})

0 commit comments

Comments
 (0)