Repository navigation
fix(profiles): require confirmation before applying an empty profile (#1349) #1384
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b0a590b
3395831
03ea597
efc7b52
7f1699f
e89c271
777ac6d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2122,7 +2122,21 @@ function readGlobalEffectiveModelConfig(cwd: string): AgentModelConfig { | |
| async function readEffectiveModelConfigAsync(cwd: string): Promise<AgentModelConfig> { | ||
| const pinned = pinnedEffectiveModelConfig(cwd); | ||
| if (pinned) return pinned; | ||
| const effective = cloneModelConfig(await readModelConfigAsync(cwd)); | ||
| return readGlobalEffectiveModelConfigFromAsync(cwd, await readModelConfigAsync(cwd)); | ||
| } | ||
|
|
||
| /** | ||
| * The saved global routing merged with the materialized stores of every | ||
| * discoverable agent it is silent about — the same effective view | ||
| * `readEffectiveModelConfigAsync` builds, but starting from an already-read | ||
| * saved routing so callers that must distinguish an unreadable authority can | ||
| * keep that distinction while still seeing materialized routes. | ||
| */ | ||
| async function readGlobalEffectiveModelConfigFromAsync( | ||
| cwd: string, | ||
| base: AgentModelConfig, | ||
| ): Promise<AgentModelConfig> { | ||
| const effective = cloneModelConfig(base); | ||
| const profilesByPath = new Map<string, Record<string, unknown>>(); | ||
| for (const agent of await listDiscoverableAgentsAsync(cwd)) { | ||
| if (isProviderReviewRole(agent.name) || agent.name in effective) continue; | ||
|
|
@@ -4148,6 +4162,89 @@ async function runProfilesPanelAction( | |
| } | ||
| const normalized = normalizeModelConfig(file.profiles[result.name]) ?? {}; | ||
| const orchestratorEntry = readProfileOrchestrator(normalized); | ||
| const hasAgentRoutes = Object.keys(normalized).some((name) => !isProfileOrchestratorKey(name)); | ||
| // Every global apply confirms before anything is written: applying replaces | ||
| // the whole routing map in models.json and clears materialized routes the | ||
| // profile omits, so the dialog must name the concrete diff. The current | ||
| // routing is read from the same authority every other consumer uses; an | ||
| // unreadable config is tolerated as empty, exactly like readModelConfigAsync. | ||
| const savedRouting = await readModelRoutingAuthorityAsync( | ||
| modelConfigPath(ctx.cwd), | ||
| legacyProjectModelConfigPath(ctx.cwd), | ||
| ); | ||
| // The apply pads omitted discoverable agents with clear entries, so the | ||
| // diff must run against the effective current routing: the saved global | ||
| // routing plus the materialized routes (frontmatter, subagents.json) of | ||
| // agents the saved routing is silent about. models.json alone would hide | ||
| // materialized-only routes the approval actually clears. | ||
| const currentRouting = savedRouting.status === "valid" | ||
| ? await readGlobalEffectiveModelConfigFromAsync(ctx.cwd, savedRouting.config) | ||
| : {}; | ||
| const agentNames = [ | ||
| ...new Set([ | ||
| ...Object.keys(currentRouting).filter((name) => !isProfileOrchestratorKey(name)), | ||
| ...Object.keys(normalized).filter((name) => !isProfileOrchestratorKey(name)), | ||
| ]), | ||
| ].sort(); | ||
| const replacedRoutes: string[] = []; | ||
| const clearedRoutes: string[] = []; | ||
| const addedRoutes: string[] = []; | ||
| for (const name of agentNames) { | ||
| const from = currentRouting[name]; | ||
| const to = normalized[name]; | ||
| if (to === undefined) { | ||
| clearedRoutes.push(`${name}: ${formatOrchestratorSelection(from)} → inherit (cleared)`); | ||
| } else if (from === undefined) { | ||
| addedRoutes.push(`${name}: ${formatOrchestratorSelection(to)} (added)`); | ||
| } else if (from.model !== to.model || from.thinking !== to.thinking) { | ||
| replacedRoutes.push(`${name}: ${formatOrchestratorSelection(from)} → ${formatOrchestratorSelection(to)}`); | ||
| } | ||
| } | ||
| if (!hasAgentRoutes) { | ||
| // A genuinely empty profile and an orchestrator-only profile both wipe | ||
| // every materialized agent route, but they read very differently to the | ||
| // user: the orchestrator entry survives the apply and reconfigures the | ||
| // orchestrator, so the dialog must not promise an entirely empty config. | ||
| const [confirmTitle, confirmMessage] = orchestratorEntry !== undefined | ||
| ? [ | ||
| "Apply orchestrator-only profile?", | ||
| `Profile "${result.name}" has an orchestrator entry but no agent routing entries. Applying it will replace global routing in ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} with the orchestrator entry alone, clear every materialized agent route so every agent returns to inherit its default model, set the configured orchestrator entry in settings.json, and attempt to switch this session to that orchestrator model. Continue?`, | ||
| ] | ||
| : [ | ||
| "Apply empty profile?", | ||
| `Profile "${result.name}" has no routing entries. Applying it will replace global routing in ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} with an empty configuration and return every agent to inherit its default model. Continue?`, | ||
| ]; | ||
| const approved = await ctx.ui.confirm(confirmTitle, confirmMessage); | ||
| if (!approved) return file; | ||
| } else { | ||
| // A populated profile keeps the same abort semantics as the empty and | ||
| // orchestrator-only dialogs: declining leaves every surface untouched. | ||
| // When the routing authority is unreadable the diff above was computed | ||
| // against an empty map, so the dialog must disclose the unreadable | ||
| // routing and the replace/clear-to-inherit effect instead of presenting | ||
| // existing routes as merely "(added)" (the #1349 wipe-bug class). | ||
| // When the profile carries an orchestrator entry, approval also writes | ||
| // settings.json and tries to move the live session, so both variants | ||
| // must disclose those effects in the same words the orchestrator-only | ||
| // dialog uses. An unconditional "will switch" is never claimed: the | ||
| // registry/auth can refuse the live move. | ||
| const orchestratorEffects = orchestratorEntry !== undefined | ||
| ? ", set the configured orchestrator entry in settings.json, and attempt to switch this session to that orchestrator model" | ||
| : ""; | ||
| let confirmMessage: string; | ||
| if (savedRouting.status !== "valid") { | ||
| const modelsPath = sanitizeTerminalText(modelConfigPath(ctx.cwd)); | ||
| confirmMessage = `Profile "${result.name}" has agent routing entries, but the current global routing in ${modelsPath} could not be read, so existing routes are not listed. Applying it will replace global routing in ${modelsPath} with this profile's routes, so every existing agent route may be replaced or cleared back to inherit${orchestratorEffects}. Continue?`; | ||
| } else { | ||
| const changes = [...replacedRoutes, ...clearedRoutes, ...addedRoutes]; | ||
| const changeSummary = changes.length > 0 | ||
| ? changes.join("; ") | ||
| : "its agent routes already match the current global routing"; | ||
| confirmMessage = `Profile "${result.name}" has agent routing entries. Applying it will replace global routing in ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} with this profile's routes: ${changeSummary}${orchestratorEffects}. Continue?`; | ||
| } | ||
| const approved = await ctx.ui.confirm(`Apply profile "${result.name}"?`, confirmMessage); | ||
| if (!approved) return file; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Reject a routing diff that changes while confirmation is open. If another session changes global routing during 🤖 Prompt for AI Agents |
||
| } | ||
| // Applying spans three files — the store, models.json, and Pi's global | ||
| // settings.json — and there is no cross-file rename, so order the writes to | ||
| // keep the store truthful and compensate on failure: claim the profile in | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| # PR #1384 Populated-Apply Confirmation | ||
|
|
||
| ## Objective | ||
|
|
||
| Close the maintainer-flagged gap on PR #1384: applying a *populated* profile | ||
| globally still replaces every materialized route without confirmation | ||
| (`Ctrl+S` → `enter` sequence from #1349). Global applies now always confirm, | ||
| naming what changes before anything is written. | ||
|
|
||
| ## Decisions (operator, 2026-09-30) | ||
|
|
||
| - Behavior: confirm **every** global profile apply. Populated profiles get a | ||
| dialog naming replaced routes, cleared agents, and added agents. The | ||
| empty / orchestrator-only dialogs from commit `efc7b52a` stay specialized. | ||
| - Repo-pinned applies stay silent (repo-scoped, no global state touched). | ||
| - Delivery: local only. No push, no PR comment unless the operator later | ||
| authorizes it. | ||
|
|
||
| ## Scope | ||
|
|
||
| - `extensions/gentle-ai.ts`: extend the `case "apply"` guard — read the | ||
| current global routing via `readModelRoutingAuthorityAsync` before the | ||
| prompt, compute a diff (replaced / cleared / added agent routes), prompt on | ||
| every global apply, abort on decline before any write or live switch. | ||
| - `tests/gentle-ai.test.ts`: flip the "nonempty global apply never asks for | ||
| confirmation" assertion; add populated-apply regressions (decline preserves | ||
| all four surfaces byte-identically; confirm applies and reports). | ||
|
|
||
| ## Out of scope | ||
|
|
||
| - #1557 key rebinding (`a`) reconciliation — follow-up when that branch lands. | ||
| - Snapshot/undo of successful applies (jonathanludena's general case). | ||
| - `odd/tasks/pr-1384-review-fixes.md` cleanup — operator decision at delivery. | ||
|
|
||
| ## Tasks | ||
|
|
||
| - [x] T1 — RED: populated-apply regression (prompt shown, decline preserves | ||
| models.json/subagents.json/store/settings byte-identically and no live | ||
| switch). Observed: `confirmCalls.length 0 !== 1` on both new tests and on | ||
| the flipped orchestrator-persistence assertion. | ||
| - [x] T2 — Implement the universal confirmation with route diff. | ||
| - [x] T3 — GREEN: focused profile tests (37 pass), full | ||
| `tests/gentle-ai.test.ts` (96 pass), typecheck (187 diagnostics, baseline | ||
| unchanged), `git diff --check` clean. | ||
| - [ ] T4 — Work-unit commit (local only) and delivery report. | ||
|
|
||
| ## CodeRabbit closure round (2026-10-02, both Major threads) | ||
|
|
||
| - Finding 1 (materialized-only routes): the populated dialog now diffs against | ||
| the effective current routing — saved global routing merged with materialized | ||
| routes via the new `readGlobalEffectiveModelConfigFromAsync` helper (reuses | ||
| `listDiscoverableAgentsAsync` / `readMaterializedRoutingEntryAsync`, same as | ||
| `readEffectiveModelConfigAsync`, which now delegates to it). `models.json` | ||
| alone hid routes the approval actually clears. Unreadable-authority | ||
| disclosure branch unchanged and still authoritative. | ||
| - Finding 2 (orchestrator effects): when a populated profile has an | ||
| `orchestrator` entry, both the readable and unreadable populated messages | ||
| append "set the configured orchestrator entry in settings.json, and attempt | ||
| to switch this session to that orchestrator model" (same wording as the | ||
| orchestrator-only dialog; never an unconditional switch promise). No | ||
| orchestrator entry → messages unchanged. | ||
| - T7 — RED observed: 4 new tests fail — dialog claimed "its agent routes | ||
| already match the current global routing" while approval would clear | ||
| helper's materialized route (2 tests); readable and unreadable populated | ||
| messages lacked the settings.json / live-switch disclosure (2 tests). | ||
| - T8 — GREEN: focused profile tests 43 pass, full `tests/gentle-ai.test.ts` | ||
| 102 pass, typecheck 187 diagnostics (baseline unchanged), `git diff --check` | ||
| clean. Confirm-path regression added (materialized-only route cleared on | ||
| approval); existing message assertions untouched. | ||
|
|
||
| ## QA R4 closure (2026-09-30) | ||
|
|
||
| - Finding: with an invalid routing authority the apply guard fell back to | ||
| `currentRouting = {}`, so the populated dialog labeled every profile route | ||
| `(added)` and never disclosed the replace/clear-to-inherit effect. | ||
| - Fix: when `readModelRoutingAuthorityAsync` is not `valid` at prompt time, the | ||
| populated dialog uses an alternative message disclosing the unreadable | ||
| current routing and that applying replaces global routing so every existing | ||
| agent route may be replaced or cleared back to inherit. Guard structure, | ||
| decline semantics, empty/orchestrator-only dialogs, and repo-pinned silence | ||
| unchanged. | ||
| - T5 — RED observed: `AssertionError ... the dialog must disclose that the | ||
| current global routing is unreadable` with actual message ending | ||
| `worker: openai/alpha (added). Continue?` on both new tests. | ||
| - T6 — GREEN: focused profile tests 39 pass, full `tests/gentle-ai.test.ts` | ||
| 98 pass, typecheck 187 diagnostics (baseline), `git diff --check` clean. | ||
|
|
||
| ## Constraints | ||
|
|
||
| - Strict TDD: observe RED before changing production code. | ||
| - Technical artifacts in English; dialog copy stays consistent with the | ||
| existing `efc7b52a` style. | ||
| - Allowed edit surfaces: `extensions/gentle-ai.ts`, `tests/gentle-ai.test.ts` | ||
| (plus this task doc). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| # PR #1384 Review Fixes | ||
|
|
||
| ## Objective | ||
|
|
||
| Close the verified CodeRabbit review finding on PR #1384 (`#1349` empty profile wipe guard) by requiring confirmation when applying orchestrator-only profiles that lack agent routes. | ||
|
|
||
| ## Problem | ||
|
|
||
| Commit `b0a590be` checked `Object.keys(normalized).length === 0` to prompt for confirmation. However, a profile that contains only an orchestrator entry (`orchestrator: { model: "..." }`) has `Object.keys(normalized).length === 1`. Applying it skipped confirmation, replaced global routing with the orchestrator-only config, and wiped materialized routes for all omitted agents in `subagents.json`. | ||
|
|
||
| ## Scope | ||
|
|
||
| - In `extensions/gentle-ai.ts`, check whether any agent routes exist (excluding the reserved orchestrator key) before applying a profile. | ||
| - Add regression coverage in `tests/gentle-ai.test.ts` for orchestrator-only profiles. | ||
| - Verify typecheck and tests. | ||
|
|
||
| ## Constraints | ||
|
|
||
| - Keep the patch minimal and limited to PR #1384 review findings. | ||
| - Technical artifacts remain in English. | ||
| - Do not commit, push, or merge without explicit user direction. | ||
| - Strict TDD discipline: RED test confirmed before implementation fix. | ||
|
|
||
| ## Tasks | ||
|
|
||
| - [x] **T1 — Guard orchestrator-only profiles against unconfirmed agent route wipes.** Check for absence of agent routes (e.g. `!Object.keys(normalized).some((name) => !isProfileOrchestratorKey(name))`) in the profile apply flow. | ||
| - [x] **T2 — Regression tests and typecheck verification.** Verify orchestrator-only profile application prompts for confirmation and aborts when declined. | ||
| - [x] **T3 — Correct confirmation copy and strengthen safety assertions.** Implementation and independent nan QA complete: dialog distinguishes empty/orchestrator-only profiles and explains settings plus attempted live-session change. Decline preserves models, subagents, profile store, settings and live switches; confirmation clears omitted routes; nonempty/repo-pinned applies do not prompt. Work-unit commit pending explicit user authorization. | ||
| - [ ] **T4 — Validate the correction candidate and report delivery readiness.** Run candidate assessment and native review under the enabled user-owned RDD switch. Commit, push and PR merge remain pending explicit user direction. | ||
|
|
||
| ## QA follow-up (2026-09-30) | ||
|
|
||
| The operator accepted independent nan QA findings on target `03ea59771bb7d18adc28f8702890b306bf7d1069`. The confirmation works, but the orchestrator-only message incorrectly promises an empty config and omits settings/live-session effects. Existing tests do not explicitly assert preservation of every affected surface or confirmed omitted-agent clearing. | ||
|
|
||
| - Allowed source surfaces: `extensions/gentle-ai.ts`, `tests/gentle-ai.test.ts`. | ||
| - Route: delegated `gentle-ai-worker`; multi-file writer trigger (two nontrivial source/test files). | ||
| - TDD: strict, retained from this feature document's Constraints; observe failing copy regression before changing production code, then GREEN. | ||
| - Focused runner: `node --experimental-strip-types --test --test-name-pattern="profile" tests/gentle-ai.test.ts`. | ||
| - Closure checks: `node --experimental-strip-types --test tests/gentle-ai.test.ts`; `node scripts/check-types.mjs`; `git diff --check`. | ||
| - Environment: target requires pi-coding-agent 0.99.1; parent dependencies previously contained 0.85.1. Use matching dependencies in an isolated test worktree, without installing or modifying operator routing. | ||
| - Delivery strategy: ask-on-risk; forecast approximately 80–160 additional authored lines, one cohesive correction unit; no size-driven split required yet. | ||
| - Local branch `pr-1384` fast-forwarded to updated PR head `03ea5977`; no history rewritten. | ||
| - Progress: T3 verified locally. Writer (glm-4.7/high) observed RED for title and conditional live-switch wording, then GREEN. Independent nan/mimo-v2.6-flash QA observed focused 35/35 and full extension 94/94 tests, typecheck 187 baseline diagnostics/no regressions, and full-tree diff check exit 0 using pi 0.99.1 dependencies. Temporary test worktrees removed; parent dependencies and operator config unchanged. | ||
| - T4 blocked: native assessment returned schema-incompatible/unassessable, requiring independent verification (completed). Native inspect then stopped with managed_assets_outdated, offering `/home/jbarbat/.local/share/gentle-ai/main/gentle-ai sync --agent pi`. No lineage started and no review approval obtained. Synchronizing installed assets is outside this source-only correction and awaits operator direction. | ||
| - Remaining checks: manual TUI Escape/cancel smoke and installed packed-package check not performed; no new CI run because corrections remain local. | ||
| - Delivery: no correction commit, push or PR merge executed. Engram mirror unavailable (session already ended). Next step: operator decides whether to sync managed assets for native review and authorize delivery separately. |
Uh oh!
There was an error while loading. Please reload this page.