From 3b24aab2a9271404cf90831e8db9d42efc1341be Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 2 Sep 2026 22:58:28 +0000 Subject: [PATCH 1/6] fix: keep nested subtask delegation active --- src/__tests__/provider-delegation.spec.ts | 5 +-- src/core/webview/ClineProvider.ts | 35 ++++++++++++++----- .../ClineProvider.apiHandlerRebuild.spec.ts | 35 +++++++++++++++++++ 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 0b7aef8775..c934170438 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -249,8 +249,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // Provider-level event expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1") - // Mode switch - expect(handleModeSwitch).toHaveBeenCalledWith("code") + // The parent has already been removed, so the mode switch must not publish a + // transient empty-task state before the child is created. + expect(handleModeSwitch).toHaveBeenCalledWith("code", undefined, { preparePendingTask: true }) }) it("posts taskHistoryItemUpdated to the webview when isViewLaunched is true", async () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 394da7c10f..3d7410219a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1711,15 +1711,20 @@ export class ClineProvider * @param targetTask The task whose in-memory mode should be updated. Defaults to the * current task. Pass null to apply only global mode/profile effects for a pending child. */ - public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { + public async handleModeSwitch( + newMode: Mode, + targetTask: Task | null | undefined = this.getCurrentTask(), + options: { preparePendingTask?: boolean } = {}, + ) { return this.enqueueProviderProfileMutation((signal) => - this.handleModeSwitchUnlocked(newMode, targetTask, signal), + this.handleModeSwitchUnlocked(newMode, targetTask, options, signal), ) } private async handleModeSwitchUnlocked( newMode: Mode, targetTask: Task | null | undefined, + options: { preparePendingTask?: boolean }, signal?: AbortSignal, ): Promise { const task = targetTask @@ -1759,7 +1764,7 @@ export class ClineProvider // If workspace lock is on, keep the current API config — don't load mode-specific config const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (lockApiConfigAcrossModes) { - if (targetTask !== null) { + if (targetTask !== null && !options.preparePendingTask) { await this.postStateToWebview() } return @@ -1793,7 +1798,15 @@ export class ClineProvider if (hasActualSettings) { await this.activateProviderProfileUnlocked( { name: profile.name }, - targetTask === null ? { skipCurrentTaskRebuild: true } : undefined, + targetTask === null + ? { skipCurrentTaskRebuild: true } + : options.preparePendingTask + ? { + skipCurrentTaskRebuild: true, + applyProviderSettingsToContext: true, + suppressStatePost: true, + } + : undefined, signal, ) } else { @@ -1815,7 +1828,7 @@ export class ClineProvider } } - if (targetTask !== null) { + if (targetTask !== null && !options.preparePendingTask) { await this.postStateToWebview() } } @@ -1993,6 +2006,8 @@ export class ClineProvider persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + applyProviderSettingsToContext?: boolean + suppressStatePost?: boolean }, ) { return this.enqueueProviderProfileMutation((signal) => @@ -2006,6 +2021,8 @@ export class ClineProvider persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + applyProviderSettingsToContext?: boolean + suppressStatePost?: boolean }, signal?: AbortSignal, ): Promise { @@ -2016,8 +2033,10 @@ export class ClineProvider const persistModeConfig = options?.persistModeConfig ?? true const persistTaskHistory = options?.persistTaskHistory ?? true const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false + const applyProviderSettingsToContext = options?.applyProviderSettingsToContext ?? !skipCurrentTaskRebuild + const suppressStatePost = options?.suppressStatePost ?? false - if (!skipCurrentTaskRebuild) { + if (applyProviderSettingsToContext) { // See `upsertProviderProfile` for a description of what this is doing. await Promise.all([ this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), @@ -2041,7 +2060,7 @@ export class ClineProvider await this.persistStickyProviderProfileToCurrentTask(name, { skipCurrentTaskRebuild }) } - if (!skipCurrentTaskRebuild) { + if (!skipCurrentTaskRebuild && !suppressStatePost) { await this.postStateToWebview() } @@ -3900,7 +3919,7 @@ export class ClineProvider // The mode switch must happen before createTask() because the Task constructor // initializes its mode from provider.getState() during initializeTaskMode(). try { - await this.handleModeSwitch(mode as any) + await this.handleModeSwitch(mode as any, undefined, { preparePendingTask: true }) } catch (e) { this.log( `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 99d254cb9a..8ec5d8b149 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -612,6 +612,41 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledWith({ name: "ask-profile" }) }) + test("pending child preparation applies its profile without posting an empty task state", async () => { + const unrelatedTask = new Task(defaultTaskOptions) + await provider.addClineToStack(unrelatedTask) + provider["providerSettingsManager"].getModeConfigId = vi.fn().mockResolvedValue("ask-id") + provider["providerSettingsManager"].listConfig = vi + .fn() + .mockResolvedValue([{ name: "ask-profile", id: "ask-id", apiProvider: providerIdentifiers.openrouter }]) + provider["providerSettingsManager"].getProfile = vi.fn().mockResolvedValue({ + name: "ask-profile", + id: "ask-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4.1-mini", + }) + provider["providerSettingsManager"].activateProfile = vi.fn().mockResolvedValue({ + name: "ask-profile", + id: "ask-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4.1-mini", + }) + const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + postStateSpy.mockClear() + + await provider["handleModeSwitchUnlocked"]("ask" as Mode, undefined, { preparePendingTask: true }) + + expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "ask-profile") + expect(setProviderSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ openRouterModelId: "openai/gpt-4.1-mini" }), + ) + expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() + expect(postStateSpy).not.toHaveBeenCalled() + }) + test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => { const mockTask = new Task({ ...defaultTaskOptions, From d8af5f45bd289999346d92257727b8135ab67ce2 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 00:42:50 +0000 Subject: [PATCH 2/6] fix: isolate nested child mode preparation --- src/__tests__/provider-delegation.spec.ts | 2 +- src/core/webview/ClineProvider.ts | 23 +++++----- .../ClineProvider.apiHandlerRebuild.spec.ts | 45 ++++++++++++++++++- src/eslint-suppressions.json | 2 +- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index c934170438..e3e32a2365 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -251,7 +251,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // The parent has already been removed, so the mode switch must not publish a // transient empty-task state before the child is created. - expect(handleModeSwitch).toHaveBeenCalledWith("code", undefined, { preparePendingTask: true }) + expect(handleModeSwitch).toHaveBeenCalledWith("code", null, { preparePendingTask: true }) }) it("posts taskHistoryItemUpdated to the webview when isViewLaunched is true", async () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3d7410219a..5a1fbb40fd 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1796,19 +1796,16 @@ export class ClineProvider const hasActualSettings = !!fullProfile.apiProvider if (hasActualSettings) { - await this.activateProviderProfileUnlocked( - { name: profile.name }, - targetTask === null + const activationOptions = options.preparePendingTask + ? { + skipCurrentTaskRebuild: true, + applyProviderSettingsToContext: true, + suppressStatePost: true, + } + : targetTask === null ? { skipCurrentTaskRebuild: true } - : options.preparePendingTask - ? { - skipCurrentTaskRebuild: true, - applyProviderSettingsToContext: true, - suppressStatePost: true, - } - : undefined, - signal, - ) + : undefined + await this.activateProviderProfileUnlocked({ name: profile.name }, activationOptions, signal) } else { // The task will continue with the current/default configuration. } @@ -3919,7 +3916,7 @@ export class ClineProvider // The mode switch must happen before createTask() because the Task constructor // initializes its mode from provider.getState() during initializeTaskMode(). try { - await this.handleModeSwitch(mode as any, undefined, { preparePendingTask: true }) + await this.handleModeSwitch(mode, null, { preparePendingTask: true }) } catch (e) { this.log( `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 8ec5d8b149..bd0793f9b9 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -614,6 +614,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { test("pending child preparation applies its profile without posting an empty task state", async () => { const unrelatedTask = new Task(defaultTaskOptions) + unrelatedTask["_taskMode"] = "code" as Mode await provider.addClineToStack(unrelatedTask) provider["providerSettingsManager"].getModeConfigId = vi.fn().mockResolvedValue("ask-id") provider["providerSettingsManager"].listConfig = vi @@ -632,11 +633,12 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { openRouterModelId: "openai/gpt-4.1-mini", }) const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory") const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") postStateSpy.mockClear() - await provider["handleModeSwitchUnlocked"]("ask" as Mode, undefined, { preparePendingTask: true }) + await provider.handleModeSwitch("ask" as Mode, null, { preparePendingTask: true }) expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "ask-profile") expect(setProviderSettingsSpy).toHaveBeenCalledWith( @@ -644,6 +646,47 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { ) expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() + expect(unrelatedTask["_taskMode"]).toBe("code") + expect(updateTaskHistorySpy).not.toHaveBeenCalled() + expect(postStateSpy).not.toHaveBeenCalled() + }) + + test("pending child preparation keeps the current profile when the mode has no saved profile", async () => { + const unrelatedTask = new Task(defaultTaskOptions) + unrelatedTask["_taskMode"] = "code" as Mode + await provider.addClineToStack(unrelatedTask) + const activateProfileSpy = vi.spyOn(provider["providerSettingsManager"], "activateProfile") + const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + postStateSpy.mockClear() + + await provider.handleModeSwitch("ask" as Mode, null, { preparePendingTask: true }) + + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "ask") + expect(activateProfileSpy).not.toHaveBeenCalled() + expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() + expect(unrelatedTask["_taskMode"]).toBe("code") + expect(postStateSpy).not.toHaveBeenCalled() + }) + + test("pending child preparation preserves the locked profile without posting state", async () => { + const unrelatedTask = new Task(defaultTaskOptions) + unrelatedTask["_taskMode"] = "code" as Mode + await provider.addClineToStack(unrelatedTask) + vi.mocked(mockContext.workspaceState.get).mockReturnValue(true) + const getModeConfigIdSpy = vi.spyOn(provider["providerSettingsManager"], "getModeConfigId") + const activateProfileSpy = vi.spyOn(provider["providerSettingsManager"], "activateProfile") + const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + postStateSpy.mockClear() + + await provider.handleModeSwitch("ask" as Mode, null, { preparePendingTask: true }) + + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "ask") + expect(getModeConfigIdSpy).not.toHaveBeenCalled() + expect(activateProfileSpy).not.toHaveBeenCalled() + expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() + expect(unrelatedTask["_taskMode"]).toBe("code") expect(postStateSpy).not.toHaveBeenCalled() }) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 393e108645..75edc64863 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1026,7 +1026,7 @@ }, "core/webview/ClineProvider.ts": { "@typescript-eslint/no-explicit-any": { - "count": 12 + "count": 11 } }, "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": { From 497aaab700392697cbddffa75e1504639b025187 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 01:25:47 +0000 Subject: [PATCH 3/6] test: model provider delegation handoffs --- docs/architecture/task-lifecycle-model.md | 10 + package.json | 2 +- scripts/check-provider-handoff.ts | 358 ++++++++++++++++++ src/__tests__/provider-delegation.spec.ts | 6 +- src/core/task-persistence/index.ts | 9 + src/core/task-persistence/providerHandoff.ts | 53 +++ src/core/webview/ClineProvider.ts | 61 ++- .../ClineProvider.apiHandlerRebuild.spec.ts | 15 +- 8 files changed, 495 insertions(+), 19 deletions(-) create mode 100644 scripts/check-provider-handoff.ts create mode 100644 src/core/task-persistence/providerHandoff.ts diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index 3218eb4ba8..f03443ee16 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -37,6 +37,16 @@ The model has three fixed task slots, enough to cover competing siblings and a n Production completion also accepts a recovery-compatible `active` parent that still awaits the returning child, then clears the stale pointers. Normal model transitions never create that intermediate state, so it is covered by a focused reducer test rather than admitted as a generally valid reachable state. +## Provider handoff refinement model + +The same command runs `scripts/check-provider-handoff.ts`, a separate bounded model for the concrete provider steps that refine the atomic `delegate(parent, child)` lifecycle operation. It imports the production handoff policy and profile decision functions from `src/core/task-persistence/providerHandoff.ts`; its single persistence step calls `delegateTaskToChild` rather than duplicating the persisted transition. + +The model covers both a sole live parent and a nested parent whose removal exposes an unrelated root task. For each topology it checks saved, unsaved, and workspace-locked profile paths through these observable phases: remove parent, prepare child profile, create the paused child, persist delegation, start the child, and publish the child state. It enforces that pending preparation publishes no intermediate state, cannot mutate the exposed root task, creates the child with the requested mode and selected profile, and starts the child only after exactly one atomic delegation commit. + +An injected legacy policy retains the pre-fix implicit-current-task targeting and intermediate publication behavior without modifying repository history. The checker requires shortest counterexamples for both an empty publication after removing a sole parent and mutation of an exposed root during nested delegation. These witnesses are regression ratchets for the provider handoff policy, not generally allowed lifecycle states. + +This model deliberately keeps profile identities as opaque names/IDs and does not model API secrets, provider construction, VS Code transport latency, filesystem durability, scheduler fairness, or rollback cleanup. Focused provider tests remain responsible for proving that `ClineProvider` interprets the shared production policy correctly. + ## Shared-store concurrency model The same `pnpm lifecycle:model-check` command also runs a second bounded explorer over two `TaskHistoryStore` hosts. It imports the production `computeHistoryDelta` and `mergeHistoryDelta` functions, so its semantics match the store rather than assuming coherent caches or transactional pair writes: diff --git a/package.json b/package.json index 1a44a12680..c3fe2a5ce6 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "check-types": "turbo check-types --log-order grouped --output-logs new-only", "test": "turbo test --log-order grouped --output-logs new-only", "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs", - "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && pnpm cleanup-protocol:model-check", + "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-provider-handoff.ts && tsx scripts/check-task-store-concurrency.ts && pnpm cleanup-protocol:model-check", "cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts", "test:coverage": "turbo test:coverage --log-order grouped --output-logs new-only", "format": "turbo format --log-order grouped --output-logs new-only", diff --git a/scripts/check-provider-handoff.ts b/scripts/check-provider-handoff.ts new file mode 100644 index 0000000000..209f3491bb --- /dev/null +++ b/scripts/check-provider-handoff.ts @@ -0,0 +1,358 @@ +import assert from "node:assert/strict" + +import type { HistoryItem } from "../packages/types/src/history" +import { + createProviderHandoffPlan, + decideProviderHandoffProfile, + type ProviderProfileRef, +} from "../src/core/task-persistence/providerHandoff" +import { delegateTaskToChild } from "../src/core/task-persistence/taskLifecycle" + +type TaskId = "root" | "parent" | "child" +type Topology = "sole-parent" | "exposed-root" +type ProfileScenario = "saved" | "unsaved" | "locked" +type Phase = + | "parent-open" + | "parent-removed" + | "profile-prepared" + | "child-created" + | "delegation-committed" + | "child-running" + | "settled" + +interface RuntimeTask { + mode: string + profile: string +} + +interface ModelState { + topology: Topology + scenario: ProfileScenario + phase: Phase + currentTaskId?: TaskId + rootTask: RuntimeTask + rootHistory: HistoryItem + parentHistory: HistoryItem + childTask?: RuntimeTask + childStarted: boolean + globalMode: string + globalProfile: string + modeProfileId?: string + publications: Array + refinementCommits: number +} + +interface ModelPolicy { + target: "none" | "implicit-current" + mutateExposedTask: boolean + publishWhilePending: boolean + applyProviderSettingsToContext: boolean +} + +interface TraceStep { + action: string + state: ModelState +} + +interface ModelResult { + states: number + traces: number + actions: Set +} + +interface Counterexample { + violation: string + trace: TraceStep[] +} + +const requestedMode = "child-mode" +const currentProfile: ProviderProfileRef = { name: "root-profile", id: "root-profile-id" } +const savedProfile: ProviderProfileRef = { name: "child-profile", id: "child-profile-id" } +const MAX_STATES = 100 +const actionOrder = [ + "remove-parent", + "prepare-profile", + "create-child", + "persist-delegation", + "start-child", + "publish-child", +] as const + +const legacyPolicy: ModelPolicy = { + target: "implicit-current", + mutateExposedTask: true, + publishWhilePending: true, + applyProviderSettingsToContext: true, +} + +function history(id: TaskId, parentTaskId?: TaskId): HistoryItem { + return { + id, + number: id === "root" ? 0 : id === "parent" ? 1 : 2, + ts: id === "root" ? 0 : id === "parent" ? 1 : 2, + task: id, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "active", + mode: "root-mode", + parentTaskId, + rootTaskId: parentTaskId ? "root" : undefined, + childIds: [], + } +} + +function initialState(topology: Topology, scenario: ProfileScenario): ModelState { + const parentHistory = history("parent", topology === "exposed-root" ? "root" : undefined) + const rootHistory = topology === "exposed-root" ? delegateTaskToChild(history("root"), "parent") : history("root") + return { + topology, + scenario, + phase: "parent-open", + currentTaskId: "parent", + rootTask: { mode: "root-mode", profile: currentProfile.name }, + rootHistory, + parentHistory, + childStarted: false, + globalMode: "root-mode", + globalProfile: currentProfile.name, + publications: [], + refinementCommits: 0, + } +} + +function profileDecision(state: ModelState) { + return decideProviderHandoffProfile({ + locked: state.scenario === "locked", + currentProfile, + savedProfile: state.scenario === "saved" ? savedProfile : undefined, + }) +} + +function productionPolicy(): ModelPolicy { + const { policy } = createProviderHandoffPlan(requestedMode) + return { + target: policy.targetTask === null ? "none" : "implicit-current", + mutateExposedTask: policy.mutateExposedTask, + publishWhilePending: policy.publishWhilePending, + applyProviderSettingsToContext: policy.applyProviderSettingsToContext, + } +} + +function nextAction(phase: Phase): (typeof actionOrder)[number] | undefined { + switch (phase) { + case "parent-open": + return "remove-parent" + case "parent-removed": + return "prepare-profile" + case "profile-prepared": + return "create-child" + case "child-created": + return "persist-delegation" + case "delegation-committed": + return "start-child" + case "child-running": + return "publish-child" + case "settled": + return undefined + } +} + +function transition(state: ModelState, action: (typeof actionOrder)[number], policy: ModelPolicy): ModelState { + const next = structuredClone(state) + const decision = profileDecision(state) + + switch (action) { + case "remove-parent": + next.phase = "parent-removed" + next.currentTaskId = state.topology === "exposed-root" ? "root" : undefined + return next + case "prepare-profile": { + next.phase = "profile-prepared" + next.globalMode = requestedMode + if (policy.applyProviderSettingsToContext && decision.profile) { + next.globalProfile = decision.profile.name + } + if (decision.source === "unsaved-current") { + next.modeProfileId = decision.persistModeProfileId + } + if (policy.target === "implicit-current" && policy.mutateExposedTask && next.currentTaskId === "root") { + next.rootTask = { mode: requestedMode, profile: next.globalProfile } + next.rootHistory = { ...next.rootHistory, mode: requestedMode } + } + if (policy.publishWhilePending) next.publications.push(next.currentTaskId) + return next + } + case "create-child": + next.phase = "child-created" + next.currentTaskId = "child" + next.childTask = { mode: next.globalMode, profile: next.globalProfile } + return next + case "persist-delegation": + next.phase = "delegation-committed" + next.parentHistory = delegateTaskToChild(next.parentHistory, "child") + next.refinementCommits++ + return next + case "start-child": + next.phase = "child-running" + next.childStarted = true + return next + case "publish-child": + next.phase = "settled" + next.publications.push(next.currentTaskId) + return next + } +} + +function phaseAtLeast(state: ModelState, phase: Phase): boolean { + const phases: Phase[] = [ + "parent-open", + "parent-removed", + "profile-prepared", + "child-created", + "delegation-committed", + "child-running", + "settled", + ] + return phases.indexOf(state.phase) >= phases.indexOf(phase) +} + +function violations(state: ModelState): string[] { + const result: string[] = [] + const initialRoot = initialState(state.topology, state.scenario) + const decision = profileDecision(state) + const expectedProfile = decision.profile?.name ?? currentProfile.name + + if (state.publications.some((taskId) => taskId === undefined)) { + result.push("published an empty task while child handoff was pending") + } + if (state.phase !== "settled" && state.publications.length > 0) { + result.push("published state before child handoff settled") + } + if ( + JSON.stringify(state.rootTask) !== JSON.stringify(initialRoot.rootTask) || + JSON.stringify(state.rootHistory) !== JSON.stringify(initialRoot.rootHistory) + ) { + result.push("mutated the unrelated exposed root task") + } + if (phaseAtLeast(state, "profile-prepared") && state.globalProfile !== expectedProfile) { + result.push("prepared the wrong child profile") + } + if (state.scenario === "unsaved" && phaseAtLeast(state, "profile-prepared")) { + if (state.modeProfileId !== currentProfile.id) result.push("did not persist the inherited unsaved profile") + } + if (state.scenario !== "unsaved" && state.modeProfileId !== undefined) { + result.push("persisted an unexpected mode profile") + } + if (phaseAtLeast(state, "child-created")) { + if (state.childTask?.mode !== requestedMode || state.childTask.profile !== expectedProfile) { + result.push("created the child with the wrong mode or profile") + } + } + if (state.childStarted && state.refinementCommits !== 1) { + result.push("started the child before the atomic delegation commit") + } + if (phaseAtLeast(state, "delegation-committed")) { + const expectedParent = delegateTaskToChild(initialRoot.parentHistory, "child") + if (JSON.stringify(state.parentHistory) !== JSON.stringify(expectedParent)) { + result.push("delegation commit did not refine delegateTaskToChild") + } + if (state.refinementCommits !== 1) result.push("atomic delegation commit count was not exactly one") + } + if (state.phase === "settled" && state.publications.at(-1) !== "child") { + result.push("final publication did not identify the child") + } + return result +} + +function canonical(state: ModelState): string { + return JSON.stringify(state) +} + +function runModel(policy: ModelPolicy): ModelResult { + const queue: Array<{ state: ModelState; trace: TraceStep[] }> = [] + for (const topology of ["sole-parent", "exposed-root"] as const) { + for (const scenario of ["saved", "unsaved", "locked"] as const) { + const state = initialState(topology, scenario) + queue.push({ state, trace: [{ action: "initial", state }] }) + } + } + + const visited = new Set(queue.map(({ state }) => canonical(state))) + const actions = new Set() + let settledTraces = 0 + for (let index = 0; index < queue.length; index++) { + const node = queue[index]! + const found = violations(node.state) + if (found.length) throw new Error(`${found.join("; ")}\n${formatTrace(node.trace)}`) + const action = nextAction(node.state.phase) + if (!action) { + settledTraces++ + continue + } + actions.add(action) + const next = transition(node.state, action, policy) + const key = canonical(next) + if (!visited.has(key)) { + visited.add(key) + if (visited.size > MAX_STATES) { + throw new Error(`Provider handoff exploration exceeded its ${MAX_STATES}-state budget`) + } + queue.push({ state: next, trace: [...node.trace, { action, state: next }] }) + } + } + return { states: visited.size, traces: settledTraces, actions } +} + +function findCounterexample( + policy: ModelPolicy, + topology: Topology, + violationText: string, +): Counterexample | undefined { + let state = initialState(topology, "saved") + const trace: TraceStep[] = [{ action: "initial", state }] + while (true) { + const found = violations(state).find((violation) => violation === violationText) + if (found) return { violation: found, trace } + const action = nextAction(state.phase) + if (!action) return undefined + state = transition(state, action, policy) + trace.push({ action, state }) + } +} + +function formatTrace(trace: TraceStep[]): string { + return trace + .map( + ({ action, state }) => + `${action}: phase=${state.phase}, current=${state.currentTaskId ?? "none"}, rootMode=${state.rootTask.mode}, globalProfile=${state.globalProfile}, publications=${JSON.stringify(state.publications)}`, + ) + .join(" -> ") +} + +const result = runModel(productionPolicy()) +assert.deepEqual([...result.actions], actionOrder) +assert.equal(result.traces, 6) + +const emptyPublication = findCounterexample( + legacyPolicy, + "sole-parent", + "published an empty task while child handoff was pending", +) +const rootMutation = findCounterexample(legacyPolicy, "exposed-root", "mutated the unrelated exposed root task") +assert(emptyPublication) +assert(rootMutation) +assert.deepEqual( + emptyPublication.trace.map(({ action }) => action), + ["initial", "remove-parent", "prepare-profile"], +) +assert.deepEqual( + rootMutation.trace.map(({ action }) => action), + ["initial", "remove-parent", "prepare-profile"], +) + +console.log( + `Provider handoff model check passed: ${result.states} reachable states, ${result.traces} scenario traces, ${result.actions.size}/${actionOrder.length} actions reachable, 3/3 profile paths, 2/2 legacy counterexamples reproduced`, +) +console.log(`Legacy empty-publication counterexample: ${formatTrace(emptyPublication.trace)}`) +console.log(`Legacy exposed-root mutation counterexample: ${formatTrace(rootMutation.trace)}`) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index e3e32a2365..ea624dc4a4 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -5,6 +5,7 @@ import type { HistoryItem } from "@roo-code/types" import { RooCodeEventName } from "@roo-code/types" import { ClineProvider } from "../core/webview/ClineProvider" import { TaskScheduler } from "../core/task/TaskScheduler" +import { createProviderHandoffPlan } from "../core/task-persistence/providerHandoff" const parentHistoryItem: HistoryItem = { id: "parent-1", @@ -251,7 +252,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { // The parent has already been removed, so the mode switch must not publish a // transient empty-task state before the child is created. - expect(handleModeSwitch).toHaveBeenCalledWith("code", null, { preparePendingTask: true }) + const handoff = createProviderHandoffPlan("code") + expect(handleModeSwitch).toHaveBeenCalledWith(handoff.requestedMode, handoff.policy.targetTask, { + pendingHandoff: handoff.policy, + }) }) it("posts taskHistoryItemUpdated to the webview when isViewLaunched is true", async () => { diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index 463df8a0bb..3b084a47db 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -2,6 +2,15 @@ export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages export { readTaskMessages, saveTaskMessages } from "./taskMessages" export { taskMetadata } from "./taskMetadata" export { TaskHistoryStore } from "./TaskHistoryStore" +export { + createProviderHandoffPlan, + decideProviderHandoffProfile, + getProviderHandoffActivationOptions, + PRODUCTION_PROVIDER_HANDOFF_POLICY, + type ProviderHandoffPolicy, + type ProviderHandoffProfileDecision, + type ProviderProfileRef, +} from "./providerHandoff" export { abandonDelegatedChild, assertValidTransition, diff --git a/src/core/task-persistence/providerHandoff.ts b/src/core/task-persistence/providerHandoff.ts new file mode 100644 index 0000000000..03cc3d1008 --- /dev/null +++ b/src/core/task-persistence/providerHandoff.ts @@ -0,0 +1,53 @@ +export interface ProviderProfileRef { + name: string + id?: string +} + +export interface ProviderHandoffPolicy { + targetTask: null + mutateExposedTask: boolean + publishWhilePending: boolean + applyProviderSettingsToContext: boolean +} + +export const PRODUCTION_PROVIDER_HANDOFF_POLICY = { + targetTask: null, + mutateExposedTask: false, + publishWhilePending: false, + applyProviderSettingsToContext: true, +} as const satisfies ProviderHandoffPolicy + +export function createProviderHandoffPlan(requestedMode: string) { + return { + requestedMode, + policy: PRODUCTION_PROVIDER_HANDOFF_POLICY, + } as const +} + +export type ProviderHandoffProfileDecision = + | { source: "locked-current"; profile?: ProviderProfileRef } + | { source: "saved"; profile: ProviderProfileRef } + | { source: "unsaved-current"; profile?: ProviderProfileRef; persistModeProfileId?: string } + +export function decideProviderHandoffProfile(params: { + locked: boolean + currentProfile?: ProviderProfileRef + savedProfile?: ProviderProfileRef +}): ProviderHandoffProfileDecision { + const { locked, currentProfile, savedProfile } = params + if (locked) return { source: "locked-current", profile: currentProfile } + if (savedProfile) return { source: "saved", profile: savedProfile } + return { + source: "unsaved-current", + profile: currentProfile, + persistModeProfileId: currentProfile?.id, + } +} + +export function getProviderHandoffActivationOptions(policy: ProviderHandoffPolicy) { + return { + skipCurrentTaskRebuild: !policy.mutateExposedTask, + applyProviderSettingsToContext: policy.applyProviderSettingsToContext, + suppressStatePost: !policy.publishWhilePending, + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5a1fbb40fd..932263d465 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -116,8 +116,12 @@ import { TaskHistoryStore, abandonDelegatedChild, completeDelegatedChild, + createProviderHandoffPlan, + decideProviderHandoffProfile, delegateTaskToChild, + getProviderHandoffActivationOptions, interruptDelegatedChild, + type ProviderHandoffPolicy, } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" @@ -1714,7 +1718,7 @@ export class ClineProvider public async handleModeSwitch( newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask(), - options: { preparePendingTask?: boolean } = {}, + options: { pendingHandoff?: ProviderHandoffPolicy } = {}, ) { return this.enqueueProviderProfileMutation((signal) => this.handleModeSwitchUnlocked(newMode, targetTask, options, signal), @@ -1724,7 +1728,7 @@ export class ClineProvider private async handleModeSwitchUnlocked( newMode: Mode, targetTask: Task | null | undefined, - options: { preparePendingTask?: boolean }, + options: { pendingHandoff?: ProviderHandoffPolicy }, signal?: AbortSignal, ): Promise { const task = targetTask @@ -1764,7 +1768,17 @@ export class ClineProvider // If workspace lock is on, keep the current API config — don't load mode-specific config const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (lockApiConfigAcrossModes) { - if (targetTask !== null && !options.preparePendingTask) { + if (options.pendingHandoff) { + const currentProfileName = this.getGlobalState("currentApiConfigName") + const decision = decideProviderHandoffProfile({ + locked: true, + currentProfile: currentProfileName ? { name: currentProfileName } : undefined, + }) + if (decision.source !== "locked-current") { + throw new Error("Expected locked child profile decision") + } + } + if (targetTask !== null && (options.pendingHandoff?.publishWhilePending ?? true)) { await this.postStateToWebview() } return @@ -1796,16 +1810,21 @@ export class ClineProvider const hasActualSettings = !!fullProfile.apiProvider if (hasActualSettings) { - const activationOptions = options.preparePendingTask - ? { - skipCurrentTaskRebuild: true, - applyProviderSettingsToContext: true, - suppressStatePost: true, - } + let profileName = profile.name + if (options.pendingHandoff) { + const decision = decideProviderHandoffProfile({ + locked: false, + savedProfile: { name: profile.name, id: profile.id }, + }) + if (decision.source !== "saved") throw new Error("Expected saved child profile decision") + profileName = decision.profile.name + } + const activationOptions = options.pendingHandoff + ? getProviderHandoffActivationOptions(options.pendingHandoff) : targetTask === null ? { skipCurrentTaskRebuild: true } : undefined - await this.activateProviderProfileUnlocked({ name: profile.name }, activationOptions, signal) + await this.activateProviderProfileUnlocked({ name: profileName }, activationOptions, signal) } else { // The task will continue with the current/default configuration. } @@ -1818,14 +1837,25 @@ export class ClineProvider if (currentApiConfigNameAfter) { const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) + let configId = config?.id + if (options.pendingHandoff) { + const decision = decideProviderHandoffProfile({ + locked: false, + currentProfile: { name: currentApiConfigNameAfter, id: config?.id }, + }) + if (decision.source !== "unsaved-current") { + throw new Error("Expected unsaved child profile decision") + } + configId = decision.persistModeProfileId + } - if (config?.id) { - await this.providerSettingsManager.setModeConfig(newMode, config.id) + if (configId) { + await this.providerSettingsManager.setModeConfig(newMode, configId) } } } - if (targetTask !== null && !options.preparePendingTask) { + if (targetTask !== null && (options.pendingHandoff?.publishWhilePending ?? true)) { await this.postStateToWebview() } } @@ -3916,7 +3946,10 @@ export class ClineProvider // The mode switch must happen before createTask() because the Task constructor // initializes its mode from provider.getState() during initializeTaskMode(). try { - await this.handleModeSwitch(mode, null, { preparePendingTask: true }) + const handoff = createProviderHandoffPlan(mode) + await this.handleModeSwitch(handoff.requestedMode, handoff.policy.targetTask, { + pendingHandoff: handoff.policy, + }) } catch (e) { this.log( `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index bd0793f9b9..dfe31e1a44 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -8,6 +8,7 @@ import { getModelId, RooCodeEventName } from "@roo-code/types" import { ContextProxy } from "../../config/ContextProxy" import type { Mode } from "../../../shared/modes" import { Task, TaskOptions } from "../../task/Task" +import { PRODUCTION_PROVIDER_HANDOFF_POLICY } from "../../task-persistence/providerHandoff" import { ClineProvider } from "../ClineProvider" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" @@ -638,7 +639,9 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") postStateSpy.mockClear() - await provider.handleModeSwitch("ask" as Mode, null, { preparePendingTask: true }) + await provider.handleModeSwitch("ask" as Mode, null, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }) expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "ask-profile") expect(setProviderSettingsSpy).toHaveBeenCalledWith( @@ -655,13 +658,17 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const unrelatedTask = new Task(defaultTaskOptions) unrelatedTask["_taskMode"] = "code" as Mode await provider.addClineToStack(unrelatedTask) + await provider.contextProxy.setValue("currentApiConfigName", "test-config") const activateProfileSpy = vi.spyOn(provider["providerSettingsManager"], "activateProfile") const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) postStateSpy.mockClear() - await provider.handleModeSwitch("ask" as Mode, null, { preparePendingTask: true }) + await provider.handleModeSwitch("ask" as Mode, null, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }) expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "ask") + expect(provider["providerSettingsManager"].setModeConfig).toHaveBeenCalledWith("ask", "test-id") expect(activateProfileSpy).not.toHaveBeenCalled() expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() @@ -679,7 +686,9 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) postStateSpy.mockClear() - await provider.handleModeSwitch("ask" as Mode, null, { preparePendingTask: true }) + await provider.handleModeSwitch("ask" as Mode, null, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }) expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "ask") expect(getModeConfigIdSpy).not.toHaveBeenCalled() From 83a9d4f11f22eefbabd018496fcfcfa2ed4231c9 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 22:57:44 +0000 Subject: [PATCH 4/6] fix: resolve package-local mutation tests --- .../__tests__/providerHandoff.spec.ts | 111 ++++++++++++++++++ src/core/task-persistence/index.ts | 2 + src/core/task-persistence/providerHandoff.ts | 35 ++++++ src/core/webview/ClineProvider.ts | 63 ++++------ .../ClineProvider.apiHandlerRebuild.spec.ts | 33 ++++++ 5 files changed, 203 insertions(+), 41 deletions(-) create mode 100644 src/core/task-persistence/__tests__/providerHandoff.spec.ts diff --git a/src/core/task-persistence/__tests__/providerHandoff.spec.ts b/src/core/task-persistence/__tests__/providerHandoff.spec.ts new file mode 100644 index 0000000000..55bd9663c5 --- /dev/null +++ b/src/core/task-persistence/__tests__/providerHandoff.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest" + +import { + createProviderHandoffPlan, + decideProviderHandoffProfile, + getProviderHandoffActivationOptions, + PRODUCTION_PROVIDER_HANDOFF_POLICY, + publishProviderHandoffState, + shouldPublishProviderHandoffState, + type ProviderHandoffPolicy, +} from "../providerHandoff" + +describe("provider handoff contract", () => { + it("creates a no-target, non-publishing production plan", () => { + expect(createProviderHandoffPlan("child-mode")).toEqual({ + requestedMode: "child-mode", + policy: { + targetTask: null, + mutateExposedTask: false, + publishWhilePending: false, + applyProviderSettingsToContext: true, + }, + }) + }) + + it("selects the current profile while workspace profile locking is enabled", () => { + expect( + decideProviderHandoffProfile({ + locked: true, + currentProfile: { name: "current", id: "current-id" }, + savedProfile: { name: "saved", id: "saved-id" }, + }), + ).toEqual({ source: "locked-current", profile: { name: "current", id: "current-id" } }) + expect(decideProviderHandoffProfile({ locked: true })).toEqual({ + source: "locked-current", + profile: undefined, + }) + }) + + it("selects a saved mode profile when profile locking is disabled", () => { + expect( + decideProviderHandoffProfile({ + locked: false, + currentProfile: { name: "current", id: "current-id" }, + savedProfile: { name: "saved", id: "saved-id" }, + }), + ).toEqual({ source: "saved", profile: { name: "saved", id: "saved-id" } }) + }) + + it("inherits and persists the current profile for an unsaved mode", () => { + expect( + decideProviderHandoffProfile({ + locked: false, + currentProfile: { name: "current", id: "current-id" }, + }), + ).toEqual({ + source: "unsaved-current", + profile: { name: "current", id: "current-id" }, + persistModeProfileId: "current-id", + }) + expect(decideProviderHandoffProfile({ locked: false })).toEqual({ + source: "unsaved-current", + profile: undefined, + persistModeProfileId: undefined, + }) + }) + + it("projects production and injected policies into activation options", () => { + expect(getProviderHandoffActivationOptions(PRODUCTION_PROVIDER_HANDOFF_POLICY)).toEqual({ + skipCurrentTaskRebuild: true, + applyProviderSettingsToContext: true, + suppressStatePost: true, + }) + + const unsafePolicy: ProviderHandoffPolicy = { + targetTask: null, + mutateExposedTask: true, + publishWhilePending: true, + applyProviderSettingsToContext: false, + } + expect(getProviderHandoffActivationOptions(unsafePolicy)).toEqual({ + skipCurrentTaskRebuild: false, + applyProviderSettingsToContext: false, + suppressStatePost: false, + }) + }) + + it("publishes only when a target exists and the handoff policy permits it", () => { + expect(shouldPublishProviderHandoffState(true)).toBe(true) + expect(shouldPublishProviderHandoffState(false)).toBe(false) + expect(shouldPublishProviderHandoffState(true, PRODUCTION_PROVIDER_HANDOFF_POLICY)).toBe(false) + expect( + shouldPublishProviderHandoffState(true, { + targetTask: null, + mutateExposedTask: true, + publishWhilePending: true, + applyProviderSettingsToContext: false, + }), + ).toBe(true) + }) + + it("invokes publication only when the production decision allows it", async () => { + const publish = vi.fn().mockResolvedValue(undefined) + await publishProviderHandoffState(false, undefined, publish) + await publishProviderHandoffState(true, PRODUCTION_PROVIDER_HANDOFF_POLICY, publish) + expect(publish).not.toHaveBeenCalled() + + await publishProviderHandoffState(true, undefined, publish) + expect(publish).toHaveBeenCalledOnce() + }) +}) diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index 3b084a47db..57f326c464 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -7,6 +7,8 @@ export { decideProviderHandoffProfile, getProviderHandoffActivationOptions, PRODUCTION_PROVIDER_HANDOFF_POLICY, + publishProviderHandoffState, + shouldPublishProviderHandoffState, type ProviderHandoffPolicy, type ProviderHandoffProfileDecision, type ProviderProfileRef, diff --git a/src/core/task-persistence/providerHandoff.ts b/src/core/task-persistence/providerHandoff.ts index 03cc3d1008..1d790d644a 100644 --- a/src/core/task-persistence/providerHandoff.ts +++ b/src/core/task-persistence/providerHandoff.ts @@ -29,6 +29,26 @@ export type ProviderHandoffProfileDecision = | { source: "saved"; profile: ProviderProfileRef } | { source: "unsaved-current"; profile?: ProviderProfileRef; persistModeProfileId?: string } +export function decideProviderHandoffProfile(params: { + locked: true + currentProfile?: ProviderProfileRef + savedProfile?: ProviderProfileRef +}): Extract +export function decideProviderHandoffProfile(params: { + locked: false + currentProfile?: ProviderProfileRef + savedProfile: ProviderProfileRef +}): Extract +export function decideProviderHandoffProfile(params: { + locked: false + currentProfile?: ProviderProfileRef + savedProfile?: undefined +}): Extract +export function decideProviderHandoffProfile(params: { + locked: boolean + currentProfile?: ProviderProfileRef + savedProfile?: ProviderProfileRef +}): ProviderHandoffProfileDecision export function decideProviderHandoffProfile(params: { locked: boolean currentProfile?: ProviderProfileRef @@ -51,3 +71,18 @@ export function getProviderHandoffActivationOptions(policy: ProviderHandoffPolic suppressStatePost: !policy.publishWhilePending, } } + +export function shouldPublishProviderHandoffState( + targetTaskIsNotNull: boolean, + policy?: ProviderHandoffPolicy, +): boolean { + return targetTaskIsNotNull && (policy?.publishWhilePending ?? true) +} + +export async function publishProviderHandoffState( + targetTaskIsNotNull: boolean, + policy: ProviderHandoffPolicy | undefined, + publish: () => Promise, +): Promise { + if (shouldPublishProviderHandoffState(targetTaskIsNotNull, policy)) await publish() +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 932263d465..ec248c5c9d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -121,6 +121,7 @@ import { delegateTaskToChild, getProviderHandoffActivationOptions, interruptDelegatedChild, + publishProviderHandoffState, type ProviderHandoffPolicy, } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" @@ -1768,19 +1769,9 @@ export class ClineProvider // If workspace lock is on, keep the current API config — don't load mode-specific config const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (lockApiConfigAcrossModes) { - if (options.pendingHandoff) { - const currentProfileName = this.getGlobalState("currentApiConfigName") - const decision = decideProviderHandoffProfile({ - locked: true, - currentProfile: currentProfileName ? { name: currentProfileName } : undefined, - }) - if (decision.source !== "locked-current") { - throw new Error("Expected locked child profile decision") - } - } - if (targetTask !== null && (options.pendingHandoff?.publishWhilePending ?? true)) { - await this.postStateToWebview() - } + await publishProviderHandoffState(targetTask !== null, options.pendingHandoff, () => + this.postStateToWebview(), + ) return } @@ -1810,15 +1801,12 @@ export class ClineProvider const hasActualSettings = !!fullProfile.apiProvider if (hasActualSettings) { - let profileName = profile.name - if (options.pendingHandoff) { - const decision = decideProviderHandoffProfile({ - locked: false, - savedProfile: { name: profile.name, id: profile.id }, - }) - if (decision.source !== "saved") throw new Error("Expected saved child profile decision") - profileName = decision.profile.name - } + const profileName = options.pendingHandoff + ? decideProviderHandoffProfile({ + locked: false, + savedProfile: { name: profile.name, id: profile.id }, + }).profile.name + : profile.name const activationOptions = options.pendingHandoff ? getProviderHandoffActivationOptions(options.pendingHandoff) : targetTask === null @@ -1835,29 +1823,22 @@ export class ClineProvider // If no saved config for this mode, save current config as default. const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName") - if (currentApiConfigNameAfter) { - const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) - let configId = config?.id - if (options.pendingHandoff) { - const decision = decideProviderHandoffProfile({ + const config = listApiConfig.find((candidate) => candidate.name === currentApiConfigNameAfter) + const configId = options.pendingHandoff + ? decideProviderHandoffProfile({ locked: false, - currentProfile: { name: currentApiConfigNameAfter, id: config?.id }, - }) - if (decision.source !== "unsaved-current") { - throw new Error("Expected unsaved child profile decision") - } - configId = decision.persistModeProfileId - } - - if (configId) { - await this.providerSettingsManager.setModeConfig(newMode, configId) - } + currentProfile: currentApiConfigNameAfter + ? { name: currentApiConfigNameAfter, id: config?.id } + : undefined, + }).persistModeProfileId + : config?.id + + if (configId) { + await this.providerSettingsManager.setModeConfig(newMode, configId) } } - if (targetTask !== null && (options.pendingHandoff?.publishWhilePending ?? true)) { - await this.postStateToWebview() - } + await publishProviderHandoffState(targetTask !== null, options.pendingHandoff, () => this.postStateToWebview()) } // Provider Profile Management diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index dfe31e1a44..8598a7c437 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -644,6 +644,11 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { }) expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "ask-profile") + expect(setValueSpy).toHaveBeenCalledWith( + "listApiConfigMeta", + expect.arrayContaining([expect.objectContaining({ name: "ask-profile", id: "ask-id" })]), + ) + expect(setValueSpy.mock.calls.filter(([key]) => key === "listApiConfigMeta")).toHaveLength(2) expect(setProviderSettingsSpy).toHaveBeenCalledWith( expect.objectContaining({ openRouterModelId: "openai/gpt-4.1-mini" }), ) @@ -659,6 +664,10 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { unrelatedTask["_taskMode"] = "code" as Mode await provider.addClineToStack(unrelatedTask) await provider.contextProxy.setValue("currentApiConfigName", "test-config") + provider["providerSettingsManager"].listConfig = vi.fn().mockResolvedValue([ + { name: "other-config", id: "other-id", apiProvider: providerIdentifiers.openrouter }, + { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.openrouter }, + ]) const activateProfileSpy = vi.spyOn(provider["providerSettingsManager"], "activateProfile") const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) postStateSpy.mockClear() @@ -676,6 +685,24 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(postStateSpy).not.toHaveBeenCalled() }) + test("pending child preparation leaves an unsaved mode unassigned without a current profile", async () => { + const unrelatedTask = new Task(defaultTaskOptions) + unrelatedTask["_taskMode"] = "code" as Mode + await provider.addClineToStack(unrelatedTask) + await provider.contextProxy.setValue("currentApiConfigName", undefined) + const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + postStateSpy.mockClear() + + await provider.handleModeSwitch("ask" as Mode, null, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }) + + expect(provider["providerSettingsManager"].setModeConfig).not.toHaveBeenCalled() + expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(unrelatedTask["_taskMode"]).toBe("code") + expect(postStateSpy).not.toHaveBeenCalled() + }) + test("pending child preparation preserves the locked profile without posting state", async () => { const unrelatedTask = new Task(defaultTaskOptions) unrelatedTask["_taskMode"] = "code" as Mode @@ -697,6 +724,12 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() expect(unrelatedTask["_taskMode"]).toBe("code") expect(postStateSpy).not.toHaveBeenCalled() + + await provider.handleModeSwitch("architect" as Mode, null) + expect(postStateSpy).not.toHaveBeenCalled() + + await provider.handleModeSwitch("architect" as Mode, unrelatedTask) + expect(postStateSpy).toHaveBeenCalledOnce() }) test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => { From ff5b4a94a54adb30d50fadbd8018e73be62195f8 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 3 Sep 2026 23:06:42 +0000 Subject: [PATCH 5/6] test: cover provider handoff mutations --- ...ec.ts => ClineProvider.delegation.spec.ts} | 2 +- .../ClineProvider.apiHandlerRebuild.spec.ts | 28 +++++++++++++++++++ src/eslint-suppressions.json | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) rename src/__tests__/{provider-delegation.spec.ts => ClineProvider.delegation.spec.ts} (99%) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts similarity index 99% rename from src/__tests__/provider-delegation.spec.ts rename to src/__tests__/ClineProvider.delegation.spec.ts index ea624dc4a4..469e1d6315 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -1,4 +1,4 @@ -// npx vitest run __tests__/provider-delegation.spec.ts +// npx vitest run __tests__/ClineProvider.delegation.spec.ts import { describe, it, expect, vi } from "vitest" import type { HistoryItem } from "@roo-code/types" diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 8598a7c437..1f16a63213 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -652,6 +652,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(setProviderSettingsSpy).toHaveBeenCalledWith( expect.objectContaining({ openRouterModelId: "openai/gpt-4.1-mini" }), ) + expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledWith({ name: "ask-profile" }) expect(unrelatedTask.updateApiConfiguration).not.toHaveBeenCalled() expect(unrelatedTask.setTaskApiConfigName).not.toHaveBeenCalled() expect(unrelatedTask["_taskMode"]).toBe("code") @@ -659,6 +660,20 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(postStateSpy).not.toHaveBeenCalled() }) + test("pending child preparation tolerates a current profile missing from configuration metadata", async () => { + const unrelatedTask = new Task(defaultTaskOptions) + await provider.addClineToStack(unrelatedTask) + await provider.contextProxy.setValue("currentApiConfigName", "missing-config") + + await expect( + provider.handleModeSwitch("ask" as Mode, null, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }), + ).resolves.toBeUndefined() + + expect(provider["providerSettingsManager"].setModeConfig).not.toHaveBeenCalled() + }) + test("pending child preparation keeps the current profile when the mode has no saved profile", async () => { const unrelatedTask = new Task(defaultTaskOptions) unrelatedTask["_taskMode"] = "code" as Mode @@ -775,6 +790,19 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect((mockTask as any).apiConfiguration.rateLimitSeconds).toBe(7) }) + test("suppresses only explicitly suppressed profile state posts", async () => { + const mockTask = new Task(defaultTaskOptions) + await provider.addClineToStack(mockTask) + const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + postStateSpy.mockClear() + + await provider.activateProviderProfile({ name: "test-config" }, { suppressStatePost: true }) + expect(postStateSpy).not.toHaveBeenCalled() + + await provider.activateProviderProfile({ name: "test-config" }) + expect(postStateSpy).toHaveBeenCalledOnce() + }) + test("calls updateApiConfiguration when provider changes and syncs task.apiConfiguration", async () => { const mockTask = new Task({ ...defaultTaskOptions, diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 75edc64863..a32749f49e 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -44,7 +44,7 @@ "count": 9 } }, - "__tests__/provider-delegation.spec.ts": { + "__tests__/ClineProvider.delegation.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 9 } From 30f1cf2933655dc59c9363e8f04511c9aa5cf263 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 5 Sep 2026 16:51:29 +0000 Subject: [PATCH 6/6] fix: fail closed when delegation mode switch rejects --- .../ClineProvider.delegation.spec.ts | 52 +++++++- src/core/webview/ClineProvider.ts | 46 +++---- .../ClineProvider.apiHandlerRebuild.spec.ts | 121 +++++++++++++++++- 3 files changed, 193 insertions(+), 26 deletions(-) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 469e1d6315..087ec3f919 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -130,6 +130,48 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(current).toMatchObject({ status: "delegated", awaitingChildId: "child-1" }) }) + it("fails closed when handleModeSwitch rejects: parent stays current and no child is created", async () => { + const parentTask = makeParentTask() + const removeClineFromStack = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn() + const handleModeSwitch = vi.fn().mockRejectedValue(new Error("mode switch failed")) + const providerEmit = vi.fn() + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: providerEmit, + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack, + createTask, + handleModeSwitch, + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await expect( + ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }), + ).rejects.toThrow("mode switch failed") + + // Fail closed before the stack changes: the parent was never removed, so it + // remains the current task. + expect(removeClineFromStack).not.toHaveBeenCalled() + expect(provider.getCurrentTask()).toBe(parentTask) + + // No child was created (so none was scheduled) and no parent delegation + // metadata was committed. + expect(createTask).not.toHaveBeenCalled() + expect(taskHistoryStore.atomicReadAndUpdate).not.toHaveBeenCalled() + expect(providerEmit).not.toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", expect.anything()) + }) + it("rolls back when pending-action ownership changes before the atomic parent update", async () => { const pendingAction = { kind: "create_subtask" as const, @@ -334,7 +376,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { callOrder.push("createTask") return { taskId: "child-1", start: vi.fn(), run: childRun } }) - const handleModeSwitch = vi.fn().mockResolvedValue(undefined) + const handleModeSwitch = vi.fn(async () => { + callOrder.push("handleModeSwitch") + }) const taskHistoryStore = makeStoreStub({ atomicReadAndUpdate: vi.fn(async (_taskId: string, _updater: (h: HistoryItem) => HistoryItem) => { callOrder.push("atomicReadAndUpdate") @@ -363,8 +407,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { }) await Promise.resolve() // drain scheduler microtask so child.run() is invoked - // createTask → atomicReadAndUpdate → child.run: scheduler admits child only after metadata is persisted - expect(callOrder).toEqual(["createTask", "atomicReadAndUpdate", "child.run"]) + // handleModeSwitch → createTask → atomicReadAndUpdate → child.run: the mode + // handoff completes before the parent leaves the stack, and the scheduler + // admits the child only after metadata is persisted + expect(callOrder).toEqual(["handleModeSwitch", "createTask", "atomicReadAndUpdate", "child.run"]) }) it("implicitly severs interrupted awaited child and re-delegates when parent is already delegated", async () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ec248c5c9d..5cf89304e5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3844,6 +3844,9 @@ export class ClineProvider * - Persist parent delegation metadata * - Emit TaskDelegated (task-level; API forwards to provider/bridge) * - Create child as sole active and switch mode to child's mode + * - Fail closed if the mode-switch handoff rejects: the parent is never + * removed from the stack, so it stays the current, active task and no + * child is created or scheduled */ public async delegateParentAndOpenChild(params: { parentTaskId: string @@ -3908,7 +3911,23 @@ export class ClineProvider ) } - // 3) Enforce single-open invariant by closing/disposing the parent first + // 3) Switch provider mode to child's requested mode BEFORE disposing the parent. + // This is a null-target, non-publishing handoff (see + // PRODUCTION_PROVIDER_HANDOFF_POLICY): it applies only global mode/profile + // state and never mutates or publishes the current task, so running it while + // the parent is still focused is safe. Performing it first makes delegation + // fail closed: if the mode switch rejects, we abort before the parent is + // removed from the stack, so the parent remains the current, active task and + // no child is created or scheduled. + // The mode switch must also happen before createTask() because the Task + // constructor initializes its mode from provider.getState() during + // initializeTaskMode(). + const handoff = createProviderHandoffPlan(mode) + await this.handleModeSwitch(handoff.requestedMode, handoff.policy.targetTask, { + pendingHandoff: handoff.policy, + }) + + // 4) Enforce single-open invariant by closing/disposing the parent first // This ensures we never have >1 tasks open at any time during delegation. // Await abort completion to ensure clean disposal and prevent unhandled rejections. try { @@ -3922,24 +3941,7 @@ export class ClineProvider // Non-fatal: proceed with child creation even if parent cleanup had issues } - // 3) Switch provider mode to child's requested mode BEFORE creating the child task - // This ensures the child's system prompt and configuration are based on the correct mode. - // The mode switch must happen before createTask() because the Task constructor - // initializes its mode from provider.getState() during initializeTaskMode(). - try { - const handoff = createProviderHandoffPlan(mode) - await this.handleModeSwitch(handoff.requestedMode, handoff.policy.targetTask, { - pendingHandoff: handoff.policy, - }) - } catch (e) { - this.log( - `[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${ - (e as Error)?.message ?? String(e) - }`, - ) - } - - // 4) Create child as sole active (parent reference preserved for lineage) + // 5) Create child as sole active (parent reference preserved for lineage) // Pass initialStatus: "active" to ensure the child task's historyItem is created // with status from the start, avoiding race conditions where the task might // call attempt_completion before status is persisted separately. @@ -3956,7 +3958,7 @@ export class ClineProvider startTask: false, }) - // 5) Persist parent delegation metadata BEFORE the child starts writing. + // 6) Persist parent delegation metadata BEFORE the child starts writing. // atomicReadAndUpdate reads from the in-memory cache and writes back within a // single lock acquisition — no concurrent writer can slip between the read and // write, and the pure updater cannot re-enter the lock (no deadlock). @@ -4034,10 +4036,10 @@ export class ClineProvider throw err } - // 6) Start the child task now that parent metadata is safely persisted. + // 7) Start the child task now that parent metadata is safely persisted. scheduleTask(this.taskScheduler, child, "delegateParentAndOpenChild") - // 7) Emit TaskDelegated (provider-level) + // 8) Emit TaskDelegated (provider-level) try { this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId) } catch { diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 1f16a63213..dc7e8e1617 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode" import { TelemetryService } from "@roo-code/telemetry" -import { getModelId, RooCodeEventName } from "@roo-code/types" +import { getModelId, RooCodeEventName, type HistoryItem } from "@roo-code/types" import { ContextProxy } from "../../config/ContextProxy" import type { Mode } from "../../../shared/modes" @@ -882,6 +882,125 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { }) }) + describe("delegateParentAndOpenChild - nested root handoff", () => { + test("real mode-switch handoff publishes no state and leaves the exposed root task untouched", async () => { + // Nested registry topology: root at the bottom, parent focused on top. + const rootTask = new Task(defaultTaskOptions) + Object.defineProperty(rootTask, "taskId", { value: "root-task-id" }) + rootTask["_taskMode"] = "code" as Mode + rootTask["_taskApiConfigName"] = "test-config" + + const parentTask = new Task(defaultTaskOptions) + Object.defineProperty(parentTask, "taskId", { value: "parent-task-id" }) + parentTask["_taskMode"] = "code" as Mode + Object.defineProperty(parentTask, "flushPendingToolResultsToHistory", { + value: vi.fn().mockResolvedValue(true), + }) + + await provider.addClineToStack(rootTask) + await provider.addClineToStack(parentTask) + expect(provider.getCurrentTask()).toBe(parentTask) + + // External system only: the store executes the delegation updater and + // returns the parent and root histories. + const parentHistory: HistoryItem = { + id: "parent-task-id", + number: 2, + ts: 2, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "active", + mode: "code", + childIds: [], + } + const rootHistory: HistoryItem = { + id: "root-task-id", + number: 1, + ts: 1, + task: "Root", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status: "active", + mode: "code", + childIds: ["parent-task-id"], + } + const atomicUpdateSpy = vi + .spyOn(provider.taskHistoryStore, "atomicReadAndUpdate") + .mockImplementation(async (_taskId: string, updater: (current: HistoryItem) => HistoryItem) => [ + updater(parentHistory), + rootHistory, + ]) + + // createTask double: an inert child whose insertion reproduces the real + // stack transition through the real addClineToStack. + const child = new Task({ ...defaultTaskOptions }) + Object.defineProperty(child, "taskId", { value: "child-task-id" }) + child["_taskMode"] = "code" as Mode + Object.defineProperty(child, "run", { value: vi.fn().mockResolvedValue(undefined) }) + const createTaskSpy = vi.spyOn(provider, "createTask").mockImplementation(async () => { + await provider.addClineToStack(child) + return child + }) + + // Snapshot the newly exposed root task before delegation. + const rootTaskModeBefore = rootTask["_taskMode"] + const rootApiConfigurationBefore = rootTask.apiConfiguration + const rootStickyProfileBefore = rootTask["_taskApiConfigName"] + const rootClineMessagesBefore = rootTask.clineMessages + const rootApiHistoryBefore = rootTask.apiConversationHistory + + // Spy without replacing the implementation: the pending handoff must not + // publish any state. + const postStateSpy = vi.spyOn(provider, "postStateToWebview") + + // Exercise the real handleModeSwitch/handleModeSwitchUnlocked path with + // profile activation for the child's mode. + provider["providerSettingsManager"].getModeConfigId = vi.fn().mockResolvedValue("test-id") + + const childResult = await provider.delegateParentAndOpenChild({ + parentTaskId: "parent-task-id", + message: "Do child work", + initialTodos: [], + mode: "ask", + }) + // Drain the fire-and-forget scheduler so the inert child start settles. + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(childResult).toBe(child) + expect(createTaskSpy).toHaveBeenCalledWith("Do child work", undefined, parentTask, { + initialTodos: [], + initialStatus: "active", + startTask: false, + }) + expect(atomicUpdateSpy).toHaveBeenCalledTimes(1) + + // The real mode switch applied the child's mode globally without a single + // state publication during the entire delegation. + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "ask") + expect(postStateSpy).not.toHaveBeenCalled() + + // The stack transitioned parent -> child through the real addClineToStack, + // exposing the root beneath. + expect(provider.getCurrentTaskStack()).toEqual(["root-task-id", "child-task-id"]) + expect(provider.getCurrentTask()).toBe(child) + + // The exposed root task kept its identity and values: no mode, profile, + // API configuration, or history mutation. + expect(rootTask["_taskMode"]).toBe(rootTaskModeBefore) + expect(rootTask.apiConfiguration).toBe(rootApiConfigurationBefore) + expect(rootTask.apiConfiguration).toEqual(rootApiConfigurationBefore) + expect(rootTask["_taskApiConfigName"]).toBe(rootStickyProfileBefore) + expect(rootTask.clineMessages).toBe(rootClineMessagesBefore) + expect(rootTask.apiConversationHistory).toBe(rootApiHistoryBefore) + expect(rootTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(rootTask.setTaskApiConfigName).not.toHaveBeenCalled() + }) + }) + describe("profile switching sequence", () => { test("A -> B -> A updates task.apiConfiguration each time", async () => { const mockTask = new Task({