From 6b69392cfcce337e3e159a233ea35eab6074639a Mon Sep 17 00:00:00 2001 From: Adrian Cester Trallero Date: Wed, 28 Oct 2026 01:41:52 +0100 Subject: [PATCH 1/4] fix(profiles): mark the profile this session launches with on the routing screen The routing screen read only the Enter binding, so a session frozen at startup (#1827) showed no session tab and opened on the pin or active profile even after they changed. The screen now resolves the session's profile through resolveSessionProfile, the rule launches use: - the tab marked with the session glyph is the binding or the frozen profile, and the screen opens on it; - From what's running now captures that profile's routing, never a pin written after the session started; - the subagents.json unmatched-keys notice is skipped while the session resolves a profile, since it replaces those keys; - a session profile deleted from profiles.json is named in a notice. Part of #1064 (slice 3c, read side). --- docs/readme-reference.md | 6 +- extensions/gentle-ai.ts | 48 ++++++++++++-- tests/gentle-ai.test.ts | 140 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 178 insertions(+), 16 deletions(-) diff --git a/docs/readme-reference.md b/docs/readme-reference.md index b34e84957..add287933 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -1028,11 +1028,11 @@ A project can still override the global default with: ╰──────────────────────────────────────────────────────────────────────────────╯ ``` -- **Tabs are profiles.** Click a tab or press `←` `→` to show another one; the screen redraws in place. Symbols show where a profile is in effect: `◉` this session, `★` the default for new sessions, `⌂` this repository. Leaving a profile with unsaved changes asks first: **Save and switch**, **Discard**, or **Stay**. +- **Tabs are profiles.** Click a tab or press `←` `→` to show another one; the screen redraws in place. Symbols show where a profile is in effect: `◉` this session, `★` the default for new sessions, `⌂` this repository. `◉` marks the profile this session's launches use: the one chosen with **Use in this session**, or the one it took at startup (see **Session profile frozen at startup**), so it can differ from `★` and `⌂` after they change. The screen opens on that profile. Leaving a profile with unsaved changes asks first: **Save and switch**, **Discard**, or **Stay**. - **The table is that profile's routing.** The orchestrator is the first row; agents follow. Click a model or press `enter` to choose from a searchable list (the current one is marked `✓`; **Custom model id…** takes any `provider/model`). Click an effort or press `e` to pick from a single row of levels: only the levels that row's model supports, as its provider's catalog declares them (every level when the row inherits its model or the model is not in the catalog). Pi lands an unsupported level on the nearest supported one at launch, and the table says where: `max → high`. Click `Model ▾` or `Effort ▾`, or move the cursor to that header, to change every agent at once; the orchestrator is never part of that. - **Three buttons decide where it applies.** **Use in this session** (`u`) binds what you see to this Pi session only and switches its orchestrator; nothing shared is written. **Make default** (`d`) makes it the default for new sessions through the guarded global apply (diff confirmation, pin honesty, rollback). **Save** (`s` or `ctrl+s`) writes what you see into the profile. Use and Make default save pending edits first and say so. - **The profile menu**, `▾` on the active tab (`m`), holds **Rename**, **Duplicate**, **Use in this repo** (the clone pin; it reads **Stop using in this repo** when it already applies), and **Delete**. **+ New** (`n`) starts a profile as a copy of the current one, from what is running now, or empty. -- Problems the screen will not hide appear under the profile name with `⚠`: a pin file that is not a valid pin or names a profile that does not exist (either way it is ignored), and routing keys no agent matches (ignored at launch), with the agent name it probably meant when there is one. +- Problems the screen will not hide appear under the profile name with `⚠`: a pin file that is not a valid pin or names a profile that does not exist (either way it is ignored), routing keys no agent matches (ignored at launch), with the agent name it probably meant when there is one, and a session profile that was deleted from `profiles.json` after this session took it (its launches keep that routing). - Edited values are highlighted until saved, and the footer counts them (`● 2 unsaved`). `esc` closes; with unsaved edits the first press warns and the second closes without saving. Every action reports on the line under the footer. - Everything clickable highlights under the pointer, and a click runs one action. Keys: `←→` profile, `↑↓` agent, `enter` model, `e` effort, `s` save, `u` use, `d` default, `n` new, `m` the profile menu, `esc` close. @@ -1102,7 +1102,7 @@ Outside a pinned repository, **Make default** writes `~/.pi/gentle-ai/models.jso A profile also carries the orchestrator under the reserved routing key `orchestrator`. Outside a pinned repository, **Make default** on a profile that defines one writes `defaultProvider`, `defaultModel`, and `defaultThinkingLevel` to Pi's global `settings.json` (preserving every other key; an unreadable `settings.json` aborts that part and is reported instead of being overwritten) and switches the session you are in to that model and thinking level right away, so the orchestrator answers with the profile's model from the next turn. **Use in this session** skips the settings write and switches only the session you are in: same live move, nothing persisted. When the model is not in Pi's catalog or its provider has no authentication, **Make default** still records the default for new sessions and says this session kept its current model, while **Use in this session** simply reports the refusal. Applying or binding a profile without an `orchestrator` entry never moves the orchestrator, and **+ New › From what's running now** captures the orchestrator the session actually runs together with the routing. `orchestrator` is reserved: it is not a subagent name, is never written to `subagents.json`, and is not counted as a role. -The `current` seed and **From what's running now** read the routing in effect. A valid local pin or repository declaration wins first: inside a pinned repository they read the pinned profile's routing. **From what's running now** also prefers this session's binding when it has one. Otherwise they read `models.json` where it has an entry, and the `subagents.json` model profile or frontmatter routing the runtime actually resolves for agents it omits. A sparse `models.json` therefore never hides routing that is still live. When `profiles.json` is missing, the command seeds one profile named `current` captured from that effective routing, marked active only when it has routing entries. Profiles or routing entries dropped by normalization are named in a warning instead of being lost silently. +**From what's running now** captures what this session's launches use: its `◉` profile when it has one, otherwise the global routing below. A pin written after the session started does not reach its launches, so it is not captured. The `current` seed reads the routing in effect: a valid local pin or repository declaration wins first, so inside a pinned repository it reads the pinned profile's routing. Otherwise both read `models.json` where it has an entry, and the `subagents.json` model profile or frontmatter routing the runtime actually resolves for agents it omits. A sparse `models.json` therefore never hides routing that is still live. When `profiles.json` is missing, the command seeds one profile named `current` captured from that effective routing, marked active only when it has routing entries. Profiles or routing entries dropped by normalization are named in a warning instead of being lost silently. Saved globally at: diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index db03092d7..ae0598c65 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -115,6 +115,7 @@ import { type ProfilePinStatus, } from "../lib/agent-profile-pin.ts"; import { bindSessionProfile, readSessionProfileBinding } from "../lib/session-profile-binding.ts"; +import { resolveSessionProfile, sessionProfileModelProfiles } from "../lib/session-profile-freeze.ts"; import { applyOrchestratorSettings, readOrchestratorSettings, @@ -2497,6 +2498,31 @@ function readGlobalEffectiveModelConfig(cwd: string): AgentModelConfig { return effective; } +/** + * The profile this session's launches resolve (gentle-shell#1064 slice 3c): the + * Enter binding, then the profile frozen at startup, through the same rule the + * launcher reads. It resolves from the session's directory, as the freeze and + * the launcher do. `undefined` means the session resolves no profile, so + * launches keep the global routing; a pin the session did not freeze never + * reaches them and is not read here. + */ +function sessionLaunchProfile(ctx: ExtensionContext): { name: string; modelProfiles: AgentModelConfig } | undefined { + const resolution = resolveSessionProfile({ + sessionId: ctx.sessionManager?.getSessionId?.(), + cwd: ctx.sessionManager?.getCwd?.() ?? ctx.cwd, + configHome: gentleAiConfigHome(), + }); + const name = resolution.kind === "explicit" ? resolution.name : resolution.profile?.name; + const routing = sessionProfileModelProfiles(resolution); + return name === undefined || routing === undefined ? undefined : { name, modelProfiles: cloneModelConfig(routing) }; +} + +/** The routing this session's launches use: its profile, or the global effective routing. */ +async function readSessionLaunchModelConfigAsync(ctx: ExtensionContext): Promise { + return sessionLaunchProfile(ctx)?.modelProfiles + ?? await readGlobalEffectiveModelConfigFromAsync(ctx.cwd, await readModelConfigAsync(ctx.cwd)); +} + async function readEffectiveModelConfigAsync(cwd: string): Promise { const pinned = pinnedEffectiveModelConfig(cwd); if (pinned) return pinned; @@ -4501,6 +4527,9 @@ function routingScreenNotices( for (const issue of pins.stale) { notices.push(`The ${issue.source} pin names "${issue.profile}", which no profile defines, so it is ignored (${issue.path}).`); } + if (sessionBoundName !== undefined && !hasOwnProfile(file.profiles, sessionBoundName)) { + notices.push(`This session launches with "${sessionBoundName}", which is no longer in profiles.json; its launches keep that routing.`); + } const roots = { cwd: ctx.sessionManager?.getCwd?.() ?? ctx.cwd, home: homedir(), @@ -4525,9 +4554,10 @@ function routingScreenNotices( if (entry.suggestion !== undefined) notices.push(`No agent is named "${entry.key}" in this profile; did you mean ${entry.suggestion}?`); } // Issue #2400: model_profiles keys in subagents.json that match nothing and - // suggest nothing. A session binding or a winning pin replaces those launches' - // routing, so the file's keys do not apply there. - if (sessionBoundName === undefined && pinnedEffectiveModelConfig(ctx.cwd) === undefined) { + // suggest nothing. When the session resolves a profile (its binding or the one + // frozen at startup), that profile replaces those launches' routing, so the + // file's keys do not apply there; a pin the session did not freeze does not. + if (sessionBoundName === undefined) { const stray = unmatchedProfileKeys(loadAgentsConfig(roots).modelProfiles, discovered) .filter((entry) => entry.suggestion === undefined && !isProviderReviewRole(entry.key) && !profileKeys.includes(entry.key)) .map((entry) => entry.key); @@ -4603,7 +4633,11 @@ async function openModelsScreen(ctx: ExtensionContext, live: LiveSession): Promi : read.status === "missing" ? ({ name: "current", source: "active" } as CurrentProfileTarget) : undefined; - await openProfilesPanelLoop(ctx, live, target?.name); + // The screen opens on the profile this session launches with while the store + // still has it (gentle-shell#1064 slice 3c). + const launchName = sessionLaunchProfile(ctx)?.name; + const opensOnLaunch = read.status === "valid" && launchName !== undefined && hasOwnProfile(read.file.profiles, launchName); + await openProfilesPanelLoop(ctx, live, opensOnLaunch ? launchName : target?.name); } /** `/gentle:profiles export-routing`: write the saved global routing to models.export.json. */ @@ -5572,7 +5606,8 @@ async function openProfilesPanelLoop( if (!store) return; const { path } = store; let file = store.file; - const sessionBoundName = () => readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.name; + // The profile this session launches with, bound or frozen: the tab marked ◉. + const sessionBoundName = () => sessionLaunchProfile(ctx)?.name; const pinWinner = () => evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles).winner?.profile; let selectedName = initialSelectedName ?? resolveCurrentProfileTarget(ctx.cwd, { status: "valid", file, drops: { droppedProfiles: [], droppedAgents: [], droppedActive: undefined } })?.name @@ -5712,8 +5747,7 @@ async function openProfilesPanelLoop( // Captures the routing this session's launches resolve and the // orchestrator it actually runs, the way the old snapshot did. const snapshot = profileSnapshotFrom( - readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles - ?? await readEffectiveModelConfigAsync(ctx.cwd), + await readSessionLaunchModelConfigAsync(ctx), liveOrchestratorSnapshot(ctx, live) ?? readOrchestratorSettings(orchestratorSettingsPath()), ); try { diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index 27eb26e7a..1946a70c0 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -18,6 +18,7 @@ import type { import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts"; import { PROFILES_KIND, PROFILES_VERSION, bootstrapProfilesFile, readProfilesFileResult, type AgentProfilesFile } from "../lib/agent-profiles.ts"; import { bindSessionProfile, readSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts"; +import { freezeSessionProfileAtStartup, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts"; import type { AgentModelConfig, AgentRoutingEntry, ThinkingLevel } from "../lib/model-routing-authority.ts"; type LiveSession = Pick; import { PROFILE_PIN_KIND, PROFILE_PIN_VERSION, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts"; @@ -406,7 +407,11 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) { process.env.USERPROFILE = isolatedHome; process.env.GENTLE_PI_CONFIG_HOME = configHome; process.env.GENTLE_PI_AGENT_HOME = agentHome; + // Every fixture models the same session id, so a profile frozen by one test + // must not become the next test's session profile. + resetFrozenInheritedProfilesForTesting(); t.after(() => { + resetFrozenInheritedProfilesForTesting(); if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME; else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome; if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME; @@ -3132,7 +3137,8 @@ test("From what's running now creates a profile from the routing in effect witho worker: readFileSync(workerPath, "utf8"), settings: readFileSync(settingsPath, "utf8"), }; - writeStore({ "a-target": {}, "z-active": { worker: { model: "openai/beta" } } }, "z-active"); + // No default profile: the session resolves none, so launches use the global routing. + writeStore({ "a-target": {}, "z-active": { worker: { model: "openai/beta" } } }, undefined); const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.newFromLive, "captured"); const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8")); @@ -3142,7 +3148,7 @@ test("From what's running now creates a profile from the routing in effect witho }); assert.deepEqual(store.profiles["a-target"], {}, "no existing profile is overwritten"); assert.deepEqual(store.profiles["z-active"], { worker: { model: "openai/beta" } }); - assert.equal(store.active, "z-active"); + assert.equal(store.active, undefined); assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models); assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents); assert.equal(readFileSync(workerPath, "utf8"), before.worker); @@ -3564,7 +3570,8 @@ test("at 60 columns the unmatched-key notice wraps instead of being cut", async // a suggestion is left to the launch warning. test("the models screen names subagents.json keys that match no agent and suggest none", async (t) => { const { fixture, writeStore } = profilesStoreFixture(t); - writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } } }, "team"); + // No default profile, so the session resolves none and subagents.json routes its launches. + writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } } }, undefined); writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { worker: { model: "openai/alpha" }, @@ -3777,9 +3784,10 @@ test("Use in this repo pins the shown profile for the clone without touching the assert.equal(readFileSync(storePath, "utf8"), storeBefore, "a pin stores a name, never routing"); assert.match(fixture.notifications.at(-1)?.message ?? "", /pinned profile "team" for this clone in /); assert.ok(firstPanel); - // The reopened screen marks the profile used in this repo (⌂) and says so. + // The reopened screen marks the profile used in this repo (⌂) and says so; + // ◉ stays on it because the session froze it as the default at startup. assert.ok(reopenedPanel); - assert.match(stripAnsi(renderComponent(reopenedPanel)), /team ★⌂/); + assert.match(stripAnsi(renderComponent(reopenedPanel)), /team ◉★⌂/); assert.match(stripAnsi(renderComponent(reopenedPanel)), /Used in this repo\./); // Rendering an existing screen must not resolve Git or read the pin again. setProfilePinWorktreeResolverForTesting(() => { throw new Error("unexpected pin read during render"); }); @@ -3882,7 +3890,7 @@ test("the tab of the pinned profile carries ⌂ and the screen opens on it", asy }); await fixture.run("gentle:profiles"); const tabLine = (rendered ?? "").split("\n")[1] ?? ""; - assert.match(tabLine, /other ⌂/); + assert.match(tabLine, /other ◉⌂/, "the pin present at startup is also the session's profile"); assert.doesNotMatch(tabLine, /team ★⌂/); assert.equal(selectedProfileTitle(rendered ?? ""), "other", "a winning pin is the profile the screen opens on"); }); @@ -4338,6 +4346,126 @@ test("Enter without a parent session id fails loud and writes nothing", async (t resetSessionProfileBindingsForTesting(); }); +// gentle-shell#1064 slice 3c: the routing screen shows the profile this session launches with. +function freezeScreenSession(fixture: ReturnType): void { + freezeSessionProfileAtStartup({ sessionId: "session-panel", cwd: fixture.root, configHome: fixture.configHome }); +} + +/** The first screen bare `/gentle:profiles` opens, as text, closed right after. */ +async function openedScreen(fixture: ReturnType): Promise { + let rendered: string | undefined; + fixture.onInput((panel) => { + rendered ??= stripAnsi(renderComponent(panel)); + panel.handleInput("\x1b"); + }); + await fixture.run("gentle:profiles"); + return rendered ?? ""; +} + +const screenTabLine = (rendered: string) => rendered.split("\n")[1] ?? ""; +const screenWorkerRow = (rendered: string) => rendered.split("\n").find((line) => /│\s*worker\s/.test(line)) ?? ""; + +test("a profile frozen at startup carries ◉ and opens first after the default changes", async (t) => { + const { fixture, storePath, writeStore } = profilesStoreFixture(t); + const profiles = { team: { worker: { model: "openai/alpha" } }, other: { worker: { model: "openai/beta" } } }; + writeStore(profiles, "team"); + freezeScreenSession(fixture); + writeStore(profiles, "other"); + const before = readFileSync(storePath, "utf8"); + const rendered = await openedScreen(fixture); + assert.match(screenTabLine(rendered), /team ◉(\s|$)/); + assert.match(screenTabLine(rendered), /other ★(\s|$)/); + assert.equal(selectedProfileTitle(rendered), "team"); + assert.match(screenWorkerRow(rendered), /openai\/alpha/); + assert.match(rendered, /Used in this session\./); + assert.equal(readFileSync(storePath, "utf8"), before); +}); + +test("an Enter binding opens on its profile while another is the default (#2451)", async (t) => { + const { fixture, writeStore } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } }, other: { worker: { model: "openai/beta" } } }, "other"); + t.after(() => resetSessionProfileBindingsForTesting()); + bindSessionProfile("session-panel", "team", { worker: { model: "openai/alpha" } }); + const rendered = await openedScreen(fixture); + assert.match(screenTabLine(rendered), /team ◉(\s|$)/); + assert.match(screenTabLine(rendered), /other ★(\s|$)/); + assert.equal(selectedProfileTitle(rendered), "team"); + assert.match(screenWorkerRow(rendered), /openai\/alpha/); + assert.doesNotMatch(screenWorkerRow(rendered), /openai\/beta/); +}); + +test("a pin present at startup is the session profile: ◉ and ⌂ on it, rows show its models (#2276)", async (t) => { + const { fixture, writeStore, writePin, localPinPath } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } }, other: { worker: { model: "openai/beta" } } }, "team"); + writePin(localPinPath, "other"); + freezeScreenSession(fixture); + const rendered = await openedScreen(fixture); + assert.match(screenTabLine(rendered), /other ◉⌂/); + assert.match(screenTabLine(rendered), /team ★(\s|$)/); + assert.equal(selectedProfileTitle(rendered), "other"); + assert.match(screenWorkerRow(rendered), /openai\/beta/); +}); + +test("a session without a profile marks no tab with ◉", async (t) => { + const { fixture, writeStore } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } }, other: {} }, undefined); + freezeScreenSession(fixture); + const rendered = await openedScreen(fixture); + assert.doesNotMatch(rendered, /◉|Used in this session/); + assert.equal(selectedProfileTitle(rendered), "team"); +}); + +test("a frozen profile deleted from profiles.json is named, and no tab carries ◉", async (t) => { + const { fixture, writeStore } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } }, other: {} }, "team"); + freezeScreenSession(fixture); + writeStore({ other: {} }, undefined); + const rendered = await openedScreen(fixture); + assert.doesNotMatch(rendered, /◉/); + assert.match( + rendered.replace(/\s*│\s*/g, " ").replace(/\s+/g, " "), + /This session launches with "team", which is no longer in profiles\.json; its launches keep that routing\./, + ); +}); + +test("From what's running now in a frozen session saves the frozen routing, not the global one or a later pin", async (t) => { + const { fixture, storePath, settingsPath, writeStore, writeSettings, writePin, localPinPath } = profilesStoreFixture(t); + writeSettings(); + mkdirSync(fixture.configHome, { recursive: true }); + writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/gamma" } }, null, 2)}\n`); + writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } }, other: { worker: { model: "openai/beta" } } }, "team"); + freezeScreenSession(fixture); + writePin(localPinPath, "other"); + const before = { models: readFileSync(fixture.globalPath, "utf8"), pin: readFileSync(localPinPath, "utf8"), settings: readFileSync(settingsPath, "utf8") }; + await runProfilesNameAction(fixture, SCREEN_KEYS.newFromLive, "captured"); + const store = JSON.parse(readFileSync(storePath, "utf8")); + assert.deepEqual(store.profiles, { + team: { worker: { model: "openai/alpha", thinking: "high" } }, + other: { worker: { model: "openai/beta" } }, + captured: { worker: { model: "openai/alpha", thinking: "high" }, orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" } }, + }); + assert.equal(store.active, "team"); + assert.deepEqual({ models: readFileSync(fixture.globalPath, "utf8"), pin: readFileSync(localPinPath, "utf8"), settings: readFileSync(settingsPath, "utf8") }, before); +}); + +test("the subagents.json notice follows whether the session resolves a profile, not the pin", async (t) => { + const { fixture, writeStore, writePin, localPinPath } = profilesStoreFixture(t); + writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { explor: { model: "openai/beta" } } }, null, 2)}\n`); + writeStore({ team: { worker: { model: "openai/alpha" } } }, "team"); + freezeScreenSession(fixture); + const frozen = await openedScreen(fixture); + assert.match(frozen, /Used in this session\./); + assert.doesNotMatch(frozen, /subagents\.json routes/, "the frozen profile replaces those keys"); + + resetFrozenInheritedProfilesForTesting(); + writeStore({ team: { worker: { model: "openai/alpha" } } }, undefined); + freezeScreenSession(fixture); + writePin(localPinPath, "team"); + const unfrozen = await openedScreen(fixture); + assert.match(unfrozen, /Used in this repo\./); + assert.match(unfrozen, /subagents\.json routes "explor", which no agent matches, so it is ignored\./, "a pin written after the freeze does not replace them"); +}); + test("file operations are subcommands: profiles restore-routing, export and import, and usage for anything else", async (t) => { const { fixture, storePath, writeStore } = profilesStoreFixture(t); writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } } }, "team"); From 8f5b3ec78992b94a444c30d4d8c0ba3094827417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:51:42 +0000 Subject: [PATCH 2/4] chore: drop the local node_modules link from the merge --- node_modules | 1 - 1 file changed, 1 deletion(-) delete mode 120000 node_modules diff --git a/node_modules b/node_modules deleted file mode 120000 index 6b1b5bdbc..000000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -../feat-models-profiles-fusion/node_modules \ No newline at end of file From 52f24d8e45c5a6ef83cd7d64d35cc3c37e0d2370 Mon Sep 17 00:00:00 2001 From: Adrian Cester Trallero Date: Fri, 30 Oct 2026 02:19:33 +0100 Subject: [PATCH 3/4] fix(profiles): say when the saved session profile differs from its launches When the shown tab is the session's profile and profiles.json was edited after the session froze or bound it, the table shows the saved routing while the launches keep the routing they took. The screen now names that with a notice; Use in this session adopts what the table shows. Part of #1064 (slice 3c, read side). --- docs/readme-reference.md | 2 +- extensions/gentle-ai.ts | 24 ++++++++++++++++-------- tests/gentle-ai.test.ts | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 306bf29a9..6bb4d600e 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -1034,7 +1034,7 @@ A project can still override the global default with: - **The table is that profile's routing.** The orchestrator is the first row; agents follow. Click a model or press `enter` to choose from a searchable list (the current one is marked `✓`; **Custom model id…** takes any `provider/model`). Click an effort or press `e` to pick from a single row of levels: only the levels that row's model supports, as its provider's catalog declares them (every level when the row inherits its model or the model is not in the catalog). Pi lands an unsupported level on the nearest supported one at launch, and the table says where: `max → high`. Click `Model ▾` or `Effort ▾`, or move the cursor to that header, to change every agent at once; the orchestrator is never part of that. - **Three buttons decide where it applies.** **Use in this session** (`u`) binds what you see to this Pi session only and switches its orchestrator; nothing shared is written. **Make default** (`d`) makes it the default for new sessions through the guarded global apply (diff confirmation, pin honesty, rollback). **Save** (`s` or `ctrl+s`) writes what you see into the profile. Use and Make default save pending edits first and say so. - **The profile menu**, `▾` on the active tab (`m`), holds **Rename**, **Duplicate**, **Use in this repo** (the clone pin; it reads **Stop using in this repo** when it already applies), and **Delete**. **+ New** (`n`) starts a profile as a copy of the current one, from what is running now, or empty. -- Problems the screen will not hide appear under the profile name with `⚠`: a pin file that is not a valid pin or names a profile that does not exist (either way it is ignored), routing keys no agent matches (ignored at launch), with the agent name it probably meant when there is one, and a session profile that was deleted from `profiles.json` after this session took it (its launches keep that routing). +- Problems the screen will not hide appear under the profile name with `⚠`: a pin file that is not a valid pin or names a profile that does not exist (either way it is ignored), routing keys no agent matches (ignored at launch), with the agent name it probably meant when there is one, and a session profile that was deleted from `profiles.json` after this session took it (its launches keep that routing), or edited there since (the table shows the saved routing, the launches keep the one they took, and Use in this session adopts the table). - Edited values are highlighted until saved, and the footer counts them (`● 2 unsaved`). `esc` closes; with unsaved edits the first press warns and the second closes without saving. Every action reports on the line under the footer. - Everything clickable highlights under the pointer, and a click runs one action. Keys: `←→` profile, `↑↓` agent, `enter` model, `e` effort, `s` save, `u` use, `d` default, `n` new, `m` the profile menu, `esc` close. diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 187e53681..1623a7d80 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -4522,6 +4522,7 @@ function routingScreenNotices( file: AgentProfilesFile, shownName: string, sessionBoundName: string | undefined, + launchRouting?: AgentModelConfig, ): string[] { const notices: string[] = []; const pins = evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles); @@ -4534,6 +4535,14 @@ function routingScreenNotices( if (sessionBoundName !== undefined && !hasOwnProfile(file.profiles, sessionBoundName)) { notices.push(`This session launches with "${sessionBoundName}", which is no longer in profiles.json; its launches keep that routing.`); } + // The table shows the saved profile; the session's launches keep the routing + // they took at the freeze or the binding. Both sides compare role routes only. + const roleRoutes = (config: unknown) => JSON.stringify(Object.entries(normalizeModelConfig(config) ?? {}) + .filter(([key]) => !isProfileOrchestratorKey(key)).sort(([a], [b]) => a.localeCompare(b))); + if (shownName === sessionBoundName && launchRouting !== undefined && hasOwnProfile(file.profiles, shownName) + && roleRoutes(file.profiles[shownName]) !== roleRoutes(launchRouting)) { + notices.push(`This table shows the saved "${shownName}"; this session's launches keep the routing they took. Use in this session adopts what you see.`); + } const roots = { cwd: ctx.sessionManager?.getCwd?.() ?? ctx.cwd, home: homedir(), @@ -5651,8 +5660,6 @@ async function openProfilesPanelLoop( if (!store) return; const { path } = store; let file = store.file; - // The profile this session launches with, bound or frozen: the tab marked ◉. - const sessionBoundName = () => sessionLaunchProfile(ctx)?.name; const pinWinner = () => evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles).winner?.profile; let selectedName = initialSelectedName ?? resolveCurrentProfileTarget(ctx.cwd, { status: "valid", file, drops: { droppedProfiles: [], droppedAgents: [], droppedActive: undefined } })?.name @@ -5680,12 +5687,13 @@ async function openProfilesPanelLoop( return { error }; } selectedName = name; - const boundName = sessionBoundName(); + // The profile this session launches with, bound or frozen: the tab marked ◉. + const launch = sessionLaunchProfile(ctx); return { file, - sessionBoundName: boundName, + sessionBoundName: launch?.name, pinWinner: pinWinner(), - notices: routingScreenNotices(ctx, file, name, boundName), + notices: routingScreenNotices(ctx, file, name, launch?.name, launch?.modelProfiles), feedback: `${saveFirst ? `Saved "${previous}". ` : ""}Showing "${name}".`, }; }; @@ -5725,15 +5733,15 @@ async function openProfilesPanelLoop( } const shown = selectedName; const stored = cloneModelConfig(normalizeModelConfig(file.profiles[shown]) ?? {}); - const boundName = sessionBoundName(); + const launch = sessionLaunchProfile(ctx); const result = await step(() => showRoutingTablePanel(ctx, { name: shown, config: draftOverride ?? stored, file, - sessionBoundName: boundName, + sessionBoundName: launch?.name, pinWinner: pinWinner(), feedback, - notices: routingScreenNotices(ctx, file, shown, boundName), + notices: routingScreenNotices(ctx, file, shown, launch?.name, launch?.modelProfiles), switchProfile, })); draftOverride = undefined; diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index e5b3d647e..5cbc39332 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -4660,6 +4660,8 @@ async function openedScreen(fixture: ReturnType): const screenTabLine = (rendered: string) => rendered.split("\n")[1] ?? ""; const screenWorkerRow = (rendered: string) => rendered.split("\n").find((line) => /│\s*worker\s/.test(line)) ?? ""; +const savedRoutingNotice = (rendered: string, name: string) => + rendered.replace(/\s*│\s*/g, " ").replace(/\s+/g, " ").includes(`This table shows the saved "${name}"; this session's launches keep the routing they took. Use in this session adopts what you see.`); test("a profile frozen at startup carries ◉ and opens first after the default changes", async (t) => { const { fixture, storePath, writeStore } = profilesStoreFixture(t); @@ -4674,9 +4676,25 @@ test("a profile frozen at startup carries ◉ and opens first after the default assert.equal(selectedProfileTitle(rendered), "team"); assert.match(screenWorkerRow(rendered), /openai\/alpha/); assert.match(rendered, /Used in this session\./); + assert.equal(savedRoutingNotice(rendered, "team"), false, "the saved routing matches the launch routing"); assert.equal(readFileSync(storePath, "utf8"), before); }); +test("a session profile edited in profiles.json after the freeze or the binding says its launches keep the older routing", async (t) => { + const { fixture, writeStore } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } } }, "team"); + freezeScreenSession(fixture); + writeStore({ team: { worker: { model: "openai/beta" } } }, "team"); + const frozen = await openedScreen(fixture); + assert.match(screenWorkerRow(frozen), /openai\/beta/); + assert.equal(savedRoutingNotice(frozen, "team"), true); + + resetFrozenInheritedProfilesForTesting(); + t.after(() => resetSessionProfileBindingsForTesting()); + bindSessionProfile("session-panel", "team", { worker: { model: "openai/alpha" } }); + assert.equal(savedRoutingNotice(await openedScreen(fixture), "team"), true); +}); + test("an Enter binding opens on its profile while another is the default (#2451)", async (t) => { const { fixture, writeStore } = profilesStoreFixture(t); writeStore({ team: { worker: { model: "openai/alpha" } }, other: { worker: { model: "openai/beta" } } }, "other"); From c84350c01e9eb37b478bc473bfb2d3f596b8cb70 Mon Sep 17 00:00:00 2001 From: Adrian Cester Trallero Date: Fri, 30 Oct 2026 09:46:26 +0100 Subject: [PATCH 4/4] fix(profiles): keep the session glyph off while session profile authority is unavailable An invalid or unsupported session profile record clears the binding and blocks launches, but the routing screen then fell back to the frozen profile and marked it as this session's. While authority is unavailable the screen now resolves no launch profile, shows a notice that launches are blocked until Use in this session selects one, and From what's running now creates nothing. Authority is read once per visit, as the launcher reads it. The saved-versus-launch notice is shortened so it fits the four-line notice budget at 40 columns. Part of #1064 (slice 3c, read side). --- docs/readme-reference.md | 2 +- extensions/gentle-ai.ts | 49 +++++++++++++++++++++++++++++---------- tests/gentle-ai.test.ts | 50 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 6bb4d600e..b87f513e1 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -1034,7 +1034,7 @@ A project can still override the global default with: - **The table is that profile's routing.** The orchestrator is the first row; agents follow. Click a model or press `enter` to choose from a searchable list (the current one is marked `✓`; **Custom model id…** takes any `provider/model`). Click an effort or press `e` to pick from a single row of levels: only the levels that row's model supports, as its provider's catalog declares them (every level when the row inherits its model or the model is not in the catalog). Pi lands an unsupported level on the nearest supported one at launch, and the table says where: `max → high`. Click `Model ▾` or `Effort ▾`, or move the cursor to that header, to change every agent at once; the orchestrator is never part of that. - **Three buttons decide where it applies.** **Use in this session** (`u`) binds what you see to this Pi session only and switches its orchestrator; nothing shared is written. **Make default** (`d`) makes it the default for new sessions through the guarded global apply (diff confirmation, pin honesty, rollback). **Save** (`s` or `ctrl+s`) writes what you see into the profile. Use and Make default save pending edits first and say so. - **The profile menu**, `▾` on the active tab (`m`), holds **Rename**, **Duplicate**, **Use in this repo** (the clone pin; it reads **Stop using in this repo** when it already applies), and **Delete**. **+ New** (`n`) starts a profile as a copy of the current one, from what is running now, or empty. -- Problems the screen will not hide appear under the profile name with `⚠`: a pin file that is not a valid pin or names a profile that does not exist (either way it is ignored), routing keys no agent matches (ignored at launch), with the agent name it probably meant when there is one, and a session profile that was deleted from `profiles.json` after this session took it (its launches keep that routing), or edited there since (the table shows the saved routing, the launches keep the one they took, and Use in this session adopts the table). +- Problems the screen will not hide appear under the profile name with `⚠`: a pin file that is not a valid pin or names a profile that does not exist (either way it is ignored), routing keys no agent matches (ignored at launch), with the agent name it probably meant when there is one, and a session profile that was deleted from `profiles.json` after this session took it (its launches keep that routing), or edited there since (the table shows the saved routing, the launches keep the one they took, and Use in this session adopts the table). When the session profile record cannot be read, the screen says its launches are blocked, marks no tab with `◉`, and refuses From what's running now until Use in this session selects a profile. - Edited values are highlighted until saved, and the footer counts them (`● 2 unsaved`). `esc` closes; with unsaved edits the first press warns and the second closes without saving. Every action reports on the line under the footer. - Everything clickable highlights under the pointer, and a click runs one action. Keys: `←→` profile, `↑↓` agent, `enter` model, `e` effort, `s` save, `u` use, `d` default, `n` new, `m` the profile menu, `esc` close. diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 1623a7d80..7054208fc 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -2508,9 +2508,11 @@ function readGlobalEffectiveModelConfig(cwd: string): AgentModelConfig { * launcher reads. It resolves from the session's directory, as the freeze and * the launcher do. `undefined` means the session resolves no profile, so * launches keep the global routing; a pin the session did not freeze never - * reaches them and is not read here. + * reaches them and is not read here. Without session profile authority the + * launches are blocked, so no profile is theirs. */ -function sessionLaunchProfile(ctx: ExtensionContext): { name: string; modelProfiles: AgentModelConfig } | undefined { +function sessionLaunchProfile(ctx: ExtensionContext, authorityAvailable: boolean): { name: string; modelProfiles: AgentModelConfig } | undefined { + if (!authorityAvailable) return undefined; const resolution = resolveSessionProfile({ sessionId: ctx.sessionManager?.getSessionId?.(), cwd: ctx.sessionManager?.getCwd?.() ?? ctx.cwd, @@ -2521,9 +2523,20 @@ function sessionLaunchProfile(ctx: ExtensionContext): { name: string; modelProfi return name === undefined || routing === undefined ? undefined : { name, modelProfiles: cloneModelConfig(routing) }; } +/** + * Whether this session's launches may run, read as the launcher reads it (and, + * like it, clearing a binding it cannot corroborate). A manager without a + * branch has no session profile record to distrust. + */ +function sessionProfileAuthorityAvailable(ctx: ExtensionContext): boolean { + const manager = ctx.sessionManager; + if (typeof manager?.getSessionId !== "function" || typeof manager.getBranch !== "function") return true; + return readSessionProfileAuthority(manager).available; +} + /** The routing this session's launches use: its profile, or the global effective routing. */ -async function readSessionLaunchModelConfigAsync(ctx: ExtensionContext): Promise { - return sessionLaunchProfile(ctx)?.modelProfiles +async function readSessionLaunchModelConfigAsync(ctx: ExtensionContext, authorityAvailable: boolean): Promise { + return sessionLaunchProfile(ctx, authorityAvailable)?.modelProfiles ?? await readGlobalEffectiveModelConfigFromAsync(ctx.cwd, await readModelConfigAsync(ctx.cwd)); } @@ -4523,8 +4536,10 @@ function routingScreenNotices( shownName: string, sessionBoundName: string | undefined, launchRouting?: AgentModelConfig, + authorityAvailable = true, ): string[] { const notices: string[] = []; + if (!authorityAvailable) notices.push("Session profile authority is unavailable; affected launches are blocked. Use in this session selects one."); const pins = evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles); for (const issue of pins.invalid) { notices.push(`The ${issue.source} pin file ${issue.path} is not a valid pin and is ignored. Fix or remove it.`); @@ -4541,7 +4556,7 @@ function routingScreenNotices( .filter(([key]) => !isProfileOrchestratorKey(key)).sort(([a], [b]) => a.localeCompare(b))); if (shownName === sessionBoundName && launchRouting !== undefined && hasOwnProfile(file.profiles, shownName) && roleRoutes(file.profiles[shownName]) !== roleRoutes(launchRouting)) { - notices.push(`This table shows the saved "${shownName}"; this session's launches keep the routing they took. Use in this session adopts what you see.`); + notices.push(`Saved "${shownName}" differs from this session's launches; Use in this session adopts it.`); } const roots = { cwd: ctx.sessionManager?.getCwd?.() ?? ctx.cwd, @@ -4650,9 +4665,10 @@ async function openModelsScreen(ctx: ExtensionContext, live: LiveSession): Promi : undefined; // The screen opens on the profile this session launches with while the store // still has it (gentle-shell#1064 slice 3c). - const launchName = sessionLaunchProfile(ctx)?.name; + const authorityAvailable = sessionProfileAuthorityAvailable(ctx); + const launchName = sessionLaunchProfile(ctx, authorityAvailable)?.name; const opensOnLaunch = read.status === "valid" && launchName !== undefined && hasOwnProfile(read.file.profiles, launchName); - await openProfilesPanelLoop(ctx, live, opensOnLaunch ? launchName : target?.name); + await openProfilesPanelLoop(ctx, live, opensOnLaunch ? launchName : target?.name, authorityAvailable); } /** `/gentle:profiles export-routing`: write the saved global routing to models.export.json. */ @@ -5655,6 +5671,7 @@ async function openProfilesPanelLoop( ctx: ExtensionContext, live: LiveSession, initialSelectedName?: string, + openingAuthority?: boolean, ): Promise { const store = await loadProfilesStore(ctx); if (!store) return; @@ -5666,6 +5683,8 @@ async function openProfilesPanelLoop( ?? Object.keys(file.profiles)[0]; let feedback: string | undefined; let draftOverride: AgentModelConfig | undefined; + // Read once per visit: the opening read serves the first one. + let authorityAvailable = true; const writeDraft = (name: string, config: AgentModelConfig): boolean => { try { const next = hasOwnProfile(file.profiles, name) @@ -5688,12 +5707,12 @@ async function openProfilesPanelLoop( } selectedName = name; // The profile this session launches with, bound or frozen: the tab marked ◉. - const launch = sessionLaunchProfile(ctx); + const launch = sessionLaunchProfile(ctx, authorityAvailable); return { file, sessionBoundName: launch?.name, pinWinner: pinWinner(), - notices: routingScreenNotices(ctx, file, name, launch?.name, launch?.modelProfiles), + notices: routingScreenNotices(ctx, file, name, launch?.name, launch?.modelProfiles, authorityAvailable), feedback: `${saveFirst ? `Saved "${previous}". ` : ""}Showing "${name}".`, }; }; @@ -5733,7 +5752,9 @@ async function openProfilesPanelLoop( } const shown = selectedName; const stored = cloneModelConfig(normalizeModelConfig(file.profiles[shown]) ?? {}); - const launch = sessionLaunchProfile(ctx); + authorityAvailable = openingAuthority ?? sessionProfileAuthorityAvailable(ctx); + openingAuthority = undefined; + const launch = sessionLaunchProfile(ctx, authorityAvailable); const result = await step(() => showRoutingTablePanel(ctx, { name: shown, config: draftOverride ?? stored, @@ -5741,7 +5762,7 @@ async function openProfilesPanelLoop( sessionBoundName: launch?.name, pinWinner: pinWinner(), feedback, - notices: routingScreenNotices(ctx, file, shown, launch?.name, launch?.modelProfiles), + notices: routingScreenNotices(ctx, file, shown, launch?.name, launch?.modelProfiles, authorityAvailable), switchProfile, })); draftOverride = undefined; @@ -5815,6 +5836,10 @@ async function openProfilesPanelLoop( continue; } case "create-live": { + if (!authorityAvailable) { + feedback = `${savedNote}Session profile authority is unavailable; affected launches are blocked. No profile created.`; + continue; + } const answer = await step(() => ctx.ui.input("Name for a profile of what's running now", "e.g. snapshot")); const name = answer?.trim() ?? ""; if (name === "") { @@ -5824,7 +5849,7 @@ async function openProfilesPanelLoop( // Captures the routing this session's launches resolve and the // orchestrator it actually runs, the way the old snapshot did. const snapshot = profileSnapshotFrom( - await step(() => readSessionLaunchModelConfigAsync(ctx)), + await step(() => readSessionLaunchModelConfigAsync(ctx, authorityAvailable)), liveOrchestratorSnapshot(ctx, live) ?? readOrchestratorSettings(orchestratorSettingsPath()), ); try { diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index 5cbc39332..44ffcfd3c 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -4648,10 +4648,10 @@ function freezeScreenSession(fixture: ReturnType) } /** The first screen bare `/gentle:profiles` opens, as text, closed right after. */ -async function openedScreen(fixture: ReturnType): Promise { +async function openedScreen(fixture: ReturnType, width = 120): Promise { let rendered: string | undefined; fixture.onInput((panel) => { - rendered ??= stripAnsi(renderComponent(panel)); + rendered ??= stripAnsi(panel.render(width).join("\n")); panel.handleInput("\x1b"); }); await fixture.run("gentle:profiles"); @@ -4660,8 +4660,50 @@ async function openedScreen(fixture: ReturnType): const screenTabLine = (rendered: string) => rendered.split("\n")[1] ?? ""; const screenWorkerRow = (rendered: string) => rendered.split("\n").find((line) => /│\s*worker\s/.test(line)) ?? ""; +const flatScreen = (rendered: string) => rendered.replace(/\s*│\s*/g, " ").replace(/\s+/g, " "); const savedRoutingNotice = (rendered: string, name: string) => - rendered.replace(/\s*│\s*/g, " ").replace(/\s+/g, " ").includes(`This table shows the saved "${name}"; this session's launches keep the routing they took. Use in this session adopts what you see.`); + flatScreen(rendered).includes(`Saved "${name}" differs from this session's launches; Use in this session adopts it.`); +const authorityNotice = "Session profile authority is unavailable; affected launches are blocked. Use in this session selects one."; + +/** A real session frozen on "team"; `unreadable` appends a session profile record it cannot read. */ +function frozenRealSession(fixture: ReturnType, unreadable: boolean): void { + const manager = profileActionManager(fixture.root); + freezeSessionProfileAtStartup({ sessionId: manager.getSessionId(), cwd: fixture.root, configHome: fixture.configHome }); + if (unreadable) manager.appendCustomEntry("gentle-pi.session-profile/v99", { invalid: true }); + fixture.useSessionManager(manager); +} + +test("a frozen profile is not marked as used while session profile authority is unavailable", async (t) => { + const { fixture, writeStore } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } }, other: {} }, "team"); + frozenRealSession(fixture, false); + const available = await openedScreen(fixture); + assert.match(screenTabLine(available), /team ◉/); + assert.doesNotMatch(flatScreen(available), /authority is unavailable/); + + frozenRealSession(fixture, true); + const rendered = await openedScreen(fixture, 40); + assert.doesNotMatch(rendered, /◉|Used in this session\./); + assert.ok(flatScreen(rendered).includes(authorityNotice), "the whole notice fits at 40 columns"); + + t.after(() => resetSessionProfileBindingsForTesting()); + actOnProfile(fixture, "u", "team"); + await fixture.run("gentle:profiles"); + const recovered = fixture.panels.at(-1) ?? ""; + assert.match(screenTabLine(recovered), /team ◉/, "Use in this session recovers"); + assert.doesNotMatch(flatScreen(recovered), /authority is unavailable/); +}); + +test("From what's running now refuses while session profile authority is unavailable", async (t) => { + const { fixture, storePath, writeStore } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } } }, "team"); + frozenRealSession(fixture, true); + const before = readFileSync(storePath, "utf8"); + const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.newFromLive); + assert.equal(fixture.inputPrompts.length, 0, "no name is asked for"); + assert.match(profilesPanelFooter(reopened), /Session profile authority is unavailable; affected launches are blocked\. No profile created\./); + assert.equal(readFileSync(storePath, "utf8"), before); +}); test("a profile frozen at startup carries ◉ and opens first after the default changes", async (t) => { const { fixture, storePath, writeStore } = profilesStoreFixture(t); @@ -4677,6 +4719,7 @@ test("a profile frozen at startup carries ◉ and opens first after the default assert.match(screenWorkerRow(rendered), /openai\/alpha/); assert.match(rendered, /Used in this session\./); assert.equal(savedRoutingNotice(rendered, "team"), false, "the saved routing matches the launch routing"); + assert.doesNotMatch(flatScreen(rendered), /authority is unavailable/); assert.equal(readFileSync(storePath, "utf8"), before); }); @@ -4688,6 +4731,7 @@ test("a session profile edited in profiles.json after the freeze or the binding const frozen = await openedScreen(fixture); assert.match(screenWorkerRow(frozen), /openai\/beta/); assert.equal(savedRoutingNotice(frozen, "team"), true); + assert.equal(savedRoutingNotice(await openedScreen(fixture, 40), "team"), true, "the whole notice fits at 40 columns"); resetFrozenInheritedProfilesForTesting(); t.after(() => resetSessionProfileBindingsForTesting());