diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index b4f9f47b55..66c5461307 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -25,12 +25,11 @@ jobs: echo "## Changed-code mutation testing" >> "$GITHUB_STEP_SUMMARY" echo "Mutation testing was enforced on each pull request before it entered the merge queue." >> "$GITHUB_STEP_SUMMARY" - - name: Checkout pull request head + - name: Checkout pull request merge result if: github.event_name == 'pull_request' uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} + ref: refs/pull/${{ github.event.pull_request.number }}/merge fetch-depth: 0 persist-credentials: false diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index 588ffd5204..54d2e97f53 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 8431467918..aaaa14526a 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", + "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-provider-handoff.ts && tsx scripts/check-task-store-concurrency.ts", "test:coverage": "turbo test:coverage --log-order grouped --output-logs new-only", "format": "turbo format --log-order grouped --output-logs new-only", "build": "turbo build --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/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index ede7d9defe..862f8f3cb1 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -304,14 +304,23 @@ export function preferDirectTestFiles(testFiles, sourceFiles) { return direct.length > 0 ? direct : testFiles } -function discoverRelatedTestFiles(repoRoot, packageEntry, reportDirectory) { +export function resolveVitestBinary(repoRoot, packageEntry) { + const packageRoot = path.join(repoRoot, packageEntry.root) + const runRoot = path.join(repoRoot, packageEntry.runRoot ?? packageEntry.root) + const candidates = [...new Set([runRoot, packageRoot, repoRoot])].map((root) => + path.join(root, "node_modules/.bin/vitest"), + ) + return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates.at(-1) +} + +export function discoverRelatedTestFiles(repoRoot, packageEntry, reportDirectory) { const packageRoot = path.join(repoRoot, packageEntry.root) const runRoot = path.join(repoRoot, packageEntry.runRoot ?? packageEntry.root) const outputFile = path.join(reportDirectory, "vitest-related.json") const configFile = path.relative(runRoot, path.join(packageRoot, packageEntry.vitestConfig)).replaceAll("\\", "/") const sourceFiles = [...new Set(packageEntry.selectors.map(selectorFile))] const result = spawnSync( - path.join(repoRoot, "node_modules/.bin/vitest"), + resolveVitestBinary(repoRoot, packageEntry), ["related", ...sourceFiles, "--run", "--config", configFile, "--reporter=json", `--outputFile=${outputFile}`], { cwd: runRoot, @@ -325,6 +334,9 @@ function discoverRelatedTestFiles(repoRoot, packageEntry, reportDirectory) { if (result.error?.code === "ETIMEDOUT") { throw new Error(`${packageEntry.id} related-test discovery exceeded 5 minutes`) } + if (result.error) { + throw new Error(`${packageEntry.id} related-test discovery could not start: ${result.error.message}`) + } if (result.status !== 0) { throw new Error( `${packageEntry.id} related-test discovery failed:\n${stripAnsi(`${result.stdout ?? ""}${result.stderr ?? ""}`).trim()}`, @@ -378,6 +390,11 @@ function runStryker(repoRoot, packageEntry, reportRoot, dryRunOnly) { `${packageEntry.id} mutation run exceeded 12 minutes. Split the PR or obtain a maintainer-reviewed narrow exclusion.`, ) } + if (result.error) { + throw new Error( + `${packageEntry.id} Stryker ${dryRunOnly ? "preflight" : "run"} could not start: ${result.error.message}`, + ) + } if (result.status !== 0) { throw new Error( `${packageEntry.id} Stryker ${dryRunOnly ? "preflight" : "run"} failed:\n${stripAnsi(output).trim()}`, diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index af1687ec68..e5cb3fb66c 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -4,11 +4,14 @@ import fs from "node:fs" import os from "node:os" import path from "node:path" import { describe, it } from "node:test" +import { fileURLToPath } from "node:url" import { MAX_CHANGED_LINES, MAX_MUTANTS, + PACKAGE_CONFIGS, buildManifest, + discoverRelatedTestFiles, evaluateReport, executableChangedLines, formatAnnotations, @@ -17,11 +20,30 @@ import { parseNameStatus, parseVitestTestFiles, preferDirectTestFiles, + resolveVitestBinary, packageForPath, selectFromGit, validateDisableDirectives, } from "./stryker-diff.mjs" +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") + +describe("mutation testing workflow", () => { + it("checks out the pull request merge result from the base repository", () => { + const workflow = fs.readFileSync(path.join(repositoryRoot, ".github/workflows/mutation-testing.yml"), "utf8") + + assert.ok(workflow.includes(" pull_request:")) + assert.ok(!workflow.includes("pull_request_target:")) + assert.ok(workflow.includes(" contents: read")) + assert.ok(workflow.includes("- name: Checkout pull request merge result")) + assert.ok(workflow.includes("ref: refs/pull/${{ github.event.pull_request.number }}/merge")) + assert.ok(workflow.includes("fetch-depth: 0")) + assert.ok(workflow.includes("persist-credentials: false")) + assert.ok(!workflow.includes("repository: ${{ github.event.pull_request.head.repo.full_name }}")) + assert.ok(!workflow.includes("ref: ${{ github.event.pull_request.head.sha }}")) + }) +}) + describe("parseNameStatus", () => { it("parses added, modified, and renamed paths", () => { assert.deepEqual( @@ -178,6 +200,55 @@ describe("preferDirectTestFiles", () => { }) }) +describe("related-test discovery", () => { + it("resolves Vitest from each package before falling back to the repository", () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-vitest-")) + const extension = PACKAGE_CONFIGS.find(({ id }) => id === "extension") + const webview = PACKAGE_CONFIGS.find(({ id }) => id === "webview") + const extensionBinary = path.join(repo, "src/node_modules/.bin/vitest") + const webviewBinary = path.join(repo, "webview-ui/node_modules/.bin/vitest") + const rootBinary = path.join(repo, "node_modules/.bin/vitest") + + try { + fs.mkdirSync(path.dirname(extensionBinary), { recursive: true }) + fs.mkdirSync(path.dirname(webviewBinary), { recursive: true }) + fs.writeFileSync(extensionBinary, "") + fs.writeFileSync(webviewBinary, "") + + assert.equal(resolveVitestBinary(repo, extension), extensionBinary) + assert.equal(resolveVitestBinary(repo, webview), webviewBinary) + + fs.rmSync(extensionBinary) + fs.mkdirSync(path.dirname(rootBinary), { recursive: true }) + fs.writeFileSync(rootBinary, "") + assert.equal(resolveVitestBinary(repo, extension), rootBinary) + } finally { + fs.rmSync(repo, { recursive: true, force: true }) + } + }) + + it("reports a Vitest launch error when no binary exists", () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-vitest-")) + const reportDirectory = path.join(repo, "reports") + const packageEntry = { + id: "extension", + root: "src", + vitestConfig: "vitest.config.ts", + selectors: ["utils/value.ts:1-1"], + } + + try { + fs.mkdirSync(path.join(repo, "src"), { recursive: true }) + assert.throws( + () => discoverRelatedTestFiles(repo, packageEntry, reportDirectory), + /extension related-test discovery could not start:.*ENOENT/, + ) + } finally { + fs.rmSync(repo, { recursive: true, force: true }) + } + }) +}) + describe("selectFromGit", () => { it("derives changed executable ranges from the base/head merge base", () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-diff-")) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 0b7aef8775..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", @@ -249,8 +250,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 () => { diff --git a/src/api/index.ts b/src/api/index.ts index f45412e32c..98c3c5dc7b 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -236,7 +236,7 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { case providerIdentifiers.poe: return new PoeHandler(options) case providerIdentifiers.geminiCli: - // Intentionally falls through to the Anthropic handler pending a dedicated Gemini CLI handler implementation. + // Intentionally falls through to the Anthropic handler pending a dedicated Gemini CLI handler implementation. default: return new AnthropicHandler(options) } 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 0a251aba5f..f21b6385e1 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" @@ -1696,15 +1700,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 @@ -1744,7 +1753,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) { + 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 @@ -1776,11 +1795,21 @@ export class ClineProvider const hasActualSettings = !!fullProfile.apiProvider if (hasActualSettings) { - await this.activateProviderProfileUnlocked( - { name: profile.name }, - targetTask === null ? { skipCurrentTaskRebuild: true } : undefined, - signal, - ) + 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: profileName }, activationOptions, signal) } else { // The task will continue with the current/default configuration. } @@ -1793,14 +1822,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) { + if (targetTask !== null && (options.pendingHandoff?.publishWhilePending ?? true)) { await this.postStateToWebview() } } @@ -1978,6 +2018,8 @@ export class ClineProvider persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + applyProviderSettingsToContext?: boolean + suppressStatePost?: boolean }, ) { return this.enqueueProviderProfileMutation((signal) => @@ -1991,6 +2033,8 @@ export class ClineProvider persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + applyProviderSettingsToContext?: boolean + suppressStatePost?: boolean }, signal?: AbortSignal, ): Promise { @@ -2001,8 +2045,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()), @@ -2026,7 +2072,7 @@ export class ClineProvider await this.persistStickyProviderProfileToCurrentTask(name, { skipCurrentTaskRebuild }) } - if (!skipCurrentTaskRebuild) { + if (!skipCurrentTaskRebuild && !suppressStatePost) { await this.postStateToWebview() } @@ -3885,7 +3931,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 as any) + 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 99d254cb9a..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" @@ -612,6 +613,92 @@ 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(setProviderSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ openRouterModelId: "openai/gpt-4.1-mini" }), + ) + 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) + 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, { + 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 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() + }) + test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => { const mockTask = new Task({ ...defaultTaskOptions, diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 0706dbe6fb..781840a0f0 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": {