diff --git a/.changeset/applying-a-configuration-turns-act-off.md b/.changeset/applying-a-configuration-turns-act-off.md new file mode 100644 index 00000000..39fc40fb --- /dev/null +++ b/.changeset/applying-a-configuration-turns-act-off.md @@ -0,0 +1,8 @@ +--- +"@openspec-ui/core": patch +"@openspec-ui/webui": patch +"@openspec-ui/server": patch +"openspec-ui-vscode": patch +--- + +Applying a named configuration to a change under the supervisor's Act no longer fails (applying-a-configuration-turns-act-off). Every named configuration sets Semi-autonomous, and Act acts only under Autonomous, so applying one - or saving a change's Harness Settings with another autonomy level - turns Act off instead of refusing the write, keeps the fallback agents and allowances, and says so where it says what was applied or saved. A file written by hand that sets both Act and another level is not run, but it now opens in the change's Harness Settings, which put it right on Save. diff --git a/HARNESS.md b/HARNESS.md index c67e2af7..1cc3c0c1 100644 --- a/HARNESS.md +++ b/HARNESS.md @@ -348,6 +348,18 @@ the change has one, and shows the fallback fields and the two allowances with a note on cost and providers. `openspec-ui-cli run` prints the move, and the reason under the next attempt's heading. +**An edit that lowers the level turns Act off** +(applying-a-configuration-turns-act-off). Applying a named configuration +to a change - every one sets Semi-autonomous - or saving a change's +Harness Settings with an autonomy level other than Autonomous removes +`supervisor.mode` where it was `act`, so the change follows the +workspace's mode, and says so where it says what was applied or saved. +The fallback agents and the two allowances stay in the file: choose +Autonomous and Act again to bring it back as it was. Only a file written +by hand that says both `act` and another level is not run and is not +written, since nothing chose between them; it still opens in the change's +Harness Settings, which say Act turns off and put it right on Save. + ### `branches` `{ "rebaseWhenBehind"?: , "followMain"?: }`. Optional; diff --git a/openspec/changes/applying-a-configuration-turns-act-off/.openspec.yaml b/openspec/changes/applying-a-configuration-turns-act-off/.openspec.yaml new file mode 100644 index 00000000..b681b9ac --- /dev/null +++ b/openspec/changes/applying-a-configuration-turns-act-off/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +created: 2026-10-05 +follows: + - the-supervisor-changes-agents diff --git a/openspec/changes/applying-a-configuration-turns-act-off/design.md b/openspec/changes/applying-a-configuration-turns-act-off/design.md new file mode 100644 index 00000000..7a32518a --- /dev/null +++ b/openspec/changes/applying-a-configuration-turns-act-off/design.md @@ -0,0 +1,49 @@ +## Context + +`the-supervisor-changes-agents` accepts `supervisor.mode: "act"` only in a +change's own file that also sets `autonomyLevel: "autonomous"` +(ADR 0018: what widens authority is per-change only). The rule is checked +when a file is written and when it resolves. Applying a named +configuration lays the configuration over the change's file and keeps +every other key (applying-a-template-keeps-the-rest); every named +configuration sets `semi-autonomous`; so the result kept Act and lost +Autonomous, and was refused. + +## Decisions + +1. **An edit that lowers the level turns Act off.** One function in core, + `withoutActItCannotUse(config)`, removes `supervisor.mode` where it is + `act` and the same config's `autonomyLevel` is not `autonomous`, and + says whether it did. A `supervisor` left empty is removed. The fallback + agents and the allowances stay: they do nothing without Act, and they + are what a person set. + - *Alternative: refuse with a clearer message.* Rejected by the owner: + applying a configuration is a decision to change the level, and the + configuration should apply. + - *Alternative: keep Autonomous instead.* Rejected: it would widen what + the configuration says, which is the direction ADR 0018 forbids + guessing in. +2. **Where it applies.** `changeTemplateConfigToWrite`, the one function + every surface applies a configuration to a change through (extension, + standalone settings view, standalone run dialog), and + `changeConfigToSave`, which a change's Harness Settings save through. +3. **It is said.** The extension's applied note and the settings view's + applied message add that the supervisor's Act is off, because it acts + only under Autonomous and the configuration sets another level. The + settings view's note under the supervisor, which said saving would be + refused, says Act turns off when the form is saved. +4. **A hand-written contradiction is refused where it would do something, + and opens where it can be put right.** Found in the owner's check: the + rule was part of reading a file, so a file written by hand with Act and + a lower level could not even open in its Harness Settings, the one + place that would fix it. It moves to `actWithoutAutonomousProblem`, + checked where a change's file is written and where it resolves for a + run (which names both ways out), and not where it is read. The + settings view then opens it, says Act turns off on saving, and offers + Save with nothing else changed. The product still does not pick for a + file that says both: it refuses to run it and to write it. + +## Risks / Trade-offs + +- **A person may not notice Act went off.** It is said on the surface that + applied or saved it, and the change's Harness Settings show the mode. diff --git a/openspec/changes/applying-a-configuration-turns-act-off/proposal.md b/openspec/changes/applying-a-configuration-turns-act-off/proposal.md new file mode 100644 index 00000000..18bd04fa --- /dev/null +++ b/openspec/changes/applying-a-configuration-turns-act-off/proposal.md @@ -0,0 +1,49 @@ +## Why + +Reported by the owner on 2026-10-05: in a workspace whose changes run +under `supervisor.mode: "act"`, applying any named configuration to a +change failed with "supervisor.mode "act" needs autonomyLevel +"autonomous" in the same file". Every named configuration sets +`autonomyLevel: "semi-autonomous"`, and applying one keeps the rest of the +change's file, `supervisor` included; the file it produced broke a rule +the product itself states, so the write was refused. The same happened in +a change's Harness Settings when the autonomy level was moved away from +Autonomous while Act was chosen: the view only warned that saving would +be refused. + +The owner's rule: configurations change freely, by rules a person can +follow, and are refused only where refusing is truly needed. + +## What Changes + +- Where a person's own edit takes a change's autonomy level below + Autonomous - a named configuration applied, or the level changed in the + change's Harness Settings - an Act the change had is turned off rather + than refused: the supervisor's mode is removed, so the change follows + the workspace's (Advise unless the workspace says Off). The fallback + agents and the two allowances are kept, so choosing Act again under + Autonomous restores what was set. +- Every surface that does this says so where it says what was applied or + saved. +- A file written by hand that sets Act without Autonomous is refused + where it would act - a run - and where it is written, but it opens in + its Harness Settings, which put it right on saving: nothing chose + between the two, and only a person's save does. + +## Capabilities + +### Modified Capabilities + +- `agentic-harness`: applying a named configuration turns off an Act it + leaves without Autonomous. +- `shared-ui`: a change's Harness Settings turn Act off, and say so, when + the autonomy level leaves Autonomous. + +## Impact + +- `packages/core`: `harness-templates.ts` (the rule as one function, and + `changeTemplateConfigToWrite` applying it). +- `packages/webui`: `harness-settings-parts.tsx` (`changeConfigToSave`, the + applied message), `ChangeHarnessSettingsView.tsx` (the note). +- `packages/extension`: `commands.ts` (the applied note). +- `HARNESS.md`. diff --git a/openspec/changes/applying-a-configuration-turns-act-off/specs/agentic-harness/spec.md b/openspec/changes/applying-a-configuration-turns-act-off/specs/agentic-harness/spec.md new file mode 100644 index 00000000..94fd14b7 --- /dev/null +++ b/openspec/changes/applying-a-configuration-turns-act-off/specs/agentic-harness/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Applying a configuration turns off an Act it leaves without Autonomous + +Applying a named configuration to a change SHALL NOT fail because the +change's file has `supervisor.mode: "act"` and the configuration sets an +autonomy level other than `autonomous`. The written file SHALL instead +carry no `supervisor.mode`, SHALL keep the change's `supervisor.fallback`, +`allowCostIncrease` and `allowProviderChange`, and the surface that +applied it SHALL say that Act was turned off and why. + +A change's file that sets `act` with another level SHALL be refused where +it is written and where it resolves for a run, naming how to put it +right, and SHALL NOT be refused where it is read, so that its Harness +Settings open. + +#### Scenario: A named configuration over a change under Act + +- **WHEN** a change's file sets `autonomyLevel: "autonomous"`, + `maxStageAttempts: 2` and `supervisor` with `mode: "act"` and a fallback + for apply, and Balanced is applied to it +- **THEN** the file is written with `autonomyLevel: "semi-autonomous"`, a + `supervisor` with the fallback and no `mode`, and the applied note says + the supervisor's Act is off because Balanced sets Semi-autonomous + +#### Scenario: A hand-written contradiction + +- **WHEN** a change's `harness.json` with `supervisor.mode: "act"` and + `autonomyLevel: "semi-autonomous"` is written through the product +- **THEN** the write is refused, naming the rule + +#### Scenario: The same contradiction, written by hand + +- **WHEN** such a file is already on disk +- **THEN** reading it succeeds, and resolving it for a run fails, saying + to save the change's Harness Settings to turn Act off or to set + `autonomyLevel: "autonomous"` diff --git a/openspec/changes/applying-a-configuration-turns-act-off/specs/shared-ui/spec.md b/openspec/changes/applying-a-configuration-turns-act-off/specs/shared-ui/spec.md new file mode 100644 index 00000000..7293370b --- /dev/null +++ b/openspec/changes/applying-a-configuration-turns-act-off/specs/shared-ui/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: A change's Harness Settings turn Act off when the level leaves Autonomous + +When a change's Harness Settings have Act chosen and the autonomy level +is set to anything but Autonomous, by hand or by a named configuration, +the view SHALL say that Act turns off when the form is saved, and saving +SHALL write the change's file without `supervisor.mode`, keeping its +fallback agents and allowances. Saving SHALL NOT be refused for it. + +#### Scenario: The level moved away from Autonomous + +- **WHEN** a change under Act has its autonomy level set to Inherit in its + Harness Settings and is saved +- **THEN** the note under the supervisor said Act turns off on saving, and + the saved file has no `supervisor.mode` and the same fallback agents + +#### Scenario: A file written by hand with Act and another level + +- **WHEN** a change's Harness Settings are opened on a file that sets + `act` with `semi-autonomous` +- **THEN** they open, say Act turns off on saving, and offer Save without + any other edit; saving writes the file without `supervisor.mode` diff --git a/openspec/changes/applying-a-configuration-turns-act-off/tasks.md b/openspec/changes/applying-a-configuration-turns-act-off/tasks.md new file mode 100644 index 00000000..093b31f4 --- /dev/null +++ b/openspec/changes/applying-a-configuration-turns-act-off/tasks.md @@ -0,0 +1,76 @@ +Reported by the owner on 2026-10-05: applying any named configuration to +a change under Act failed. Rule: configurations change freely by rules a +person can follow, and are refused only where truly needed. + +## 1. Core + +- [x] 1.1 `harness-config.ts`: `withoutActItCannotUse` (design.md + decision 1). Tests in `harness-config.test.ts`: act with another level + loses its mode and keeps fallback and allowances; act under autonomous + unchanged; a `supervisor` left empty is removed; a hand-written file + with act and another level is still refused. + Placed in `harness-templates.ts`, beside the function that applies + configurations, since `harness-config.ts` is not browser-safe; with + `actTurnedOff` and `actTurnedOffNote` for the surfaces. Four cases in + `harness-templates.test.ts`; the hand-written refusal in + `harness-config.test.ts`. 266 passed across both. +- [x] 1.2 `harness-templates.ts`: `changeTemplateConfigToWrite` applies it. + Test in `harness-templates.test.ts`: every change-scope configuration + over a change under Act writes a file the validator accepts, with no + mode and the fallback kept. + Every change-scope configuration over a change under Act is written + and accepted by `writeChangeHarnessConfig` (`harness-config.test.ts`), + with no mode and the fallback kept (`harness-templates.test.ts`). +- [x] 1.3 Found in the owner's check of 4.3: a file written by hand with + Act and another level did not open in its Harness Settings ("Load + failed"), since the rule was part of reading it. `harness-config.ts`: + `actWithoutAutonomousProblem`, checked on write and on resolve, not on + read (design.md decision 4); the settings view offers Save on such a + file with nothing else changed. Tests: `harness-config.test.ts` (read + succeeds, resolve refuses and names the way out), 267 passed with + `harness-templates`; `ChangeHarnessSettingsView.test.tsx`, 32 passed. + +## 2. Surfaces + +- [x] 2.1 `packages/webui`: `changeConfigToSave` applies it; the + supervisor's note says Act turns off on saving; the applied message + says so after a configuration. Tests in + `ChangeHarnessSettingsView.test.tsx`. + `changeConfigToSave` and the applied message; the standalone run + dialog's applied note too (`applyTemplateToChange` returns whether Act + went off). Two cases replace the old "will be refused" one; 31 passed + in the file, webui 713. +- [x] 2.2 `packages/extension`: the applied note says Act was turned off. + Test beside the command's existing tests. + One case in `commands.test.ts`, 163 passed. + +## 3. Documents + +- [x] 3.1 `HARNESS.md`, `supervisor`, Act: what turns it off. + "An edit that lowers the level turns Act off". +- [x] 3.2 A changeset: core, webui, server, extension, patch. + `.changeset/applying-a-configuration-turns-act-off.md`. + +## 4. Checks + +- [x] 4.1 `npm run typecheck && npm run lint`, and every test project, each + on its own where the root run would exceed a background limit. + Typecheck clean; lint 0 errors (3 warnings in lines this change did + not touch); seven script tests pass. Core 2101, core-git-subprocess 67, + cli 201, webui 713, server 122, extension 503 passed. After 1.3: core + 2102, core-git-subprocess 67, cli 201, webui 714, server 122, extension + 503 passed; typecheck and lint as before. +- [x] 4.2 `openspec validate applying-a-configuration-turns-act-off + --strict`, and the merge gate with the worktree's absolute path as + `--cwd`. + Valid; the gate with `--cwd C:/Prog/.worktrees/OpenSpec-UI/applying-a-configuration-turns-act-off` + reports only 4.3 open (Human-only). +- [x] 4.3 **Human-only**: in the owner's workspace, apply a named + configuration to a change under Act, and change its autonomy level in + its Harness Settings; both apply, and say Act is off. + Closed by the owner on 2026-10-06 in an Extension Development Host on a + copy of their workspace's changes: a named configuration applied and an + autonomy level changed over Act, both saved and said Act is off; Act + came back with its fallbacks; the hand-written contradiction opened and + was put right on Save. Ticked on the owner's word in chat: everything + works, close it. diff --git a/packages/core/src/browser.ts b/packages/core/src/browser.ts index eba9f91a..c7e79e59 100644 --- a/packages/core/src/browser.ts +++ b/packages/core/src/browser.ts @@ -139,7 +139,7 @@ export type { ChangeDateEvidence, ChangeDateSource, ChangeDates, DatedFact } fro // Pure over a resolved config, with only type imports from the Node-side // modules — safe in the browser bundle, unlike harness-config.js itself. export { findHarnessConfigLimits, groupHarnessFindings } from "./harness-config-findings.js"; -export { agentForEveryStageToWrite, changeTemplateConfigToWrite, HARNESS_TEMPLATES, stepAgentsForTemplate, templateConfigToWrite, templatesForScope } from "./harness-templates.js"; +export { actTurnedOff, actTurnedOffNote, agentForEveryStageToWrite, changeTemplateConfigToWrite, HARNESS_TEMPLATES, stepAgentsForTemplate, templateConfigToWrite, templatesForScope, withoutActItCannotUse } from "./harness-templates.js"; export { HARNESS_EFFORT_LEVELS, effortLevelCollisions, resolveEffortLevel } from "./harness-effort-level.js"; export type { HarnessEffortLevel, ResolvedEffort } from "./harness-effort-level.js"; export { recommendTemplate } from "./harness-recommendation.js"; diff --git a/packages/core/src/harness-config.test.ts b/packages/core/src/harness-config.test.ts index 00300497..eb186d42 100644 --- a/packages/core/src/harness-config.test.ts +++ b/packages/core/src/harness-config.test.ts @@ -35,7 +35,7 @@ import { // merged a stage entry by spread and kept `customAgent`, `mergeStepAgent` // named three fields and dropped it, so the same entry came out of the two // differently. See a-stage-override-keeps-its-custom-agent. -import { HARNESS_TEMPLATES, templateConfigToWrite } from "./harness-templates.js"; +import { changeTemplateConfigToWrite, HARNESS_TEMPLATES, templateConfigToWrite, templatesForScope } from "./harness-templates.js"; // suite-survives-a-loaded-machine: // measured 2026-09-05 for this file alone at 734ms test time (3.91s wall) @@ -1104,6 +1104,33 @@ describe("supervisor act", () => { await expect(write({ apply: [] })).rejects.toThrow(/non-empty list/); }); + // applying-a-configuration-turns-act-off 1.1, 1.2: what a named + // configuration writes over a change under Act is a file that is accepted; + // the same contradiction written by hand is still refused. + it("accepts what a named configuration writes over Act, and refuses the contradiction by hand", async () => { + const root = await temporaryRoot(); + const own = { autonomyLevel: "autonomous" as const, maxStageAttempts: 2, supervisor: act }; + await writeChangeHarnessConfig(root, "demo", own); + for (const template of templatesForScope("change")) { + await writeChangeHarnessConfig(root, "demo", changeTemplateConfigToWrite(template, await resolveHarnessConfig(root), own)); + } + await expect(writeChangeHarnessConfig(root, "demo", { ...own, autonomyLevel: "semi-autonomous" })) + .rejects.toThrow(/needs autonomyLevel "autonomous" in the same file/); + }); + + // applying-a-configuration-turns-act-off: a hand-written contradiction + // opens, so it can be put right in its Harness Settings, and nothing runs + // on it. + it("reads a hand-written Act without Autonomous, and refuses to resolve it for a run", async () => { + const root = await temporaryRoot(); + const file = path.join(root, "openspec", "changes", "demo", "harness.json"); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify({ autonomyLevel: "semi-autonomous", maxStageAttempts: 2, supervisor: act }), "utf8"); + + expect((await readChangeHarnessConfig(root, "demo"))?.supervisor?.mode).toBe("act"); + await expect(resolveHarnessConfig(root, "demo")).rejects.toThrow(/Open the change's Harness Settings and save to turn Act off/); + }); + it("refuses the allowances in the workspace's file", async () => { const root = await temporaryRoot(); await expect(writeGlobalHarnessConfig(root, { supervisor: { allowCostIncrease: true } } as never)) diff --git a/packages/core/src/harness-config.ts b/packages/core/src/harness-config.ts index 5c0db640..77199427 100644 --- a/packages/core/src/harness-config.ts +++ b/packages/core/src/harness-config.ts @@ -150,6 +150,18 @@ export function resolveSupervisor(value: HarnessSupervisor | undefined): Resolve /** Why a resolved configuration's `act` cannot act, or `undefined`. Checked * where the configuration resolves, because the attempts it spends may come * from the other file (the-supervisor-changes-agents). */ +/** Why a change's own file sets an `act` it cannot have, or `undefined`: + * Act needs `autonomyLevel: "autonomous"` in the same file (ADR 0018). + * Refused where the file is written and where it resolves for a run, and + * not where it is read: a file written by hand that says both has to open + * in its Harness Settings to be put right there + * (applying-a-configuration-turns-act-off). */ +export function actWithoutAutonomousProblem(own: Partial | undefined): string | undefined { + if (own?.supervisor?.mode !== "act" || own.autonomyLevel === "autonomous") return undefined; + return 'supervisor.mode "act" needs autonomyLevel "autonomous" in the same file: under a level that asks a person, the person decides.' + + " Open the change's Harness Settings and save to turn Act off, or set autonomyLevel \"autonomous\""; +} + export function supervisorActProblem(config: Pick): string | undefined { if (config.supervisor?.mode !== "act") return undefined; const attempts = config.maxStageAttempts ?? 1; @@ -909,7 +921,6 @@ const SUPERVISOR_KEYS = ["mode", "silentAfterSeconds", "waitingAfterSeconds", "f function assertValidSupervisor( value: unknown, isPerChangeFile: boolean, - autonomyLevel: HarnessAutonomyLevel | undefined, ): asserts value is HarnessSupervisor | undefined { if (value === undefined) return; if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -928,9 +939,6 @@ function assertValidSupervisor( if (!isPerChangeFile) { throw new InvalidHarnessConfigError('supervisor.mode "act" is set in a change\'s own harness.json, never for the whole workspace: it may start another agent unattended'); } - if (autonomyLevel !== "autonomous") { - throw new InvalidHarnessConfigError('supervisor.mode "act" needs autonomyLevel "autonomous" in the same file: under a level that asks a person, the person decides'); - } } for (const key of ["silentAfterSeconds", "waitingAfterSeconds"] as const) { const seconds = record[key]; @@ -1182,7 +1190,7 @@ function assertValidHarnessConfigInput( assertValidReviewGate(input.reviewGate, isPerChangeFile); assertValidCheckpoints(input.checkpoints, isPerChangeFile); assertValidHints((input as { hints?: unknown }).hints); - assertValidSupervisor((input as { supervisor?: unknown }).supervisor, isPerChangeFile, input.autonomyLevel); + assertValidSupervisor((input as { supervisor?: unknown }).supervisor, isPerChangeFile); assertValidMaxCost((input as { budget?: { maxCost?: unknown } }).budget?.maxCost); const allowAgentMessages = (input as { allowAgentMessages?: unknown }).allowAgentMessages; if (allowAgentMessages !== undefined && typeof allowAgentMessages !== "boolean") { @@ -1490,6 +1498,12 @@ export async function resolveHarnessConfig(workspaceRoot: string, changeName?: s } }); + // Act without Autonomous in the change's own file is read, so its + // settings can open, and refused here, so nothing runs on it + // (applying-a-configuration-turns-act-off). + const contradiction = actWithoutAutonomousProblem(override); + if (contradiction !== undefined) throw new InvalidHarnessConfigError(contradiction); + // `act` spends attempts, which may be set in either file // (the-supervisor-changes-agents). const actProblem = supervisorActProblem(merged); @@ -1525,5 +1539,7 @@ export async function writeChangeHarnessConfig( ): Promise { const migrated = migrateLegacyDispatchInConfig(config); assertValidHarnessConfigInput(migrated.value, true); + const contradiction = actWithoutAutonomousProblem(migrated.value as Partial); + if (contradiction !== undefined) throw new InvalidHarnessConfigError(contradiction); await writeJsonFile(changeHarnessConfigPath(workspaceRoot, changeName), migrated.value); } diff --git a/packages/core/src/harness-templates.test.ts b/packages/core/src/harness-templates.test.ts index cdbed848..550e799d 100644 --- a/packages/core/src/harness-templates.test.ts +++ b/packages/core/src/harness-templates.test.ts @@ -4,7 +4,16 @@ import { DEFAULT_HARNESS_CONFIG, type HarnessConfig } from "./harness-config.js" import { HARNESS_AGENT_CAPABILITIES, normalizeStepAgent } from "./harness-step-agent.js"; import { HARNESS_EFFORT_LEVELS, resolveEffortLevel } from "./harness-effort-level.js"; import type { HarnessStepAgent, HarnessStepAgents } from "./harness-step-agent.js"; -import { agentForEveryStageToWrite, changeTemplateConfigToWrite, HARNESS_TEMPLATES, stepAgentsForTemplate, templateConfigToWrite, templatesForScope } from "./harness-templates.js"; +import { + actTurnedOff, + agentForEveryStageToWrite, + changeTemplateConfigToWrite, + HARNESS_TEMPLATES, + stepAgentsForTemplate, + templateConfigToWrite, + templatesForScope, + withoutActItCannotUse, +} from "./harness-templates.js"; // settings-templates: // pure over in-memory data — no files, no processes. Measured 2026-09-08 @@ -459,3 +468,56 @@ describe("agentForEveryStageToWrite — a recommendation from runs, applied", () expect(written.maxStageAttempts).toBe(3); }); }); + +// applying-a-configuration-turns-act-off 1.1, 1.2 +describe("withoutActItCannotUse — an edit that lowers the level turns Act off", () => { + const underAct: Partial = { + autonomyLevel: "autonomous", + maxStageAttempts: 2, + supervisor: { + mode: "act", + fallback: { apply: ["deepseek-cli-acp", "copilot-cli-acp"] }, + allowCostIncrease: true, + allowProviderChange: true, + }, + }; + + it("removes the mode under another level, and keeps the fallback and the allowances", () => { + expect(withoutActItCannotUse({ ...underAct, autonomyLevel: "semi-autonomous" })).toEqual({ + autonomyLevel: "semi-autonomous", + maxStageAttempts: 2, + supervisor: { + fallback: { apply: ["deepseek-cli-acp", "copilot-cli-acp"] }, + allowCostIncrease: true, + allowProviderChange: true, + }, + }); + const { autonomyLevel: _inherited, ...inheriting } = underAct; + expect(withoutActItCannotUse(inheriting).supervisor).not.toHaveProperty("mode"); + }); + + it("leaves Act under autonomous, and every other mode, as it was", () => { + expect(withoutActItCannotUse(underAct)).toBe(underAct); + const advising: Partial = { autonomyLevel: "assisted", supervisor: { mode: "off" } }; + expect(withoutActItCannotUse(advising)).toBe(advising); + }); + + it("removes a supervisor left with nothing in it", () => { + expect(withoutActItCannotUse({ autonomyLevel: "semi-autonomous", supervisor: { mode: "act" } })).toEqual({ autonomyLevel: "semi-autonomous" }); + }); + + it("is applied by every named configuration a change may be given, and says so", () => { + for (const template of templatesForScope("change")) { + const written = changeTemplateConfigToWrite(template, DEFAULT_HARNESS_CONFIG, underAct); + expect(written.autonomyLevel, template.id).not.toBe("autonomous"); + expect(written.supervisor, template.id).toEqual({ + fallback: { apply: ["deepseek-cli-acp", "copilot-cli-acp"] }, + allowCostIncrease: true, + allowProviderChange: true, + }); + expect(actTurnedOff(underAct, written), template.id).toBe(true); + } + expect(actTurnedOff(underAct, underAct)).toBe(false); + expect(actTurnedOff(undefined, {})).toBe(false); + }); +}); diff --git a/packages/core/src/harness-templates.ts b/packages/core/src/harness-templates.ts index 1f268ca3..4869afb5 100644 --- a/packages/core/src/harness-templates.ts +++ b/packages/core/src/harness-templates.ts @@ -260,11 +260,47 @@ export function changeTemplateConfigToWrite( base: HarnessConfig, override: Partial | undefined, ): Partial { - return templateConfigToWrite( + // Every named configuration sets a level below autonomous, and an Act the + // change kept would make the file one the validator refuses + // (applying-a-configuration-turns-act-off). + return withoutActItCannotUse(templateConfigToWrite( template, mergeStepAgents(base.stepAgents, override?.stepAgents), override ?? {}, - ); + )); +} + +/** A change's file with an Act it can no longer use turned off: where the + * supervisor's mode is `act` and the same file's autonomy level is not + * `autonomous`, the mode is removed, so the change follows the + * workspace's. The fallback agents and allowances stay, so choosing Act + * again under Autonomous restores what was set; a `supervisor` left empty + * is removed. Applied where a person's own edit lowered the level - a named + * configuration, the change's Harness Settings - and never to a file read + * as written, whose contradiction is refused rather than guessed at + * (applying-a-configuration-turns-act-off). */ +export function withoutActItCannotUse>(config: T): T { + if (config.supervisor?.mode !== "act" || config.autonomyLevel === "autonomous") return config; + const { mode: _off, ...rest } = config.supervisor; + const result = { ...config }; + if (Object.keys(rest).length === 0) delete result.supervisor; + else result.supervisor = rest; + return result; +} + +/** What a surface says where applying `template` turned a change's Act off: + * the same words in every host. */ +export function actTurnedOffNote(template: HarnessTemplate): string { + const level = template.config.autonomyLevel; + const named = level === undefined ? "another level" : `${level[0]?.toUpperCase() ?? ""}${level.slice(1)}`; + return `The supervisor's Act is off: it acts only under Autonomous, and "${template.title}" sets ${named}.` + + " The fallback agents and allowances are kept."; +} + +/** Whether an edit turned the change's Act off, for the surface that made + * it to say so. */ +export function actTurnedOff(before: Partial | null | undefined, after: Partial): boolean { + return before?.supervisor?.mode === "act" && after.supervisor?.mode !== "act"; } /** The override to write when one agent is put on every stage of a diff --git a/packages/extension/src/commands.test.ts b/packages/extension/src/commands.test.ts index 0b9374ae..244efbf5 100644 --- a/packages/extension/src/commands.test.ts +++ b/packages/extension/src/commands.test.ts @@ -2752,6 +2752,26 @@ describe("createRunChoiceHandler", () => { expect(deps.implementationSessions.start).not.toHaveBeenCalled(); }); + // applying-a-configuration-turns-act-off 2.2: a change's Act that the + // written level does not allow is turned off, kept fallback and all, and + // the note beside the Apply button says so. + it("turns off an Act the written level does not allow, and says so", async () => { + readGlobalHarnessConfigMock.mockResolvedValue({ stepAgents: {}, autonomyLevel: "assisted", reviewGate: { mode: "human-required" } }); + resolveHarnessConfigMock.mockResolvedValue({ stepAgents: {}, autonomyLevel: "assisted", reviewGate: { mode: "human-required" } }); + readChangeHarnessConfigMock.mockResolvedValue({ supervisor: { mode: "act", fallback: { apply: ["local-llm-acp"] } } }); + buildRunPlanMock.mockReturnValue({ resolved: "single-stage", because: "x", stageAgents: [], offered: [], findings: [] }); + const deps = makeDeps(); + + await createRunChoiceHandler(deps)({ kind: "apply-template", templateId: "balanced" }, context); + + expect(writeChangeHarnessConfigMock).toHaveBeenCalledWith("/workspace/repo", "demo-change", expect.objectContaining({ + supervisor: { fallback: { apply: ["local-llm-acp"] } }, + })); + expect(deps.revealAiPanel).toHaveBeenCalledWith(expect.objectContaining({ + appliedNote: expect.stringContaining("The supervisor's Act is off: it acts only under Autonomous"), + })); + }); + it("writes nothing for a configuration id it does not have", async () => { // The two sides disagreeing about what exists is worth a line in the // log; it is not worth writing something. diff --git a/packages/extension/src/commands.ts b/packages/extension/src/commands.ts index c9df4e4f..3322f948 100644 --- a/packages/extension/src/commands.ts +++ b/packages/extension/src/commands.ts @@ -26,6 +26,8 @@ import { describeChangeState, readChangeStandings, type DescribedChangeState, + actTurnedOff, + actTurnedOffNote, changeTemplateConfigToWrite, templatesForScope, type RecommendationInput, type RunStartFacts, @@ -678,18 +680,18 @@ export function createRunChoiceHandler(deps: CommandsDeps) { // and a-stage-override-keeps-its-custom-agent. const global = await readGlobalHarnessConfig(workspaceRoot); const existing = await readChangeHarnessConfig(workspaceRoot, changeName); - await writeChangeHarnessConfig( - workspaceRoot, - changeName, - changeTemplateConfigToWrite(template, global, existing), - ); + const written = changeTemplateConfigToWrite(template, global, existing); + await writeChangeHarnessConfig(workspaceRoot, changeName, written); // Re-read rather than re-render what was on screen: the dialog // should show what the file now resolves to, which is the same // thing the standalone shell does after applying one. await revealResolved({ appliedNote: `Applied "${template.title}" to openspec/changes/${changeName}/harness.json. ` - + "The dialog now shows what the change resolves to.", + + "The dialog now shows what the change resolves to." + // An Act the configuration's level does not allow was turned off + // (applying-a-configuration-turns-act-off). + + (actTurnedOff(existing, written) ? ` ${actTurnedOffNote(template)}` : ""), }); } catch (error) { await showCommandError("apply the named configuration", error); diff --git a/packages/webui/src/components/ChangeHarnessSettingsView.test.tsx b/packages/webui/src/components/ChangeHarnessSettingsView.test.tsx index 408cdb01..5f004f28 100644 --- a/packages/webui/src/components/ChangeHarnessSettingsView.test.tsx +++ b/packages/webui/src/components/ChangeHarnessSettingsView.test.tsx @@ -347,16 +347,72 @@ describe("ChangeHarnessSettingsView — saving", () => { await waitFor(() => expect(api.writeChangeOverride).toHaveBeenCalledWith("demo", expect.objectContaining({ supervisor: { mode: "act" } }))); }); - it("says Act will be refused once the change is no longer autonomous", async () => { + // applying-a-configuration-turns-act-off 2.1 + it("turns Act off on saving once the change is no longer autonomous, and says so first", async () => { const api = createApi({ - readChangeOverride: vi.fn().mockResolvedValue({ stepAgents: {}, autonomyLevel: "autonomous", maxStageAttempts: 3, supervisor: { mode: "act" } }), + readChangeOverride: vi.fn().mockResolvedValue({ + stepAgents: {}, + autonomyLevel: "autonomous", + maxStageAttempts: 3, + supervisor: { mode: "act", fallback: { apply: ["local-llm-acp"] }, allowCostIncrease: true }, + }), }); await renderLoaded(api); expect(chosen("Change supervisor")).toBe("act"); - expect(screen.queryByTestId("change-supervisor-act-refused")).toBeNull(); + expect(screen.queryByTestId("change-supervisor-act-off")).toBeNull(); choose("Change autonomy level", ""); - expect(screen.getByTestId("change-supervisor-act-refused").textContent).toContain("saving will be refused"); + expect(screen.getByTestId("change-supervisor-act-off").textContent).toContain("Act turns off when this is saved"); + fireEvent.click(saveButton()); + + await waitFor(() => expect(api.writeChangeOverride).toHaveBeenCalled()); + const saved = (api.writeChangeOverride as ReturnType).mock.calls[0]![1] as Record; + expect(saved.autonomyLevel).toBeUndefined(); + expect(saved.supervisor).toEqual({ fallback: { apply: ["local-llm-acp"] }, allowCostIncrease: true }); + }); + + it("opens a file written by hand with Act and another level, and saving puts it right", async () => { + const api = createApi({ + readChangeOverride: vi.fn().mockResolvedValue({ + stepAgents: {}, + autonomyLevel: "semi-autonomous", + maxStageAttempts: 2, + supervisor: { mode: "act", fallback: { apply: ["local-llm-acp"] } }, + }), + }); + await renderLoaded(api); + + expect(chosen("Change supervisor")).toBe("act"); + expect(screen.getByTestId("change-supervisor-act-off").textContent).toContain("Act turns off when this is saved"); + // Nothing else to change: saving it as it stands is what puts it right. + expect(saveButton()).not.toBeDisabled(); + fireEvent.click(saveButton()); + + await waitFor(() => expect(api.writeChangeOverride).toHaveBeenCalled()); + const saved = (api.writeChangeOverride as ReturnType).mock.calls[0]![1] as Record; + expect(saved.supervisor).toEqual({ fallback: { apply: ["local-llm-acp"] } }); + }); + + it("applies a named configuration over Act, and says Act is off", async () => { + const api = createApi({ + readChangeOverride: vi.fn().mockResolvedValue({ + stepAgents: { apply: "local-llm-acp" }, + autonomyLevel: "autonomous", + maxStageAttempts: 2, + supervisor: { mode: "act", fallback: { apply: ["deepseek-cli-acp"] }, allowProviderChange: true }, + }), + }); + await renderLoaded(api); + + choose("Named configuration", "balanced"); + fireEvent.click(screen.getByRole("button", { name: "Apply to the form" })); + expect(screen.getByText(/The supervisor's Act is off: it acts only under Autonomous, and "Balanced" sets Semi-autonomous/)).toBeTruthy(); + fireEvent.click(saveButton()); + + await waitFor(() => expect(api.writeChangeOverride).toHaveBeenCalled()); + const saved = (api.writeChangeOverride as ReturnType).mock.calls[0]![1] as Record; + expect(saved.autonomyLevel).toBe("semi-autonomous"); + expect(saved.supervisor).toEqual({ fallback: { apply: ["deepseek-cli-acp"] }, allowProviderChange: true }); }); it("still removes autonomyLevel when it is set back to inherit", async () => { diff --git a/packages/webui/src/components/ChangeHarnessSettingsView.tsx b/packages/webui/src/components/ChangeHarnessSettingsView.tsx index 7e9ec231..28548091 100644 --- a/packages/webui/src/components/ChangeHarnessSettingsView.tsx +++ b/packages/webui/src/components/ChangeHarnessSettingsView.tsx @@ -1,5 +1,6 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; import { + actTurnedOff, changeTemplateConfigToWrite, findHarnessConfigLimits, isHarnessStepAgentStage, @@ -203,8 +204,12 @@ export function ChangeHarnessSettingsView( return findHarnessConfigLimits({ ...global, ...ownRest, stepAgents: resolvedStepAgents }); }, [global, override, resolvedStepAgents]); + // A saved file that says Act under another level - written by hand - is + // put right by saving it as it stands, so there is something to save + // (applying-a-configuration-turns-act-off). + const actToTurnOff = override?.supervisor?.mode === "act" && autonomyLevel !== "autonomous"; const dirty = savedSnapshot !== null && override !== undefined - && snapshotOf(override, forms, autonomyLevel, reviewGateMode, runBudget) !== savedSnapshot; + && (actToTurnOff || snapshotOf(override, forms, autonomyLevel, reviewGateMode, runBudget) !== savedSnapshot); /** Applies a named configuration against what the change resolves to, * through the one core function every surface applies one through, and @@ -222,7 +227,7 @@ export function ChangeHarnessSettingsView( // The ceilings ride here, not in the form, and the save lays the form // over this rather than replacing it. setOverride(written); - setApplyStatus(changeTemplateAppliedMessage(template, writtenForms.effort, resolvedAgents, reset)); + setApplyStatus(changeTemplateAppliedMessage(template, writtenForms.effort, resolvedAgents, reset, actTurnedOff(override, written))); }; async function save() { @@ -417,8 +422,8 @@ export function ChangeHarnessSettingsView( : ""}

{supervisorMode === "act" && !ownAutonomous ? ( -

- Act needs this change's own autonomy level to be Autonomous: saving will be refused until it is, or until another mode is chosen. +

+ Act turns off when this is saved: it acts only where this change's own autonomy level is Autonomous. The fallback agents and allowances are kept.

) : null} {supervisorMode === "act" ? ( diff --git a/packages/webui/src/components/harness-settings-parts.tsx b/packages/webui/src/components/harness-settings-parts.tsx index d8259d13..618cb56c 100644 --- a/packages/webui/src/components/harness-settings-parts.tsx +++ b/packages/webui/src/components/harness-settings-parts.tsx @@ -10,6 +10,8 @@ import { resolveEffortLevel, STAGE_PURPOSES, VSCODE_CHAT_STEP_AGENT_ID, + actTurnedOffNote, + withoutActItCannotUse, type HarnessAutonomyLevel, type HarnessConfig, type HarnessEffort, @@ -266,12 +268,14 @@ export function changeTemplateAppliedMessage( effort: StepEffortForm, agents: StepAgentsForm, reset: readonly HarnessStepAgentStage[], + actOff = false, ): string { + const actNote = actOff ? ` ${actTurnedOffNote(template)}` : ""; const agentNote = reset.length === 0 ? " The agents and models on screen are unchanged." : ` ${reset.join(", ")} now name${reset.length === 1 ? "s" : ""} the agent the change resolves to,` + " because an effort without its agent means nothing."; - return `Filled from "${template.title}". ${templateEffortNote(template, effort, agents)}${agentNote}` + return `Filled from "${template.title}". ${templateEffortNote(template, effort, agents)}${agentNote}${actNote}` + " Nothing is saved until you save."; } @@ -349,7 +353,9 @@ export function changeConfigToSave( else delete config.autonomyLevel; if (reviewGateMode !== INHERIT) config.reviewGate = { mode: reviewGateMode }; else delete config.reviewGate; - return withRunBudget(config, runBudget); + // An Act the level no longer allows is turned off rather than refused + // (applying-a-configuration-turns-act-off). + return withoutActItCannotUse(withRunBudget(config, runBudget)); } /** The run budget as the field shows it: the chain's cost ceiling, or empty. */ diff --git a/packages/webui/src/run-with-harness-dispatch.ts b/packages/webui/src/run-with-harness-dispatch.ts index 4732dcaf..5e8d1af4 100644 --- a/packages/webui/src/run-with-harness-dispatch.ts +++ b/packages/webui/src/run-with-harness-dispatch.ts @@ -7,7 +7,7 @@ // code from entry-point wiring (`harness-config-client.ts`, // `change-editor-client.ts`). -import { agentForEveryStageToWrite, buildRunPlan, changeTemplateConfigToWrite, openTaskCount, resolveRunWithHarnessTarget, runStartFactsFrom, type HarnessBudget, type HarnessTemplate, type RunPlan, type RunWithHarnessTarget } from "@openspec-ui/core/browser"; +import { actTurnedOff, agentForEveryStageToWrite, buildRunPlan, changeTemplateConfigToWrite, openTaskCount, resolveRunWithHarnessTarget, runStartFactsFrom, type HarnessBudget, type HarnessTemplate, type RunPlan, type RunWithHarnessTarget } from "@openspec-ui/core/browser"; import { readChangeHarnessOverride, resolveHarnessConfig, writeHarnessConfig } from "./harness-config-client.js"; import { loadChangeTimeline } from "./change-timeline-client.js"; import type { ChangeEditorRequest } from "./change-editor-client.js"; @@ -118,7 +118,7 @@ export async function applyTemplateToChange( cwd: string, changeName: string, template: HarnessTemplate, -): Promise { +): Promise<{ actTurnedOff: boolean }> { const [existing, global] = await Promise.all([ readChangeHarnessOverride(request, cwd, changeName), // The global file, not the change's own override: the effort has to @@ -129,6 +129,9 @@ export async function applyTemplateToChange( ]); const config = changeTemplateConfigToWrite(template, global, existing ?? undefined); await writeHarnessConfig(request, cwd, config, changeName); + // Said by the caller beside what it applied + // (applying-a-configuration-turns-act-off). + return { actTurnedOff: actTurnedOff(existing, config) }; } /** Puts one agent on every stage of a change, keeping what the change's diff --git a/packages/webui/src/standalone-entry.tsx b/packages/webui/src/standalone-entry.tsx index 753651a4..bb64b321 100644 --- a/packages/webui/src/standalone-entry.tsx +++ b/packages/webui/src/standalone-entry.tsx @@ -116,6 +116,7 @@ import { } from "./run-with-harness-dispatch.js"; import { fireDueSchedule, type ScheduleFiringHost } from "./scheduled-run-firing.js"; import { + actTurnedOffNote, comparisonRows, comparisonWindow, DEFAULT_COMPARISON_PERIOD, @@ -928,14 +929,15 @@ function StandaloneApp() { if (!runDispatch) return; setRunHarnessLoading(true); try { - await applyTemplateToChangeApi(apiFetch, cwd, runChangeName, template); + const applied = await applyTemplateToChangeApi(apiFetch, cwd, runChangeName, template); setRunDispatch(await resolveRunWithHarnessDispatch(apiFetch, cwd, runChangeName)); // Beside the Apply button, in the dialog. It used to be set above // the dialog, where the person who pressed Apply could not see it // (a-change-is-configured-from-the-change). setRunAppliedNote( `Applied "${template.title}" to openspec/changes/${runChangeName}/harness.json. ` - + "The dialog now shows what the change resolves to.", + + "The dialog now shows what the change resolves to." + + (applied.actTurnedOff ? ` ${actTurnedOffNote(template)}` : ""), ); } catch (error) { const message = error instanceof Error ? error.message : String(error);