diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 028ce2242..716346c56 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -100,6 +100,7 @@ import { type ProfilePinSource, type ProfilePinStatus, } from "../lib/agent-profile-pin.ts"; +import { bindSessionProfile, readSessionProfileBinding } from "../lib/session-profile-binding.ts"; import { applyOrchestratorSettings, readOrchestratorSettings, @@ -3564,6 +3565,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 } @@ -3751,6 +3753,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, @@ -3764,6 +3767,7 @@ class ProfilesPanel implements OverlayComponent { saveSnapshot: ProfilesSnapshotHandler, requestRender: () => void, pinStatus: () => ProfilePinStatus | undefined, + sessionBound: () => string | undefined, feedback?: string, ) { this.file = file; @@ -3776,7 +3780,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, @@ -3839,6 +3844,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 }); @@ -3964,7 +3970,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"), @@ -4000,6 +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: 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) — 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), @@ -4056,6 +4070,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, @@ -4063,6 +4078,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. @@ -4081,6 +4097,7 @@ async function showProfilesPanel( saveSnapshot, () => tui.requestRender(), () => readProfilePinStatus(ctx.cwd), + () => sessionBoundName, status, ); const container = createNativeFullscreenInteraction({ @@ -4178,6 +4195,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, @@ -4188,6 +4206,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. 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( + `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)". 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; + } + 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 @@ -4688,6 +4730,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); @@ -4729,12 +4772,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 = {}; @@ -4747,6 +4793,7 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): selectedName, saveSnapshot, report.status, + sessionBoundName(), ); } } @@ -6309,6 +6356,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 @@ -9149,6 +9201,7 @@ export const __testing = { parseReviewControllerParameters, parseReviewCaptureParameters, parseReviewCaptureGroupParameters, + runProfilesPanelAction, resolveReviewModeGate, readEffectiveModelConfig, readEffectiveModelConfigAsync, diff --git a/lib/agent-profiles.ts b/lib/agent-profiles.ts index f4c953460..92c0baeef 100644 --- a/lib/agent-profiles.ts +++ b/lib/agent-profiles.ts @@ -406,18 +406,28 @@ 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, + 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 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 + ? `${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..f36de2f98 --- /dev/null +++ b/lib/session-profile-binding.ts @@ -0,0 +1,68 @@ +// 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(); + +/** 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 }])); +} + +/** + * 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 b0866860d..21f58ba66 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -15,7 +15,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"; @@ -1985,9 +1986,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"); }); } @@ -2669,7 +2672,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); @@ -3447,3 +3450,107 @@ 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."); }); + +/** 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`); + 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(); + 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 = readValidProfilesStore(storePath); + 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"); + // 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(); +}); + +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() {}, confirm: async () => true }, + sessionManager: { getSessionId: () => "session-panel" }, + } as unknown as ExtensionContext; + const live = { setModel: async () => true, setThinkingLevel() {} }; + 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"); + 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 = 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"); + 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 = readValidProfilesStore(storePath); + 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); +}); +