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__/ClineProvider.delegation.spec.ts similarity index 88% rename from src/__tests__/provider-delegation.spec.ts rename to src/__tests__/ClineProvider.delegation.spec.ts index 0b7aef8775..087ec3f919 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -1,10 +1,11 @@ -// 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" 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", @@ -129,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, @@ -249,8 +292,12 @@ 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. + 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 () => { @@ -329,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") @@ -358,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/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 463df8a0bb..57f326c464 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -2,6 +2,17 @@ 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, + publishProviderHandoffState, + shouldPublishProviderHandoffState, + 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..1d790d644a --- /dev/null +++ b/src/core/task-persistence/providerHandoff.ts @@ -0,0 +1,88 @@ +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: 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 + 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, + } +} + +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 394da7c10f..5cf89304e5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -116,8 +116,13 @@ import { TaskHistoryStore, abandonDelegatedChild, completeDelegatedChild, + createProviderHandoffPlan, + decideProviderHandoffProfile, delegateTaskToChild, + getProviderHandoffActivationOptions, interruptDelegatedChild, + publishProviderHandoffState, + type ProviderHandoffPolicy, } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" @@ -1711,15 +1716,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: { pendingHandoff?: ProviderHandoffPolicy } = {}, + ) { 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: { pendingHandoff?: ProviderHandoffPolicy }, signal?: AbortSignal, ): Promise { const task = targetTask @@ -1759,9 +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 (targetTask !== null) { - await this.postStateToWebview() - } + await publishProviderHandoffState(targetTask !== null, options.pendingHandoff, () => + this.postStateToWebview(), + ) return } @@ -1791,11 +1801,18 @@ export class ClineProvider const hasActualSettings = !!fullProfile.apiProvider if (hasActualSettings) { - await this.activateProviderProfileUnlocked( - { name: profile.name }, - targetTask === null ? { skipCurrentTaskRebuild: true } : undefined, - signal, - ) + 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 + ? { skipCurrentTaskRebuild: true } + : undefined + await this.activateProviderProfileUnlocked({ name: profileName }, activationOptions, signal) } else { // The task will continue with the current/default configuration. } @@ -1806,18 +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) - - if (config?.id) { - await this.providerSettingsManager.setModeConfig(newMode, config.id) - } + const config = listApiConfig.find((candidate) => candidate.name === currentApiConfigNameAfter) + const configId = options.pendingHandoff + ? decideProviderHandoffProfile({ + locked: false, + currentProfile: currentApiConfigNameAfter + ? { name: currentApiConfigNameAfter, id: config?.id } + : undefined, + }).persistModeProfileId + : config?.id + + if (configId) { + await this.providerSettingsManager.setModeConfig(newMode, configId) } } - if (targetTask !== null) { - await this.postStateToWebview() - } + await publishProviderHandoffState(targetTask !== null, options.pendingHandoff, () => this.postStateToWebview()) } // Provider Profile Management @@ -1993,6 +2014,8 @@ export class ClineProvider persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + applyProviderSettingsToContext?: boolean + suppressStatePost?: boolean }, ) { return this.enqueueProviderProfileMutation((signal) => @@ -2006,6 +2029,8 @@ export class ClineProvider persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + applyProviderSettingsToContext?: boolean + suppressStatePost?: boolean }, signal?: AbortSignal, ): Promise { @@ -2016,8 +2041,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 +2068,7 @@ export class ClineProvider await this.persistStickyProviderProfileToCurrentTask(name, { skipCurrentTaskRebuild }) } - if (!skipCurrentTaskRebuild) { + if (!skipCurrentTaskRebuild && !suppressStatePost) { await this.postStateToWebview() } @@ -3817,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 @@ -3881,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 { @@ -3895,21 +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 { - await this.handleModeSwitch(mode as any) - } 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. @@ -3926,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). @@ -4004,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 99d254cb9a..dc7e8e1617 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -3,11 +3,12 @@ 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" 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" @@ -612,6 +613,140 @@ 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) + unrelatedTask["_taskMode"] = "code" as Mode + 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 updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory") + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + postStateSpy.mockClear() + + await provider.handleModeSwitch("ask" as Mode, null, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }) + + 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" }), + ) + expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledWith({ name: "ask-profile" }) + 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 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 + 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() + + 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() + expect(unrelatedTask["_taskMode"]).toBe("code") + 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 + 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, { + pendingHandoff: PRODUCTION_PROVIDER_HANDOFF_POLICY, + }) + + 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() + + 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 () => { const mockTask = new Task({ ...defaultTaskOptions, @@ -655,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, @@ -734,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({ diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 393e108645..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 } @@ -1026,7 +1026,7 @@ }, "core/webview/ClineProvider.ts": { "@typescript-eslint/no-explicit-any": { - "count": 12 + "count": 11 } }, "core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts": {