From a941993e548da9fcd176b88d364f20cf64c4d502 Mon Sep 17 00:00:00 2001 From: danielgap Date: Tue, 29 Sep 2026 19:16:51 +0200 Subject: [PATCH 1/5] feat(profiles): bind the active profile to the parent session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1/2 of #1064: Enter in /gentle:profiles binds the selected profile to the current parent session as in-process state keyed by the session id. It writes nothing — no store marker, no models.json, no materialized stores, no agent frontmatter, no Pi settings, and no pin or declaration layer, pin or not — and the legacy global apply moves to the explicit a key with its semantics unchanged. The binding is visible in the panel itself: the bound profile is listed and detailed as "name (session)", outranking the pin marker. The launch resolver, the shell status, and the usage scope consume the same store in the follow-up slice. --- extensions/gentle-ai.ts | 52 +++++++++++++- lib/agent-profiles.ts | 14 +++- lib/session-profile-binding.ts | 67 ++++++++++++++++++ tests/agent-profiles.test.ts | 12 ++++ tests/gentle-ai.test.ts | 99 ++++++++++++++++++++++++++- tests/session-profile-binding.test.ts | 92 +++++++++++++++++++++++++ 6 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 lib/session-profile-binding.ts create mode 100644 tests/session-profile-binding.test.ts diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index a0fed68c1..46d4c5a07 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -95,6 +95,7 @@ import { type ProfilePinSource, type ProfilePinStatus, } from "../lib/agent-profile-pin.ts"; +import { bindSessionProfile, readSessionProfileBinding } from "../lib/session-profile-binding.ts"; import { applyOrchestratorSettings, readOrchestratorSettings, @@ -3489,6 +3490,7 @@ function updateCurrentProfileFromSavedRouting(ctx: ExtensionContext, pi: Extensi type ProfilesPanelResult = | { type: "apply"; name: string } + | { type: "apply-global"; name: string } | { type: "create" } | { type: "update"; name: string } | { type: "duplicate"; name: string } @@ -3676,6 +3678,7 @@ class ProfilesPanel implements OverlayComponent { private readonly orchestratorSettings: OrchestratorSettingsReadResult; // Actions reopen the panel, refreshing this snapshot without disk reads during rendering. private readonly pinStatus: ProfilePinStatus | undefined; + private readonly sessionBoundName: string | undefined; constructor( file: AgentProfilesFile, @@ -3689,6 +3692,7 @@ class ProfilesPanel implements OverlayComponent { saveSnapshot: ProfilesSnapshotHandler, requestRender: () => void, pinStatus: () => ProfilePinStatus | undefined, + sessionBound: () => string | undefined, feedback?: string, ) { this.file = file; @@ -3701,7 +3705,8 @@ class ProfilesPanel implements OverlayComponent { this.rows = rows; this.orchestratorSettings = orchestratorSettings; this.pinStatus = pinStatus(); - const items = buildProfileListItems(file, evaluateProfilePin(this.pinStatus, file.profiles).winner?.profile); + this.sessionBoundName = sessionBound(); + const items = buildProfileListItems(file, evaluateProfilePin(this.pinStatus, file.profiles).winner?.profile, this.sessionBoundName); this.listItems = items; this.list = new NativeChoiceList( items, @@ -3764,6 +3769,7 @@ class ProfilesPanel implements OverlayComponent { this.requestRender(); return; } + if (data === "a") return this.finish({ type: "apply-global", name }); if (data === "d") return this.finish({ type: "duplicate", name }); if (data === "r") return this.finish({ type: "rename", name }); if (data === "x") return this.finish({ type: "delete", name }); @@ -3889,7 +3895,7 @@ class ProfilesPanel implements OverlayComponent { private renderFooterRow(width: number): string { const hints = - "enter apply · c create · s snapshot · d duplicate · r rename · x delete · e export · i import · p pin · P share · j/k line · ctrl+j/k page · esc close"; + "enter use in this session · a set as global default · c create · s snapshot · d duplicate · r rename · x delete · e export · i import · p pin · P share · j/k line · ctrl+j/k page · esc close"; const text = this.feedback ?? hints; return [ this.renderText("│", "border"), @@ -3925,6 +3931,12 @@ class ProfilesPanel implements OverlayComponent { // any invalid or stale layer, and the scope sentence all come from the shared // precedence rule the launch resolver uses. ...profilePinDetailLines(this.pinStatus, this.file.profiles).map((line) => this.renderLine(line, width, "muted")), + // gentle-shell#1064 slice 1: a session binding outranks the pin for this + // session's launches, so it is named right after the pin layers it + // supersedes — the same "name (session)" spelling the shell bar shows. + ...(this.sessionBoundName === undefined + ? [] + : [this.renderLine(`session ${sanitizeTerminalText(this.sessionBoundName)} (session) — this session's launches resolve it; nothing was written`, width, "muted")]), "", this.renderLine("Profile routing", width, "accent"), ...this.indentLines(this.routingLines(profileRows, widths), width), @@ -3988,6 +4000,7 @@ async function showProfilesPanel( selectedName: string | undefined, saveSnapshot: ProfilesSnapshotHandler, status?: string, + sessionBoundName?: string, ): Promise { // Both orchestrator and pin state are snapshots for this panel visit. // Actions (including p/P) reopen the panel and read fresh state. @@ -4006,6 +4019,7 @@ async function showProfilesPanel( saveSnapshot, () => tui.requestRender(), () => readProfilePinStatus(ctx.cwd), + () => sessionBoundName, status, ); const container = createNativeFullscreenInteraction({ @@ -4113,6 +4127,30 @@ async function runProfilesPanelAction( ): Promise { switch (result.type) { case "apply": { + if (!hasOwnProfile(file.profiles, result.name)) return file; + // gentle-shell#1064 slice 1: Enter binds the selected profile to this + // parent session. The binding is in-process state keyed by the session + // id: it writes no store marker, no global routing, no materialized + // stores, no agent frontmatter, no Pi settings, and no pin or declaration + // layer, pin or not. Launches from this session resolve it ahead of + // p/P/global; queued and running children keep the routing their task + // requests already carry. Refreshing the binding means selecting again. + const sessionId = ctx.sessionManager?.getSessionId?.(); + if (typeof sessionId !== "string" || sessionId.length === 0) { + ctx.ui.notify( + `el Gentleman cannot bind profile "${result.name}" to this session: no parent session id is available here. Set it as the global default with a instead.`, + "warning", + ); + return file; + } + bindSessionProfile(sessionId, result.name, normalizeModelConfig(file.profiles[result.name]) ?? {}); + ctx.ui.notify( + `el Gentleman bound profile "${result.name}" to this session — shown as "${result.name} (session)". Subagent launches from this session resolve it; queued and running children keep their routing. Nothing was written: the global routing, pins, and materialized stores are untouched. Set as global default with a.`, + "info", + ); + return file; + } + case "apply-global": { if (!hasOwnProfile(file.profiles, result.name)) return file; // A pinned repository resolves its subagent routing from the profile at launch, // so a global apply would move global state this repository never reads. When a @@ -4571,12 +4609,15 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): return next; }; let selectedName: string | undefined; + const sessionBoundName = () => readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.name; let result = await showProfilesPanel( ctx, file, await readEffectiveModelConfigAsync(ctx.cwd), selectedName, saveSnapshot, + undefined, + sessionBoundName(), ); while (result.type !== "close") { const report: ProfilesPanelReport = {}; @@ -4589,6 +4630,7 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): selectedName, saveSnapshot, report.status, + sessionBoundName(), ); } } @@ -5922,6 +5964,11 @@ const processRetainedNativeStatusSelections = new Map(); +// gentle-shell#1064 slice 1: the parent-session profile binding store lives in +// lib/session-profile-binding.ts (in-process, keyed by parent session id). The +// panel binds on Enter; the launch resolver, the shell status reader, and the +// usage provider scope read the same store through the lib. + // gentle-pi#677: gentle-ai#4309 owns anonymous usage telemetry end to end; // Pi only nudges it once per process. This is a plain process-lifetime // guard, not a session-keyed map, because the nudge is meant to fire at most @@ -8694,6 +8741,7 @@ async function executeReviewControllerOperation( /** @internal */ export const __testing = { + runProfilesPanelAction, resolveReviewModeGate, readEffectiveModelConfig, readEffectiveModelConfigAsync, diff --git a/lib/agent-profiles.ts b/lib/agent-profiles.ts index f4c953460..170790bc6 100644 --- a/lib/agent-profiles.ts +++ b/lib/agent-profiles.ts @@ -409,15 +409,23 @@ export interface ProfileListItem { export function buildProfileListItems( file: AgentProfilesFile, pinned?: string, + sessionBound?: string, ): ProfileListItem[] { return Object.entries(file.profiles).map(([name, config]) => { const roles = profileRoleEntries(config).length; const active = name === file.active ? `${name} (active)` : name; + // A pinned profile is the one this repository launches with, which is not the + // same thing as the globally active profile, so both are named. A session + // binding outranks the pin (it is what this session's launches resolve now), + // so its marker wins on the same profile. + const label = name === sessionBound + ? `${active} (session)` + : name === pinned + ? `${active} (pinned)` + : active; return { id: name, - // A pinned profile is the one this repository launches with, which is not the - // same thing as the globally active profile, so both are named. - label: name === pinned ? `${active} (pinned)` : active, + label, description: `${roles} ${roles === 1 ? "role" : "roles"}`, }; }); diff --git a/lib/session-profile-binding.ts b/lib/session-profile-binding.ts new file mode 100644 index 000000000..4efebbd01 --- /dev/null +++ b/lib/session-profile-binding.ts @@ -0,0 +1,67 @@ +// Session-bound active profiles (gentle-shell#1064, slice 1). +// +// One parent Pi session may bind one saved profile for its own launches without +// touching any shared state: no clone pin (`p`), no repository declaration (`P`), +// no global routing, no materialized stores, no agent frontmatter, no Pi default +// settings. The binding is an in-process map keyed by the parent session id, so +// concurrent Pi processes are isolated by construction and a resumed session +// starts from the shared layers (`p → P → global`) until it binds again. +// Persistence across resume and reload semantics are deliberate follow-up +// slices; this module never writes a file. +import type { AgentModelConfig } from "./model-routing-authority.ts"; + +/** A session's bound profile: its saved name plus the routing snapshot taken at bind time. */ +export interface SessionProfileBinding { + readonly name: string; + readonly modelProfiles: AgentModelConfig; +} + +const bindingsBySession = new Map(); + +function cloneProfiles(profiles: AgentModelConfig): AgentModelConfig { + return Object.fromEntries(Object.entries(profiles).map(([agent, entry]) => [agent, { ...entry }])); +} + +/** + * Bind (or rebind) one parent session to a profile. The routing snapshot is + * copied, so later edits to the caller's object or to the saved profile + * definition never mutate a session that already bound it; refreshing a bound + * session requires an explicit re-selection. + */ +export function bindSessionProfile(sessionId: string, name: string, modelProfiles: AgentModelConfig): void { + bindingsBySession.set(sessionId, { name, modelProfiles: cloneProfiles(modelProfiles) }); +} + +/** + * Read a session's binding as a private copy. Returns `undefined` for unknown, + * cleared, or undefined session ids: an unbound session keeps resolving the + * shared layers exactly as before. + */ +export function readSessionProfileBinding(sessionId: string | undefined): SessionProfileBinding | undefined { + if (sessionId === undefined) return undefined; + const binding = bindingsBySession.get(sessionId); + return binding === undefined ? undefined : { name: binding.name, modelProfiles: cloneProfiles(binding.modelProfiles) }; +} + +/** Remove one session's binding without touching any other session's. */ +export function clearSessionProfileBinding(sessionId: string): void { + bindingsBySession.delete(sessionId); +} + +/** Testing-only reset of every binding. */ +export function resetSessionProfileBindingsForTesting(): void { + bindingsBySession.clear(); +} + +/** + * The launch-time precedence for wholesale routing replacement: a session + * binding outranks a winning pin, a pin alone still replaces, and neither + * leaves today's routing untouched. Named so the precedence rule a launch and + * the status display agree on lives in exactly one place. + */ +export function sessionOrPinModelProfiles( + session: AgentModelConfig | undefined, + pin: AgentModelConfig | undefined, +): AgentModelConfig | undefined { + return session ?? pin; +} diff --git a/tests/agent-profiles.test.ts b/tests/agent-profiles.test.ts index dea16b7b1..a5e3e31a6 100644 --- a/tests/agent-profiles.test.ts +++ b/tests/agent-profiles.test.ts @@ -767,3 +767,15 @@ test("formatOrchestratorSelection separates set, inherit, and inherit-with-think "nan/glm5.3 · max", ); }); + +test("buildProfileListItems marks the session-bound profile ahead of the pinned one", () => { + const file = createProfile(createProfile(emptyProfilesFile(), "work", { worker: { model: "zai/glm-4.7" } }), "personal", { solo: { model: "openai/o4-mini" } }); + const items = buildProfileListItems(file, "work", "personal"); + assert.equal(items.find((item) => item.id === "personal")?.label, "personal (session)"); + assert.equal(items.find((item) => item.id === "work")?.label, "work (pinned)"); + assert.equal(items.find((item) => item.id === "personal")?.description, "1 role"); + const both = buildProfileListItems(file, "work", "work"); + assert.equal(both.find((item) => item.id === "work")?.label, "work (session)", "the session marker outranks the pin marker on the same profile"); + const unbound = buildProfileListItems(file, "work"); + assert.equal(unbound.find((item) => item.id === "personal")?.label, "personal", "no session marker without a binding"); +}); diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index b2e1a5ef2..19faaba22 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -14,7 +14,8 @@ import type { ToolCallEventResult, } from "@earendil-works/pi-coding-agent"; import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts"; -import { PROFILES_KIND, PROFILES_VERSION } from "../lib/agent-profiles.ts"; +import { PROFILES_KIND, PROFILES_VERSION, readProfilesFileResult } from "../lib/agent-profiles.ts"; +import { readSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts"; import type { AgentRoutingEntry } from "../lib/model-routing-authority.ts"; type LiveSession = Pick; import { PROFILE_PIN_KIND, PROFILE_PIN_VERSION, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts"; @@ -1928,9 +1929,11 @@ function applyOnce( fixture: { onInput(action: (panel: { handleInput(data: string): void }) => void): void }, ): void { let visits = 0; + // gentle-shell#1064: Enter now binds the parent session, so the legacy global + // apply these tests exercise is the explicit `a` key. fixture.onInput((panel) => { visits += 1; - panel.handleInput(visits === 1 ? "\r" : "\x1b"); + panel.handleInput(visits === 1 ? "a" : "\x1b"); }); } @@ -2173,7 +2176,7 @@ test("s snapshots current routing in place without applying or reopening the pro }); assert.deepEqual(store.profiles["z-active"], { worker: { model: "openai/beta" } }); assert.equal(store.active, "z-active"); - assert.match(fixture.panels[0] ?? "", /enter apply · c create · s snapshot/); + assert.match(fixture.panels[0] ?? "", /enter use in this session · a set as global default · c create · s snapshot/); assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models); assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents); assert.equal(readFileSync(workerPath, "utf8"), before.worker); @@ -2950,3 +2953,93 @@ test("switchLiveOrchestrator returns note when setModel fails", async () => { const result = await __testing.switchLiveOrchestrator(ctx, live, entry); assert.equal(result, "\nno authentication is configured for openai; this session keeps its current model."); }); + +test("Enter binds the selected profile to the parent session and writes nothing", async (t) => { + const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t); + writeSettings(); + writeStore({ team: { worker: { model: "openai/alpha" } }, old: {} }, "old"); + const before = { + store: readFileSync(storePath, "utf8"), + settings: readFileSync(settingsPath, "utf8"), + }; + resetSessionProfileBindingsForTesting(); + const notifications: Array<{ message: string; severity: string }> = []; + const ctx = { + cwd: fixture.root, + hasUI: true, + ui: { notify(message: string, severity: string) { notifications.push({ message, severity }); } }, + sessionManager: { getSessionId: () => "session-panel" }, + } as unknown as ExtensionContext; + const live = { setModel: async () => true, setThinkingLevel() {} }; + const file = readProfilesFileResult(storePath).file!; + await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {}); + const binding = readSessionProfileBinding("session-panel"); + assert.equal(binding?.name, "team"); + assert.equal(binding?.modelProfiles.worker?.model, "openai/alpha"); + assert.equal(readFileSync(storePath, "utf8"), before.store, "the profiles store is untouched"); + assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "Pi settings are untouched"); + assert.equal(existsSync(fixture.globalPath), false, "no global models.json is written"); + assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "no materialized store is written"); + assert.match(notifications.at(-1)?.message ?? "", /session/i); + resetSessionProfileBindingsForTesting(); +}); + +test("a keeps the legacy global apply semantics", async (t) => { + const { fixture, storePath, writeStore } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } }, old: {} }, "old"); + const ctx = { + cwd: fixture.root, + hasUI: true, + ui: { notify() {} }, + sessionManager: { getSessionId: () => "session-panel" }, + } as unknown as ExtensionContext; + const live = { setModel: async () => true, setThinkingLevel() {} }; + const file = readProfilesFileResult(storePath).file!; + await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply-global", name: "team" }, {}); + assert.equal(JSON.parse(readFileSync(storePath, "utf8")).active, "team", "the global store claims the profile"); + assert.ok(existsSync(fixture.globalPath), "the global routing is materialized"); + resetSessionProfileBindingsForTesting(); +}); + +test("Enter with a winning pin binds the session and never touches the pin layers", async (t) => { + const { fixture, storePath, writeStore, writePin, localPinPath } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } }, old: {} }, undefined); + writePin(localPinPath, "old"); + const pinBefore = readFileSync(localPinPath, "utf8"); + const storeBefore = readFileSync(storePath, "utf8"); + resetSessionProfileBindingsForTesting(); + const ctx = { + cwd: fixture.root, + hasUI: true, + ui: { notify() {} }, + sessionManager: { getSessionId: () => "session-panel" }, + } as unknown as ExtensionContext; + const live = { setModel: async () => true, setThinkingLevel() {} }; + const file = readProfilesFileResult(storePath).file!; + await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {}); + assert.equal(readSessionProfileBinding("session-panel")?.name, "team"); + assert.equal(readFileSync(localPinPath, "utf8"), pinBefore, "the clone pin is untouched"); + assert.equal(readFileSync(storePath, "utf8"), storeBefore, "the store is untouched"); + assert.equal(existsSync(fixture.globalPath), false); + resetSessionProfileBindingsForTesting(); +}); + +test("Enter without a parent session id fails loud and writes nothing", async (t) => { + const { fixture, storePath, writeStore } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } } }, undefined); + const before = readFileSync(storePath, "utf8"); + const notifications: Array<{ message: string; severity: string }> = []; + const ctx = { + cwd: fixture.root, + hasUI: true, + ui: { notify(message: string, severity: string) { notifications.push({ message, severity }); } }, + } as unknown as ExtensionContext; + const live = { setModel: async () => true, setThinkingLevel() {} }; + const file = readProfilesFileResult(storePath).file!; + await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {}); + assert.equal(readSessionProfileBinding(undefined), undefined); + assert.equal(readFileSync(storePath, "utf8"), before); + assert.equal(existsSync(fixture.globalPath), false); + assert.equal(notifications.at(-1)?.severity, "warning"); + resetSessionProfileBindingsForTesting(); +}); diff --git a/tests/session-profile-binding.test.ts b/tests/session-profile-binding.test.ts new file mode 100644 index 000000000..d00ce43f5 --- /dev/null +++ b/tests/session-profile-binding.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { AgentModelConfig } from "../lib/model-routing-authority.ts"; +import { + bindSessionProfile, + clearSessionProfileBinding, + readSessionProfileBinding, + resetSessionProfileBindingsForTesting, + sessionOrPinModelProfiles, +} from "../lib/session-profile-binding.ts"; + +const ROUTING: AgentModelConfig = { + worker: { model: "zai/glm-4.7", thinking: "medium" }, + reviewer: { model: "openai/o4-mini" }, +}; + +test("bind and read roundtrip the snapshot by session id", () => { + resetSessionProfileBindingsForTesting(); + bindSessionProfile("session-a", "work", ROUTING); + const binding = readSessionProfileBinding("session-a"); + assert.equal(binding?.name, "work"); + assert.equal(binding?.modelProfiles.worker?.model, "zai/glm-4.7"); + assert.equal(binding?.modelProfiles.worker?.thinking, "medium"); +}); + +test("bindings are isolated per session id", () => { + resetSessionProfileBindingsForTesting(); + bindSessionProfile("session-a", "work", ROUTING); + bindSessionProfile("session-b", "personal", { solo: { model: "openai/o4-mini" } }); + assert.equal(readSessionProfileBinding("session-a")?.name, "work"); + assert.equal(readSessionProfileBinding("session-b")?.name, "personal"); + assert.equal(readSessionProfileBinding("session-c"), undefined); +}); + +test("rebinding one session replaces its binding without touching others", () => { + resetSessionProfileBindingsForTesting(); + bindSessionProfile("session-a", "work", ROUTING); + bindSessionProfile("session-b", "personal", ROUTING); + bindSessionProfile("session-a", "review", { auditor: { model: "zai/glm-4.7" } }); + assert.equal(readSessionProfileBinding("session-a")?.name, "review"); + assert.equal(readSessionProfileBinding("session-b")?.name, "personal"); +}); + +test("stored snapshots are immune to later mutation of the source object", () => { + resetSessionProfileBindingsForTesting(); + const source: AgentModelConfig = { worker: { model: "zai/glm-4.7" } }; + bindSessionProfile("session-a", "work", source); + source.worker!.model = "openai/o4-mini"; + assert.equal(readSessionProfileBinding("session-a")?.modelProfiles.worker?.model, "zai/glm-4.7"); +}); + +test("read snapshots are copies: callers cannot corrupt the store", () => { + resetSessionProfileBindingsForTesting(); + bindSessionProfile("session-a", "work", ROUTING); + const first = readSessionProfileBinding("session-a"); + first!.modelProfiles.worker!.model = "mutated/evil"; + const second = readSessionProfileBinding("session-a"); + assert.equal(second?.modelProfiles.worker?.model, "zai/glm-4.7"); +}); + +test("readSessionProfileBinding(undefined) never resolves a binding", () => { + resetSessionProfileBindingsForTesting(); + bindSessionProfile("session-a", "work", ROUTING); + assert.equal(readSessionProfileBinding(undefined), undefined); +}); + +test("clearSessionProfileBinding removes only the named session", () => { + resetSessionProfileBindingsForTesting(); + bindSessionProfile("session-a", "work", ROUTING); + bindSessionProfile("session-b", "personal", ROUTING); + clearSessionProfileBinding("session-a"); + assert.equal(readSessionProfileBinding("session-a"), undefined); + assert.equal(readSessionProfileBinding("session-b")?.name, "personal"); +}); + +test("sessionOrPinModelProfiles keeps the wholesale-replacement contract: session wins, pin alone, else undefined", () => { + resetSessionProfileBindingsForTesting(); + const session: AgentModelConfig = { worker: { model: "zai/glm-4.7" } }; + const pin: AgentModelConfig = { worker: { model: "openai/o4-mini" } }; + assert.equal(sessionOrPinModelProfiles(session, pin), session); + assert.equal(sessionOrPinModelProfiles(undefined, pin), pin); + assert.equal(sessionOrPinModelProfiles(session, undefined), session); + assert.equal(sessionOrPinModelProfiles(undefined, undefined), undefined); +}); + +test("session bindings from one process never leak into a fresh map state", () => { + resetSessionProfileBindingsForTesting(); + bindSessionProfile("session-a", "work", ROUTING); + resetSessionProfileBindingsForTesting(); + assert.equal(readSessionProfileBinding("session-a"), undefined); +}); + From 6da3a5c6056da71debee57adf35b648276c2aabf Mon Sep 17 00:00:00 2001 From: danielgap Date: Tue, 29 Sep 2026 19:36:07 +0200 Subject: [PATCH 2/5] fix(profiles): narrow the profiles store read in the panel tests readProfilesFileResult returns a status union, so the new panel tests access .file through a helper that narrows to the valid variant instead of a non-null assertion the type ratchet rejects. --- tests/gentle-ai.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index 19faaba22..bb59cd37d 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -2954,6 +2954,12 @@ test("switchLiveOrchestrator returns note when setModel fails", async () => { assert.equal(result, "\nno authentication is configured for openai; this session keeps its current model."); }); +function readValidProfilesStore(path: string) { + const result = readProfilesFileResult(path); + if (result.status !== "valid") throw new Error(`profiles store at ${path} is ${result.status}, expected valid`); + return result.file; +} + test("Enter binds the selected profile to the parent session and writes nothing", async (t) => { const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t); writeSettings(); @@ -2971,7 +2977,7 @@ test("Enter binds the selected profile to the parent session and writes nothing" sessionManager: { getSessionId: () => "session-panel" }, } as unknown as ExtensionContext; const live = { setModel: async () => true, setThinkingLevel() {} }; - const file = readProfilesFileResult(storePath).file!; + const file = readValidProfilesStore(storePath); await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {}); const binding = readSessionProfileBinding("session-panel"); assert.equal(binding?.name, "team"); @@ -2994,7 +3000,7 @@ test("a keeps the legacy global apply semantics", async (t) => { sessionManager: { getSessionId: () => "session-panel" }, } as unknown as ExtensionContext; const live = { setModel: async () => true, setThinkingLevel() {} }; - const file = readProfilesFileResult(storePath).file!; + const file = readValidProfilesStore(storePath); await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply-global", name: "team" }, {}); assert.equal(JSON.parse(readFileSync(storePath, "utf8")).active, "team", "the global store claims the profile"); assert.ok(existsSync(fixture.globalPath), "the global routing is materialized"); @@ -3015,7 +3021,7 @@ test("Enter with a winning pin binds the session and never touches the pin layer sessionManager: { getSessionId: () => "session-panel" }, } as unknown as ExtensionContext; const live = { setModel: async () => true, setThinkingLevel() {} }; - const file = readProfilesFileResult(storePath).file!; + const file = readValidProfilesStore(storePath); await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {}); assert.equal(readSessionProfileBinding("session-panel")?.name, "team"); assert.equal(readFileSync(localPinPath, "utf8"), pinBefore, "the clone pin is untouched"); @@ -3035,7 +3041,7 @@ test("Enter without a parent session id fails loud and writes nothing", async (t ui: { notify(message: string, severity: string) { notifications.push({ message, severity }); } }, } as unknown as ExtensionContext; const live = { setModel: async () => true, setThinkingLevel() {} }; - const file = readProfilesFileResult(storePath).file!; + const file = readValidProfilesStore(storePath); await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {}); assert.equal(readSessionProfileBinding(undefined), undefined); assert.equal(readFileSync(storePath, "utf8"), before); From f1acb9ec4fc3e4143970465c315c2b47d5d6cdf4 Mon Sep 17 00:00:00 2001 From: danielgap Date: Tue, 29 Sep 2026 21:40:03 +0200 Subject: [PATCH 3/5] docs(profiles): cover the touched functions the docstring gate flags Six docstrings on the functions slice 1 touches without one: the snapshot copier, the list-item builder, the three panel entry points, and the test store reader. --- extensions/gentle-ai.ts | 3 +++ lib/agent-profiles.ts | 1 + lib/session-profile-binding.ts | 1 + tests/gentle-ai.test.ts | 1 + 4 files changed, 6 insertions(+) diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 46d4c5a07..afc42ec83 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -3993,6 +3993,7 @@ class ProfilesPanel implements OverlayComponent { } } +/** The full-screen profile picker: one visit per action, so every reopen reads fresh store, pin, orchestrator, and session-binding state. */ async function showProfilesPanel( ctx: ExtensionContext, file: AgentProfilesFile, @@ -4117,6 +4118,7 @@ function profileSnapshotFrom( return snapshot; } +/** Runs one finished panel action against the store and the live session, returning the file the reopened panel should show. */ async function runProfilesPanelAction( ctx: ExtensionContext, live: LiveSession, @@ -4568,6 +4570,7 @@ async function runProfilesPanelAction( } } +/** `/gentle:profiles`: seed or open the store, then loop the panel over one action at a time until it closes. */ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): Promise { const path = profilesFilePath(gentleAiConfigHome()); const read = readProfilesFileResult(path); diff --git a/lib/agent-profiles.ts b/lib/agent-profiles.ts index 170790bc6..a517a88a5 100644 --- a/lib/agent-profiles.ts +++ b/lib/agent-profiles.ts @@ -406,6 +406,7 @@ export interface ProfileListItem { description: string; } +/** One list item per saved profile, named with the markers that explain what a launch would use: the global `(active)` claim, plus `(pinned)` for the pin winner or `(session)` for this session's binding when either applies. */ export function buildProfileListItems( file: AgentProfilesFile, pinned?: string, diff --git a/lib/session-profile-binding.ts b/lib/session-profile-binding.ts index 4efebbd01..f36de2f98 100644 --- a/lib/session-profile-binding.ts +++ b/lib/session-profile-binding.ts @@ -18,6 +18,7 @@ export interface SessionProfileBinding { const bindingsBySession = new Map(); +/** Copy one routing snapshot level deep — the layer a defensive copy needs, so a binding never aliases its source or its readers. */ function cloneProfiles(profiles: AgentModelConfig): AgentModelConfig { return Object.fromEntries(Object.entries(profiles).map(([agent, entry]) => [agent, { ...entry }])); } diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index bb59cd37d..fb780f981 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -2954,6 +2954,7 @@ test("switchLiveOrchestrator returns note when setModel fails", async () => { assert.equal(result, "\nno authentication is configured for openai; this session keeps its current model."); }); +/** Reads the fixture's profiles store or fails the test: every panel-action fixture writes a valid store before acting. */ function readValidProfilesStore(path: string) { const result = readProfilesFileResult(path); if (result.status !== "valid") throw new Error(`profiles store at ${path} is ${result.status}, expected valid`); From 97a372641a09550b7e562ba7c969879d2e11e349 Mon Sep 17 00:00:00 2001 From: danielgap Date: Sat, 3 Oct 2026 12:23:03 +0200 Subject: [PATCH 4/5] test(profiles): confirm the global apply dialog in the legacy panel test Main's #1349 apply-dialog hardening made runProfilesPanelAction call ctx.ui.confirm before every global apply, so the slice-1 legacy-semantics test stub now needs an approving confirm. --- tests/gentle-ai.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index 19394c268..0d0a25c96 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -3494,7 +3494,7 @@ test("a keeps the legacy global apply semantics", async (t) => { const ctx = { cwd: fixture.root, hasUI: true, - ui: { notify() {} }, + ui: { notify() {}, confirm: async () => true }, sessionManager: { getSessionId: () => "session-panel" }, } as unknown as ExtensionContext; const live = { setModel: async () => true, setThinkingLevel() {} }; From f23f57825cde2cbfc9c19259a849a1c3f06d57d3 Mon Sep 17 00:00:00 2001 From: danielgap Date: Sat, 3 Oct 2026 19:51:32 +0200 Subject: [PATCH 5/5] fix(profiles): keep the slice-1 session-binding copy truthful about launches (#1557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit finding on #1557: the Enter-bind notice and the panel detail line claimed subagent launches resolve the session binding, but slice 1 only stores it — launch resolution ships with slice 2 (#1558). Both messages now state the binding is stored, that launch routing is unchanged, and keep the nothing-was-written sentence (global routing, pins, materialized stores untouched). The adjacent comments in gentle-ai.ts and the label comment in agent-profiles.ts repeat the same claim and are aligned. The Enter-bind test now pins this contract: it asserts the stored/unchanged wording and fails on any launch-resolution claim. --- extensions/gentle-ai.ts | 18 ++++++++++-------- lib/agent-profiles.ts | 5 +++-- tests/gentle-ai.test.ts | 9 ++++++++- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index f53166e72..716346c56 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -4006,12 +4006,14 @@ class ProfilesPanel implements OverlayComponent { // any invalid or stale layer, and the scope sentence all come from the shared // precedence rule the launch resolver uses. ...profilePinDetailLines(this.pinStatus, this.file.profiles).map((line) => this.renderLine(line, width, "muted")), - // gentle-shell#1064 slice 1: a session binding outranks the pin for this - // session's launches, so it is named right after the pin layers it - // supersedes — the same "name (session)" spelling the shell bar shows. + // gentle-shell#1064 slice 1: the binding is stored for this session and + // outranks the pin in the panel list, so it is named right after the pin + // layers. Launch resolution ships with slice 2 (gentle-shell#1558); this + // slice stores the binding only, launch routing is unchanged, and nothing + // was written. ...(this.sessionBoundName === undefined ? [] - : [this.renderLine(`session ${sanitizeTerminalText(this.sessionBoundName)} (session) — this session's launches resolve it; nothing was written`, width, "muted")]), + : [this.renderLine(`session ${sanitizeTerminalText(this.sessionBoundName)} (session) — stored for this session; launch routing is unchanged; nothing was written`, width, "muted")]), "", this.renderLine("Profile routing", width, "accent"), ...this.indentLines(this.routingLines(profileRows, widths), width), @@ -4209,9 +4211,9 @@ async function runProfilesPanelAction( // parent session. The binding is in-process state keyed by the session // id: it writes no store marker, no global routing, no materialized // stores, no agent frontmatter, no Pi settings, and no pin or declaration - // layer, pin or not. Launches from this session resolve it ahead of - // p/P/global; queued and running children keep the routing their task - // requests already carry. Refreshing the binding means selecting again. + // layer, pin or not. This slice stores the binding only: launch routing + // is unchanged until slice 2 (gentle-shell#1558) resolves the binding + // at launch. Refreshing the binding means selecting again. const sessionId = ctx.sessionManager?.getSessionId?.(); if (typeof sessionId !== "string" || sessionId.length === 0) { ctx.ui.notify( @@ -4222,7 +4224,7 @@ async function runProfilesPanelAction( } bindSessionProfile(sessionId, result.name, normalizeModelConfig(file.profiles[result.name]) ?? {}); ctx.ui.notify( - `el Gentleman bound profile "${result.name}" to this session — shown as "${result.name} (session)". Subagent launches from this session resolve it; queued and running children keep their routing. Nothing was written: the global routing, pins, and materialized stores are untouched. Set as global default with a.`, + `el Gentleman bound profile "${result.name}" to this session — shown as "${result.name} (session)". The binding is stored for this session; launch routing is unchanged. Nothing was written: the global routing, pins, and materialized stores are untouched. Set as global default with a.`, "info", ); return file; diff --git a/lib/agent-profiles.ts b/lib/agent-profiles.ts index a517a88a5..92c0baeef 100644 --- a/lib/agent-profiles.ts +++ b/lib/agent-profiles.ts @@ -417,8 +417,9 @@ export function buildProfileListItems( const active = name === file.active ? `${name} (active)` : name; // A pinned profile is the one this repository launches with, which is not the // same thing as the globally active profile, so both are named. A session - // binding outranks the pin (it is what this session's launches resolve now), - // so its marker wins on the same profile. + // binding outranks the pin in this list (slice 1 stores it; slice 2, + // gentle-shell#1558, makes launches resolve it), so its marker wins on the + // same profile. const label = name === sessionBound ? `${active} (session)` : name === pinned diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index 0d0a25c96..21f58ba66 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -3484,7 +3484,14 @@ test("Enter binds the selected profile to the parent session and writes nothing" assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "Pi settings are untouched"); assert.equal(existsSync(fixture.globalPath), false, "no global models.json is written"); assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "no materialized store is written"); - assert.match(notifications.at(-1)?.message ?? "", /session/i); + // gentle-shell#1557: slice 1 stores the binding; the notice must not claim + // launch resolution (that is slice 2, gentle-shell#1558) and must keep the + // nothing-was-written sentence. + const applied = notifications.at(-1)?.message ?? ""; + assert.match(applied, /bound profile "team" to this session/); + assert.match(applied, /stored for this session; launch routing is unchanged/, "the notice states the binding is stored without claiming launch resolution"); + assert.match(applied, /global routing, pins, and materialized stores are untouched/); + assert.doesNotMatch(applied, /launch(?:es)?[^.]*resolve/i, "slice 1 must not claim launches resolve the binding"); resetSessionProfileBindingsForTesting(); });