diff --git a/docs/readme-reference.md b/docs/readme-reference.md index c5b00cfd1..c59cd70d4 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -1098,7 +1098,16 @@ The file operations are subcommands: | `/gentle:profiles import` | Import the profile in `~/.pi/gentle-ai/profiles.export.json`, asking before it replaces one with the same name. | | `/gentle:profiles publish ` | Publish the shared repository declaration at `/.pi/gentle-ai/profile.json` so the whole team starts from that profile; run it again to remove the declaration. | -The binding made by **Use in this session** writes no shared routing: not `models.json`, Pi's global `settings.json`, agent frontmatter, `subagents.json`, or repository pins. When the table has unsaved edits, they are saved to the profile in `profiles.json` first, exactly as **Save** would, and the screen says so; with no pending edits nothing is written at all. Other open sessions keep their own bindings and live orchestrators. The session binding overrides pins and global routing as a complete snapshot, including reviewer routing; omitted roles do not fall back to another profile. The binding lasts in the current process (including `/reload`); resuming in another process requires selecting it again. Re-selecting re-copies the profile definition from `profiles.json`; later edits to `subagents.json` or agent frontmatter never reach a bound session, in that session or through a re-selection. +The binding made by **Use in this session** writes no shared routing: not `models.json`, Pi's global `settings.json`, agent frontmatter, `subagents.json`, or repository pins. When the table has unsaved edits, they are saved to the profile in `profiles.json` first, exactly as **Save** would, and the screen says so; with no pending edits nothing is written at all. Other open sessions keep their own bindings and live orchestrators. The session binding overrides pins and global routing as a complete snapshot, including reviewer routing; omitted roles do not fall back to another profile. The selection is appended to the session file as its own record, so start, resume, reload, fork and tree navigation restore it from the current branch without selecting it again. Re-selecting re-copies the profile definition from `profiles.json`; later edits to `subagents.json` or agent frontmatter never reach a bound session, in that session or through a re-selection. + +| Outcome | What happens | +| --- | --- | +| Persisted | The selected record is corroborated on disk before routing is published. | +| Not yet persisted | Use in this session explicitly reports a preflush selection. It is usable now, but detach/reload does not restore it without later disk corroboration. | +| Append failure | No success or live-model effect. Keep only returned disk-corroborated state; otherwise block affected launches. | +| Invalid, future or unavailable authority | Do not fall through to shared routing; affected subagent launches are blocked. An explicit successful selection or fresh corroboration can restore availability. | + +Start, resume, reload, fork and tree navigation restore only the current corroborated branch state. Restoration never applies the profile's orchestrator. Fork capture is read-only until a confirmed transition; cancellation/rejection preserves current authority. Pending Use in this session live operations settle before fork-copy, tree movement or detach. Outside a pinned repository, **Make default** writes `~/.pi/gentle-ai/models.json`, then reconciles agent frontmatter and `subagents.json` with the shared materialization rules. When a valid pin wins, it only re-pins this clone to the profile and writes none of `models.json`, materialized routing, `settings.json`, or the active marker (see **Per-repository pins**). A profile is a complete snapshot: every discoverable agent it omits returns to inherit, so routing materialized by a previous profile, by an earlier apply, or by a migration never survives a switch silently. The active profile is persisted so `/gentle:profiles` reopens with the applied profile marked. @@ -1218,7 +1227,7 @@ The notice appears once per distinct change: further launches stay quiet until t **Task card layer token.** Every agents task card shows which layer supplied the launch's model, right after the model itself (for example `nan/glm5.3 · binding`): `binding` (selected in this session), `frozen:local` / `frozen:repo` / `frozen:global` (the startup snapshot and its origin), `pin:local` / `pin:repo` (a repository pin won), `declaration` (a non-Git repository declaration won), `profile` (the globally active profile routing), `definition` (the agent's own frontmatter), or `default` (Pi's default model). This makes staleness visible: a launch through a frozen session keeps showing its frozen token even after the editable layers below it change on disk. -**Current limits.** The frozen profile is kept in memory for the life of the Pi process, so `/reload` keeps it. Resuming a session in a new process (`--resume`, `--continue`, `--session`), `/new`, and `/fork` freeze again from the defaults current at that moment, and the drift notice memory starts empty, so a pending change is announced once more. Persisting the frozen profile in the session file is planned. +**Current limits.** The frozen profile is kept in memory for the life of the Pi process, so `/reload` keeps it. This limit applies only to the startup-frozen profile: resuming a session in a new process (`--resume`, `--continue`, `--session`), `/new`, and `/fork` freeze it again from the defaults current at that moment, and the drift notice memory starts empty, so a pending change is announced once more. A profile chosen with **Use in this session** is not affected: it is restored from the current branch of the session file. Persisting the frozen profile in the session file is planned. ## Commands diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 712b4be3f..94753fbc3 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -43,6 +43,7 @@ import { fileURLToPath } from "node:url"; import type { ExtensionAPI, ExtensionContext, + SessionShutdownEvent, Theme, ThemeColor, ToolCallEventResult, @@ -114,7 +115,9 @@ import { type ProfilePinSource, type ProfilePinStatus, } from "../lib/agent-profile-pin.ts"; -import { bindSessionProfile, readSessionProfileBinding } from "../lib/session-profile-binding.ts"; +import { clearSessionProfileBinding, readSessionProfileBinding } from "../lib/session-profile-binding.ts"; +import { createSessionProfileIntegration, hasPendingSessionProfileSelection, readCurrentSessionProfileOutcome, runCurrentSessionProfileSelection, waitCurrentSessionProfileSelection } from "../lib/session-profile-integration.ts"; +import { publishSessionProfileOutcome, readSessionProfileAuthority } from "../lib/session-profile-authority.ts"; import { applyOrchestratorSettings, readOrchestratorSettings, @@ -4931,9 +4934,10 @@ async function runProfilesPanelAction( switch (result.type) { case "use-session": { if (!hasOwnProfile(file.profiles, result.name)) return file; - // Use in this session binds a complete routing snapshot to this parent - // session and switches only its live orchestrator. No shared defaults, - // materialized stores, agent frontmatter, pins, or declarations are written. + // Enter persists a complete routing snapshot for this parent session, + // publishes only the returned state, then switches its live orchestrator. + // No shared defaults, materialized stores, agent frontmatter, pins, or + // declarations are written. const sessionId = ctx.sessionManager?.getSessionId?.(); if (typeof sessionId !== "string" || sessionId.length === 0) { report.status = `This Pi session has no id to bind "${result.name}" to. Use Make default instead.`; @@ -4943,15 +4947,51 @@ async function runProfilesPanelAction( ); return file; } - const snapshot = normalizeModelConfig(result.config ?? file.profiles[result.name]) ?? {}; - bindSessionProfile(sessionId, result.name, snapshot); - const orchestrator = readProfileOrchestrator(snapshot); - const liveNote = orchestrator === undefined ? "" : await switchLiveOrchestrator(ctx, live, orchestrator); - report.status = `This session now uses "${result.name}". Nothing shared was written.${liveNote.replace(/\n/g, " ")}`; - ctx.ui.notify( - `el Gentleman bound profile "${result.name}" to this session — its tab shows ◉. Subagents and reviewers use this session's routing snapshot. Shared defaults were not written: the global routing, pins, and materialized stores are untouched. Make default writes them.${liveNote}`, - "info", - ); + const selected = await runCurrentSessionProfileSelection(ctx.sessionManager, async (attachment, owned) => { + const outcome = attachment.bind(result.name, normalizeModelConfig(result.config ?? file.profiles[result.name]) ?? {}); + if (!owned()) return false; + const authority = publishSessionProfileOutcome(ctx.sessionManager, outcome); + if ((outcome.status !== "persisted" && outcome.status !== "not-persisted") || outcome.state.status !== "bound" || !authority.available) { + const kept = authority.available ? "The last disk-corroborated state was preserved." : "Session profile authority is unavailable; affected launches are blocked."; + report.status = `"${result.name}" was not bound to this session. ${kept}`; + ctx.ui.notify( + `el Gentleman could not bind profile "${result.name}". ${kept} No live model or thinking change was applied.`, + "warning", + ); + return false; + } + const snapshot = outcome.state.binding; + // The live switch applies only while this exact snapshot is still current. + const current = () => { + if (!owned()) return false; + const state = readCurrentSessionProfileOutcome(ctx.sessionManager)?.state; + return state?.status === "bound" && JSON.stringify(state.binding) === JSON.stringify(snapshot); + }; + const orchestrator = readProfileOrchestrator(snapshot.modelProfiles); + const liveNote = orchestrator === undefined ? "" : await switchLiveOrchestrator(ctx, { + setModel: (model) => current() ? live.setModel(model) : Promise.resolve(false), + setThinkingLevel: (level) => { if (current()) live.setThinkingLevel(level); }, + getThinkingLevel: () => live.getThinkingLevel(), + }, orchestrator); + if (current()) { + const persistence = outcome.status === "persisted" ? "persisted in this session" : "not yet persisted; active only until detach or disk corroboration"; + report.status = `This session now uses "${snapshot.name}" (${persistence}). Nothing shared was written.${liveNote.replace(/\n/g, " ")}`; + ctx.ui.notify( + `el Gentleman bound profile "${snapshot.name}" to this session — its tab shows ◉; ${persistence}. Subagents and reviewers use this session's routing snapshot. Shared defaults were not written: the global routing, pins, and materialized stores are untouched. Make default writes them.${liveNote}`, + "info", + ); + } + return true; + }); + // A selection skipped by a reload or session replacement leaves a stale + // ctx; only a still-active command reports the missing attachment. + if (selected === undefined && isCommandContextActive(ctx)) { + report.status = `"${result.name}" was not bound: this session's attachment is unavailable.`; + ctx.ui.notify( + `el Gentleman cannot bind profile "${result.name}": the current session attachment is unavailable. No profile or live model change was applied.`, + "warning", + ); + } return file; } case "apply-global": { @@ -5613,145 +5653,180 @@ async function openProfilesPanelLoop( }; const differsFromStore = (name: string, config: AgentModelConfig) => JSON.stringify(normalizeModelConfig(config) ?? {}) !== JSON.stringify(normalizeModelConfig(file.profiles[name]) ?? {}); - while (true) { - if (selectedName === undefined || !hasOwnProfile(file.profiles, selectedName)) { - selectedName = Object.keys(file.profiles)[0]; - draftOverride = undefined; - } - if (selectedName === undefined) { - ctx.ui.notify("el Gentleman has no profiles left. Run /gentle:profiles again to seed one from the routing in effect.", "info"); - return; - } - const stored = cloneModelConfig(normalizeModelConfig(file.profiles[selectedName]) ?? {}); - const boundName = sessionBoundName(); - const result = await showRoutingTablePanel(ctx, { - name: selectedName, - config: draftOverride ?? stored, - file, - sessionBoundName: boundName, - pinWinner: pinWinner(), - feedback, - notices: routingScreenNotices(ctx, file, selectedName, boundName), - switchProfile, - }); - draftOverride = undefined; - feedback = undefined; - const previous = selectedName; - if (result.saveFirst && !writeDraft(previous, result.saveFirst)) { - draftOverride = result.saveFirst; - continue; + // Pi's runner invalidates this command ctx in a microtask when a reload or + // session replacement lands, possibly while a panel or input step is still in + // flight. Every step therefore re-checks the ctx when it settles (or throws), + // and a stale ctx closes the loop quietly instead of touching ctx.cwd again. + const stale = Symbol("stale-profiles-ctx"); + const step = async (run: () => Promise): Promise => { + let value: T; + try { + value = await run(); + } catch (error) { + if (!isCommandContextActive(ctx)) throw stale; + throw error; } - const savedNote = result.saveFirst ? `Saved "${previous}". ` : ""; - switch (result.type) { - case "close": + if (!isCommandContextActive(ctx)) throw stale; + return value; + }; + try { + await runProfilesPanelSteps(); + } catch (error) { + if (error !== stale) throw error; + } + async function runProfilesPanelSteps(): Promise { + while (true) { + if (!isCommandContextActive(ctx)) return; + if (selectedName === undefined || !hasOwnProfile(file.profiles, selectedName)) { + selectedName = Object.keys(file.profiles)[0]; + draftOverride = undefined; + } + if (selectedName === undefined) { + ctx.ui.notify("el Gentleman has no profiles left. Run /gentle:profiles again to seed one from the routing in effect.", "info"); return; - case "load": - selectedName = result.name; - feedback = `${savedNote}Showing "${result.name}".`; + } + const shown = selectedName; + const stored = cloneModelConfig(normalizeModelConfig(file.profiles[shown]) ?? {}); + const boundName = sessionBoundName(); + const result = await step(() => showRoutingTablePanel(ctx, { + name: shown, + config: draftOverride ?? stored, + file, + sessionBoundName: boundName, + pinWinner: pinWinner(), + feedback, + notices: routingScreenNotices(ctx, file, shown, boundName), + switchProfile, + })); + draftOverride = undefined; + feedback = undefined; + const previous = selectedName; + if (result.saveFirst && !writeDraft(previous, result.saveFirst)) { + draftOverride = result.saveFirst; continue; - case "custom": { - const label = result.target === PROFILE_ORCHESTRATOR_KEY - ? "the orchestrator" - : result.target === ROUTING_ALL_ROW ? "every agent" : sanitizeTerminalText(result.target); - const answer = await ctx.ui.input(`Custom model id for ${label}`, "provider/model"); - draftOverride = cloneModelConfig(result.config); - if (answer === undefined || answer.trim() === "") { - feedback = "No custom model set."; + } + const savedNote = result.saveFirst ? `Saved "${previous}". ` : ""; + switch (result.type) { + case "close": + return; + case "load": + selectedName = result.name; + feedback = `${savedNote}Showing "${result.name}".`; continue; - } - const model = normalizeModelId(answer.trim()); - if (!model) { - feedback = "Custom model id must be one provider/model line using letters, numbers, '.', '-', '_', '~', ':', '@', '/', '+', '%' only."; + case "custom": { + const label = result.target === PROFILE_ORCHESTRATOR_KEY + ? "the orchestrator" + : result.target === ROUTING_ALL_ROW ? "every agent" : sanitizeTerminalText(result.target); + const answer = await step(() => ctx.ui.input(`Custom model id for ${label}`, "provider/model")); + draftOverride = cloneModelConfig(result.config); + if (answer === undefined || answer.trim() === "") { + feedback = "No custom model set."; + continue; + } + const model = normalizeModelId(answer.trim()); + if (!model) { + feedback = "Custom model id must be one provider/model line using letters, numbers, '.', '-', '_', '~', ':', '@', '/', '+', '%' only."; + continue; + } + const keys = result.target === ROUTING_ALL_ROW ? modelAssignmentNames(ctx.cwd) : [result.target]; + for (const key of keys) draftOverride[key] = { ...(draftOverride[key] ?? {}), model }; + feedback = `${label}: model ${model}. Save keeps it in "${previous}".`; continue; } - const keys = result.target === ROUTING_ALL_ROW ? modelAssignmentNames(ctx.cwd) : [result.target]; - for (const key of keys) draftOverride[key] = { ...(draftOverride[key] ?? {}), model }; - feedback = `${label}: model ${model}. Save keeps it in "${previous}".`; - continue; - } - case "save": - if (writeDraft(previous, result.config)) feedback = `Saved "${previous}".`; - else draftOverride = result.config; - continue; - case "use-session": { - // What you see is what the session uses: edits are saved first. - // A tab switch may have changed the shown profile since this visit opened. - const config = result.config ?? cloneModelConfig(normalizeModelConfig(file.profiles[previous]) ?? {}); - const saved = differsFromStore(previous, config); - if (saved && !writeDraft(previous, config)) { - draftOverride = config; + case "save": + if (writeDraft(previous, result.config)) feedback = `Saved "${previous}".`; + else draftOverride = result.config; continue; - } - const report: ProfilesPanelReport = {}; - file = await runProfilesPanelAction(ctx, live, path, file, { type: "use-session", name: previous, config }, report); - feedback = `${saved ? `Saved "${previous}". ` : ""}${report.status ?? ""}`.trim() || undefined; - continue; - } - case "apply-global": { - // Make default saves what you see, then runs the guarded global - // apply with its diff confirmation, pin honesty, and rollback. - const saved = differsFromStore(previous, result.config); - if (saved && !writeDraft(previous, result.config)) { - draftOverride = result.config; + case "use-session": { + // What you see is what the session uses: edits are saved first. + // A tab switch may have changed the shown profile since this visit opened. + const config = result.config ?? cloneModelConfig(normalizeModelConfig(file.profiles[previous]) ?? {}); + const saved = differsFromStore(previous, config); + if (saved && !writeDraft(previous, config)) { + draftOverride = config; + continue; + } + const report: ProfilesPanelReport = {}; + file = await step(() => runProfilesPanelAction(ctx, live, path, file, { type: "use-session", name: previous, config }, report)); + feedback = `${saved ? `Saved "${previous}". ` : ""}${report.status ?? ""}`.trim() || undefined; continue; } - file = await runProfilesPanelAction(ctx, live, path, file, { type: "apply-global", name: previous, config: cloneModelConfig(result.config) }); - const prefix = saved ? `Saved "${previous}". ` : ""; - feedback = file.active === previous - ? `${prefix}"${previous}" is the default for new sessions.` - : pinWinner() === previous - ? `${prefix}This repo pins its profile, so "${previous}" now applies to this repo only.` - : `${prefix}"${previous}" was not made the default.`; - continue; - } - case "create-live": { - const answer = await ctx.ui.input("Name for a profile of what's running now", "e.g. snapshot"); - const name = answer?.trim() ?? ""; - if (name === "") { - feedback = `${savedNote}No profile created.`; + case "apply-global": { + // Make default saves what you see, then runs the guarded global + // apply with its diff confirmation, pin honesty, and rollback. + const saved = differsFromStore(previous, result.config); + if (saved && !writeDraft(previous, result.config)) { + draftOverride = result.config; + continue; + } + file = await step(() => runProfilesPanelAction(ctx, live, path, file, { type: "apply-global", name: previous, config: cloneModelConfig(result.config) })); + const prefix = saved ? `Saved "${previous}". ` : ""; + feedback = file.active === previous + ? `${prefix}"${previous}" is the default for new sessions.` + : pinWinner() === previous + ? `${prefix}This repo pins its profile, so "${previous}" now applies to this repo only.` + : `${prefix}"${previous}" was not made the default.`; continue; } - // 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), - liveOrchestratorSnapshot(ctx, live) ?? readOrchestratorSettings(orchestratorSettingsPath()), - ); - try { - const next = createProfile(file, name, snapshot); - writeProfilesFileSync(path, next); - file = next; - selectedName = name; - feedback = `${savedNote}Created "${name}" from what's running now.`; - } catch (error) { - feedback = `${savedNote}Profile not created: ${profilesErrorMessage(error)}`; - } - continue; - } - default: { - const report: ProfilesPanelReport = {}; - file = await runProfilesPanelAction(ctx, live, path, file, result, report); - selectedName = report.selectedName ?? selectedName; - if (result.type === "delete" && !hasOwnProfile(file.profiles, previous)) { - feedback = `Deleted "${previous}".`; - } else if (result.type === "pin") { - feedback = report.status ?? (pinWinner() === previous - ? `"${previous}" is used in this repo.` - : `"${previous}" is no longer pinned to this repo.`); - } else { - feedback = report.status ? `${savedNote}${report.status}` : savedNote || undefined; + case "create-live": { + 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 === "") { + feedback = `${savedNote}No profile created.`; + continue; + } + // 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 step(() => readEffectiveModelConfigAsync(ctx.cwd)), + liveOrchestratorSnapshot(ctx, live) ?? readOrchestratorSettings(orchestratorSettingsPath()), + ); + try { + const next = createProfile(file, name, snapshot); + writeProfilesFileSync(path, next); + file = next; + selectedName = name; + feedback = `${savedNote}Created "${name}" from what's running now.`; + } catch (error) { + feedback = `${savedNote}Profile not created: ${profilesErrorMessage(error)}`; + } + continue; } - // Rename, pin, and a declined delete keep the profile on screen; carry - // unsaved edits across them instead of reloading from the store. - if (result.draft && hasOwnProfile(file.profiles, selectedName) && (selectedName === previous || result.type === "rename")) { - draftOverride = result.draft; + default: { + const report: ProfilesPanelReport = {}; + file = await step(() => runProfilesPanelAction(ctx, live, path, file, result, report)); + selectedName = report.selectedName ?? selectedName; + if (result.type === "delete" && !hasOwnProfile(file.profiles, previous)) { + feedback = `Deleted "${previous}".`; + } else if (result.type === "pin") { + feedback = report.status ?? (pinWinner() === previous + ? `"${previous}" is used in this repo.` + : `"${previous}" is no longer pinned to this repo.`); + } else { + feedback = report.status ? `${savedNote}${report.status}` : savedNote || undefined; + } + // Rename, pin, and a declined delete keep the profile on screen; carry + // unsaved edits across them instead of reloading from the store. + if (result.draft && hasOwnProfile(file.profiles, selectedName) && (selectedName === previous || result.type === "rename")) { + draftOverride = result.draft; + } } } } } } +/** Pi's runner throws on any access to a ctx made stale by reload or session replacement. */ +function isCommandContextActive(ctx: ExtensionContext): boolean { + try { + void ctx.sessionManager; + return true; + } catch { + return false; + } +} + async function handlePersonaCommand(ctx: ExtensionContext): Promise { const current = readPersonaMode(ctx.cwd); const selected = await ctx.ui.select( @@ -10181,6 +10256,7 @@ export const __testing = { parseReviewCaptureParameters, parseReviewCaptureGroupParameters, runProfilesPanelAction, + openProfilesPanelLoop, resolveReviewModeGate, readEffectiveModelConfig, readReviewerModelConfig, @@ -10344,11 +10420,20 @@ function createGentleAiExtensionForTesting( const statusTiming = new StatusTimingDiagnostics(); const reviewSidebar = createReviewSidebarPublisher(pi); + const sessionProfiles = createSessionProfileIntegration(); + pi.on("session_before_fork", async (event, ctx) => { + await waitCurrentSessionProfileSelection(ctx.sessionManager); + sessionProfiles.beforeFork(ctx.sessionManager, { entryId: event.entryId, position: event.position }); + }); + pi.on("session_before_tree", async (_event, ctx) => { + await waitCurrentSessionProfileSelection(ctx.sessionManager); + }); const observedReviewTool: typeof reviewSidebar.tool = (definition) => statusTiming.tool(reviewSidebar.tool(definition)); pi.on("session_tree", (_event, ctx) => { auto.reset(); reviewSidebar.reset(ctx); statusTiming.reset(ctx); + readSessionProfileAuthority(ctx.sessionManager); }); // Lean RDD-off review loadout (applyRddOffReviewPrompt): every // before_agent_start sets it from the pinned line before Pi prepares that @@ -10359,6 +10444,19 @@ function createGentleAiExtensionForTesting( let reminderManager: ExtensionContext["sessionManager"] | undefined; let unbindPreparation: (() => void) | undefined; pi.on("session_shutdown", (event, context) => { + // A pending Enter selection settles before the profile owner shuts down; + // without one the whole shutdown stays synchronous, so terminal cleanup + // (child permission transport, sidebars) is never deferred a turn. + if (hasPendingSessionProfileSelection(context.sessionManager)) { + return waitCurrentSessionProfileSelection(context.sessionManager).then(() => shutdownSession(event, context)); + } + shutdownSession(event, context); + }); + function shutdownSession(event: SessionShutdownEvent, context: ExtensionContext): void { + if (sessionProfiles.isCurrent(context.sessionManager)) { + clearSessionProfileBinding(context.sessionManager.getSessionId()); + sessionProfiles.shutdown(context.sessionManager, event); + } pinnedRddStatusLines.delete(pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey)); rddOffReviewLoadout = false; auto.reset(); @@ -10382,7 +10480,7 @@ function createGentleAiExtensionForTesting( cleanupAllPendingReviewConsents(pendingReviewConsentRegistry, sessionKey); processRetainedNativeStatusSelections.delete(sessionKey); processAgentEndSubagentDepth.delete(sessionKey); - }); + } // gentle-pi ODD input phase labels: the explicit half of the bounded ODD // phase signal for the Gentle prompt's working label. Gentle Shell infers @@ -10747,6 +10845,10 @@ function createGentleAiExtensionForTesting( })); pi.on("session_start", async (event, ctx) => { + const profileSessionId = ctx.sessionManager?.getSessionId?.(); + if (profileSessionId) clearSessionProfileBinding(profileSessionId); + sessionProfiles.start(ctx.sessionManager, (type, data) => pi.appendEntry(type, data), event); + readSessionProfileAuthority(ctx.sessionManager); auto.reset(); yolo.reset(ctx); // Package-owned children reset local state but never initialize primary defaults. diff --git a/lib/session-profile-integration.ts b/lib/session-profile-integration.ts index 9c5e7e25c..fd6e2935f 100644 --- a/lib/session-profile-integration.ts +++ b/lib/session-profile-integration.ts @@ -88,7 +88,13 @@ export async function runCurrentSessionProfileSelection( current: () => boolean, ) => Promise, ): Promise { - const owner = registry().owners.get(manager); + let owner: Owner | undefined; + try { + owner = registry().owners.get(manager); + } catch { + // An incompatible registry has no authority to select against. + return undefined; + } if (!owner?.attachment) return undefined; const attachment = owner.attachment, adapter = owner.adapter; @@ -116,11 +122,28 @@ export async function runCurrentSessionProfileSelection( } /** Await before fork-copy/tree movement and before detach/invalidation. */ +/** Whether an Enter selection is still settling, so callers wait only when needed. + * A registry protocol failure means nothing of ours is pending. + */ +export function hasPendingSessionProfileSelection( + manager: SessionProfileLifecycleManager, +): boolean { + try { + return registry().owners.get(manager)?.pending !== undefined; + } catch { + return false; + } +} + export async function waitCurrentSessionProfileSelection( manager: SessionProfileLifecycleManager, ): Promise { - let pending: Promise | undefined; - while ((pending = registry().owners.get(manager)?.pending)) await pending; + try { + let pending: Promise | undefined; + while ((pending = registry().owners.get(manager)?.pending)) await pending; + } catch { + // Protocol failure: there is no owner queue of ours to drain. + } } /** The registry retains controller evidence, never old ctx/pi or append handles. diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index 430d589ca..eea3add22 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; import { gzipSync } from "node:zlib"; -import { initTheme } from "@earendil-works/pi-coding-agent"; +import { initTheme, SessionManager } from "@earendil-works/pi-coding-agent"; import { visibleWidth } from "@earendil-works/pi-tui"; import { validateToolArguments } from "@earendil-works/pi-ai"; import type { @@ -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 { createSessionProfileIntegration, runCurrentSessionProfileSelection } from "../lib/session-profile-integration.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"; @@ -504,6 +505,9 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) { liveThinking = thinking; }, refuseSetModel() { setModelResult = false; }, + // Enter persists the session profile record, so a test that presses it + // swaps in a real session manager it can append to and corroborate. + useSessionManager(manager: unknown) { (ctx as { sessionManager: unknown }).sessionManager = manager; }, rejectThinkingLevel() { thinkingRejects = true; }, confirmCalls, onConfirm(handler: (title: string, message: string) => Promise) { onConfirm = handler; }, @@ -3230,6 +3234,7 @@ test("models screen saves the explicit orchestrator choice into the profile", as assert.equal(fixture.notifications.some((entry) => entry.severity === "warning"), false); // Use in this session still drives the live session. + fixture.useSessionManager(profileActionManager(fixture.root)); actOnProfile(fixture, "u"); await fixture.run("gentle:profiles"); assert.deepEqual(fixture.liveSwitches, [ @@ -4126,6 +4131,14 @@ function readValidProfilesStore(path: string) { return result.file; } +/** A real persisted session started under the profile integration, so Enter can bind and persist. */ +function profileActionManager(root: string): SessionManager { + const manager = SessionManager.create(root, join(root, "session-fixture")); + manager.appendMessage({ role: "user", content: "isolated profile action", timestamp: 1 }); + createSessionProfileIntegration().start(manager, (type, data) => { manager.appendCustomEntry(type, data); }, { reason: "startup" }); + return manager; +} + test("Enter switches the live orchestrator, binds the profile, and writes nothing", async (t) => { const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t); writeSettings(); @@ -4136,11 +4149,12 @@ test("Enter switches the live orchestrator, binds the profile, and writes nothin }; resetSessionProfileBindingsForTesting(); const notifications: Array<{ message: string; severity: string }> = []; + const manager = profileActionManager(fixture.root); const ctx = { cwd: fixture.root, hasUI: true, ui: { notify(message: string, severity: string) { notifications.push({ message, severity }); } }, - sessionManager: { getSessionId: () => "session-panel" }, + sessionManager: manager, modelRegistry: { find: () => ({ provider: "openai", id: "beta" }) }, } as unknown as ExtensionContext; const models: unknown[] = []; @@ -4154,16 +4168,18 @@ test("Enter switches the live orchestrator, binds the profile, and writes nothin await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "use-session", name: "team" }, {}); assert.deepEqual(models, [{ provider: "openai", id: "beta" }]); assert.deepEqual(thinking, ["high"]); - const binding = readSessionProfileBinding("session-panel"); + const binding = readSessionProfileBinding(manager.getSessionId()); 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.equal(manager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === "gentle-pi.session-profile/v1").length, 1, "only the session profile record is written"); const applied = notifications.at(-1)?.message ?? ""; assert.match(applied, /bound profile "team" to this session/); assert.match(applied, /its tab shows ◉/); + assert.match(applied, /persisted in this session/); assert.match(applied, /This session now runs on openai\/beta · high/); assert.match(applied, /Subagents and reviewers use this session's routing snapshot/); assert.match(applied, /global routing, pins, and materialized stores are untouched/); @@ -4182,10 +4198,11 @@ test("Enter switches only the selecting session's orchestrator and leaves shared bindSessionProfile("other-session", "old", { worker: { model: "openai/old" } }); const switches: string[] = []; const thinking: ThinkingLevel[] = []; + const manager = profileActionManager(fixture.root); const ctx = { cwd: fixture.root, hasUI: true, ui: { notify() {} }, - sessionManager: { getSessionId: () => "selecting-session" }, + sessionManager: manager, modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) }, } as unknown as ExtensionContext; const live = { @@ -4196,7 +4213,7 @@ test("Enter switches only the selecting session's orchestrator and leaves shared await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "use-session", name: "team" }); assert.deepEqual(switches, ["openai/alpha"]); assert.deepEqual(thinking, ["high"]); - assert.equal(readSessionProfileBinding("selecting-session")?.name, "team"); + assert.equal(readSessionProfileBinding(manager.getSessionId())?.name, "team"); assert.equal(readSessionProfileBinding("other-session")?.modelProfiles.worker?.model, "openai/old"); assert.equal(readSessionProfileBinding("unbound-session"), undefined); assert.equal(readFileSync(storePath, "utf8"), beforeStore); @@ -4235,11 +4252,12 @@ test("Enter preserves session-only routing when the orchestrator is absent or ca const notifications: string[] = []; let modelCalls = 0; let thinkingCalls = 0; + const manager = profileActionManager(fixture.root); const ctx = { cwd: fixture.root, hasUI: true, ui: { notify(message: string) { notifications.push(message); } }, - sessionManager: { getSessionId: () => "session-panel" }, + sessionManager: manager, modelRegistry: { find: () => ({ provider: "openai", id: "beta" }) }, } as unknown as ExtensionContext; const live = { @@ -4250,7 +4268,7 @@ test("Enter preserves session-only routing when the orchestrator is absent or ca await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "use-session", name: "team" }, {}); assert.equal(modelCalls, rejected ? 1 : 0); assert.equal(thinkingCalls, 0); - assert.equal(readSessionProfileBinding("session-panel")?.modelProfiles.worker?.model, "openai/alpha"); + assert.equal(readSessionProfileBinding(manager.getSessionId())?.modelProfiles.worker?.model, "openai/alpha"); assert.equal(readFileSync(storePath, "utf8"), storeBefore); assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore); assert.equal(existsSync(fixture.globalPath), false); @@ -4260,6 +4278,110 @@ test("Enter preserves session-only routing when the orchestrator is absent or ca } }); +test("u keeps the session binding when a subagent launch requires session profile authority (#2623)", async (t) => { + const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t); + writeSettings(); + writeStore({ team: { worker: { model: "openai/alpha" } } }, "team"); + resetSessionProfileBindingsForTesting(); + const manager = profileActionManager(fixture.root); + 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: manager, + } as unknown as ExtensionContext; + const live = { + setModel: async () => true, + setThinkingLevel() {}, + getThinkingLevel(): ThinkingLevel { return "medium"; }, + }; + await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "use-session", name: "team" }, {}); + assert.equal(readSessionProfileBinding(manager.getSessionId())?.name, "team", "u binds the profile to the session"); + // Every subagent launch asks for session profile authority first; the u + // binding must survive that check, not be dropped for missing disk state. + const { requireSessionProfileAuthority } = await import("../lib/session-profile-authority.ts"); + const authority = requireSessionProfileAuthority(manager); + assert.equal(authority.available, true); + assert.equal( + readSessionProfileBinding(manager.getSessionId())?.name, + "team", + "the session binding was dropped by the first subagent launch", + ); + assert.equal(authority.binding?.name, "team", "authority does not publish the bound profile"); + resetSessionProfileBindingsForTesting(); +}); + +test("a reload landing while a models screen step is in flight closes the loop without touching the stale ctx", async (t) => { + const { fixture, storePath, writeStore } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } } }, undefined); + const storeBefore = readFileSync(storePath, "utf8"); + let stale = false; + let staleReads = 0; + let screens = 0; + const notifications: string[] = []; + const ui = { + notify(message: string) { notifications.push(message); }, + // The runner invalidates the ctx while this step is still pending. + custom: async () => { + screens += 1; + await Promise.resolve(); + stale = true; + return { type: "create-live" }; + }, + input: async () => "snapshot", + }; + const guard = (value: T): T => { + if (stale) { + staleReads += 1; + throw new Error("stale ctx"); + } + return value; + }; + const ctx = { + get cwd() { return guard(fixture.root); }, + hasUI: true, + get ui() { return guard(ui); }, + get sessionManager() { return guard(profileActionManager(fixture.root)); }, + } as unknown as ExtensionContext; + const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel: (): ThinkingLevel => "medium" }; + await __testing.openProfilesPanelLoop(ctx, live, "team"); + assert.equal(screens, 1, "the screen is not reopened on a stale ctx"); + assert.equal(staleReads, 1, "only the liveness probe touches the stale ctx"); + assert.deepEqual(notifications, []); + assert.equal(readFileSync(storePath, "utf8"), storeBefore, "no profile is created from a stale ctx"); +}); + +test("Enter skipped by a session replacement while queued never touches the stale ctx", async (t) => { + const { fixture, storePath, writeStore } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } } }, undefined); + resetSessionProfileBindingsForTesting(); + t.after(() => resetSessionProfileBindingsForTesting()); + const manager = profileActionManager(fixture.root); + let stale = false; + const notifications: string[] = []; + const ui = { notify(message: string) { notifications.push(message); } }; + const ctx = { + cwd: fixture.root, + hasUI: true, + get ui() { if (stale) throw new Error("stale ctx"); return ui; }, + get sessionManager() { if (stale) throw new Error("stale ctx"); return manager; }, + } as unknown as ExtensionContext; + let release!: () => void; + const holder = runCurrentSessionProfileSelection(manager, () => new Promise((resolve) => { release = resolve; })); + const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel: (): ThinkingLevel => "medium" }; + const enter = __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "use-session", name: "team" }, {}); + await new Promise((resolve) => setImmediate(resolve)); + const file = manager.getSessionFile(); + manager.getSessionFile = () => `${file}.replaced`; + stale = true; + release(); + await holder; + await enter; + assert.deepEqual(notifications, [], "no notice is sent through the stale ctx"); + assert.equal(readSessionProfileBinding(manager.getSessionId()), undefined); +}); + test("a keeps the legacy global apply semantics", async (t) => { const { fixture, storePath, writeStore } = profilesStoreFixture(t); writeStore({ team: { worker: { model: "openai/alpha" } }, old: {} }, "old"); @@ -4284,16 +4406,17 @@ test("Enter with a winning pin binds the session and never touches the pin layer const pinBefore = readFileSync(localPinPath, "utf8"); const storeBefore = readFileSync(storePath, "utf8"); resetSessionProfileBindingsForTesting(); + const manager = profileActionManager(fixture.root); const ctx = { cwd: fixture.root, hasUI: true, ui: { notify() {} }, - sessionManager: { getSessionId: () => "session-panel" }, + sessionManager: manager, } as unknown as ExtensionContext; const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } }; const file = readValidProfilesStore(storePath); await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "use-session", name: "team" }, {}); - assert.equal(readSessionProfileBinding("session-panel")?.name, "team"); + assert.equal(readSessionProfileBinding(manager.getSessionId())?.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); diff --git a/tests/session-profile-extension.test.ts b/tests/session-profile-extension.test.ts new file mode 100644 index 000000000..2c668659b --- /dev/null +++ b/tests/session-profile-extension.test.ts @@ -0,0 +1,570 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AgentSessionRuntime, + createAgentSession, + createEventBus, + createExtensionRuntime, + ModelRuntime, + SessionManager, + SettingsManager, + type AgentSession, + type ExtensionAPI, + type ExtensionUIContext, + type ResourceLoader, +} from "@earendil-works/pi-coding-agent"; +import { loadExtensionFromFactory } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js"; +import { getThemeByName } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/theme.js"; +import { createGentleAiExtension } from "../extensions/gentle-ai.ts"; +import { readSessionProfileBinding } from "../lib/session-profile-binding.ts"; +import { SESSION_PROFILE_CUSTOM_TYPE } from "../lib/session-profile-persistence.ts"; + +// Actual SDK factory loader, AgentSession and replacement runtime. Only resource, +// auth and UI boundaries are fixture-owned. No prompts, provider streams or Git. +async function fixture(t: test.TestContext, memory = false, flushed = true) { + const root = mkdtempSync(join(tmpdir(), "u3-public-")); + const config = join(root, "config"), + agentDir = join(root, "agent"); + mkdirSync(config); + mkdirSync(agentDir); + const originalConfig = process.env.GENTLE_PI_CONFIG_HOME; + process.env.GENTLE_PI_CONFIG_HOME = config; + const profilesPath = join(config, "profiles.json"); + const profiles = { + team: { worker: { model: "openai/gpt-4o" } }, + empty: {}, + live: { orchestrator: { model: "openai/gpt-5.2", thinking: "high" } }, + }; + const saveProfiles = () => + writeFileSync( + profilesPath, + JSON.stringify({ + kind: "gentle-pi.agent_model_profiles", + version: 1, + profiles, + }), + ); + saveProfiles(); + const modelRuntime = await ModelRuntime.create({ + authPath: join(agentDir, "auth.json"), + modelsPath: null, + refreshOnCreate: false, + allowModelNetwork: false, + }); + const baseModel = modelRuntime.getModel("openai", "gpt-4o"); + assert.ok(baseModel, "fixture model catalog prerequisite"); + assert.ok( + modelRuntime.getModel("openai", "gpt-5.2"), + "fixture live model catalog prerequisite", + ); + let authCalls = 0, + providerCalls = 0; + let auth: () => Promise = async () => ({ type: "api-key" }); + modelRuntime.hasConfiguredAuth = () => true; + modelRuntime.checkAuth = (async () => { + authCalls++; + return auth(); + }) as typeof modelRuntime.checkAuth; + modelRuntime.streamSimple = () => { + providerCalls++; + assert.fail("no provider requests"); + }; + const notices: Array<{ text: string; severity?: string }> = [], + errors: string[] = [], + events: string[] = []; + let selection: keyof typeof profiles | undefined; + let currentApi: ExtensionAPI | undefined; + let cancel = false; + const ui = { + theme: getThemeByName("dark")!, + getAllThemes: () => [{ name: "dark" }], + notify: (text: string, severity?: string) => notices.push({ text, severity }), + setStatus() {}, + setWidget() {}, + custom(factory: Parameters[0]) { + return new Promise((resolve) => { + void Promise.resolve( + factory( + { + terminal: { rows: 24 }, + requestRender() {}, + stop() {}, + start() {}, + } as never, + getThemeByName("dark")!, + {} as never, + resolve as never, + ), + ).then((panel) => { + if (selection === undefined) panel.handleInput?.("\x1b"); + else { + // Profiles are tabs in store order: left/right move one tab (a move may + // reopen the screen), and u uses the shown profile in this session. + const names = Object.keys(profiles); + const shown = () => { + const line = (panel.render?.(120) ?? [])[3] ?? ""; + // eslint-disable-next-line no-control-regex + return line.replace(/\x1b\[[0-9;]*m/g, "").replace(/^│\s*/, "").split(/\s{3}|\s*│/)[0]?.trim() ?? ""; + }; + for (let step = 0; step < names.length && shown() !== selection; step++) + panel.handleInput?.(names.indexOf(selection) < names.indexOf(shown()) ? "\x1b[D" : "\x1b[C"); + if (shown() === selection) { + selection = undefined; + panel.handleInput?.("u"); + } + } + }); + }); + }, + } as unknown as ExtensionUIContext; + const make = async ( + manager: SessionManager, + startEvent: { + type: "session_start"; + reason: "startup" | "new" | "resume" | "fork"; + previousSessionFile?: string; + }, + ) => { + let extensionRuntime = createExtensionRuntime(); + let loaded: Awaited>[] = []; + const reload = async () => { + extensionRuntime = createExtensionRuntime(); + const bus = createEventBus(); + loaded = [ + await loadExtensionFromFactory( + (pi) => { + currentApi = pi; + createGentleAiExtension({ + nativeReviewCli: null, + candidateViews: null, + processEnv: { GENTLE_PI_AGENTS_CHILD: "1" }, + resolveTelemetryTriggerBinary: () => { + throw Error("disabled fixture telemetry"); + }, + })(pi); + }, + root, + bus, + extensionRuntime, + ), + await loadExtensionFromFactory( + (pi) => { + pi.on("session_before_fork", () => + cancel ? { cancel: true } : undefined, + ); + pi.on("session_before_tree", () => + cancel ? { cancel: true } : undefined, + ); + pi.on("session_before_switch", () => + cancel ? { cancel: true } : undefined, + ); + pi.on("session_start", (e) => { + events.push(`start:${e.reason}`); + }); + pi.on("session_shutdown", (e) => { + events.push(`shutdown:${e.reason}`); + }); + pi.on("session_tree", () => { + events.push("tree"); + }); + }, + root, + bus, + extensionRuntime, + ), + ]; + }; + const loader: ResourceLoader = { + getExtensions: () => ({ + extensions: loaded, + errors: [], + runtime: extensionRuntime, + }), + getSkills: () => ({ skills: [], diagnostics: [] }), + getPrompts: () => ({ prompts: [], diagnostics: [] }), + getThemes: () => ({ themes: [], diagnostics: [] }), + getAgentsFiles: () => ({ agentsFiles: [] }), + getSystemPrompt: () => "Offline U3 fixture", + getSystemPromptSource: () => undefined, + getAppendSystemPrompt: () => [], + getAppendSystemPromptSources: () => [], + extendResources() {}, + reload, + }; + await reload(); + const created = await createAgentSession({ + cwd: root, + agentDir, + modelRuntime, + model: baseModel, + thinkingLevel: "off", + tools: [], + resourceLoader: loader, + settingsManager: SettingsManager.inMemory({ + compaction: { enabled: false }, + retry: { enabled: false }, + cacheWarming: "off", + }), + sessionManager: manager, + sessionStartEvent: startEvent, + }); + return { ...created, loader }; + }; + const manager = memory + ? SessionManager.inMemory(root) + : SessionManager.create(root, join(root, "sessions")); + if (flushed) + manager.appendMessage({ role: "user", content: "fixture", timestamp: 1 }); + const first = await make(manager, { + type: "session_start", + reason: "startup", + }); + const bind = (session: AgentSession) => + session.bindExtensions({ + uiContext: ui, + mode: "tui", + onError: (e) => errors.push(JSON.stringify(e)), + }); + await bind(first.session); + const runtime = new AgentSessionRuntime( + first.session, + { cwd: root, agentDir } as never, + async (options) => { + const next = await make( + options.sessionManager, + options.sessionStartEvent! as never, + ); + return { + ...next, + services: { cwd: root, agentDir } as never, + diagnostics: [], + }; + }, + ); + runtime.setRebindSession(bind); + t.after(async () => { + await runtime.dispose(); + if (originalConfig === undefined) delete process.env.GENTLE_PI_CONFIG_HOME; + else process.env.GENTLE_PI_CONFIG_HOME = originalConfig; + rmSync(root, { recursive: true, force: true }); + assert.equal(providerCalls, 0); + assert.deepEqual(errors, []); + }); + const enter = async ( + name: keyof typeof profiles = "team", + session: AgentSession = runtime.session, + ) => { + selection = name; + const command = session.extensionRunner.getCommand("gentle:profiles"); + assert.ok(command); + await command.handler("", session.extensionRunner.createCommandContext()); + }; + const binding = () => + readSessionProfileBinding(runtime.session.sessionManager.getSessionId()); + const family = () => + runtime.session.sessionManager + .getBranch() + .filter( + (e) => e.type === "custom" && e.customType === SESSION_PROFILE_CUSTOM_TYPE, + ); + const protectedBytes = () => [ + readFileSync(profilesPath, "utf8"), + existsSync(join(config, "models.json")), + existsSync(join(agentDir, "settings.json")), + existsSync(join(root, ".pi", "subagents.json")), + ]; + return { + root, + runtime, + manager, + notices, + events, + errors, + profiles, + saveProfiles, + enter, + binding, + family, + protectedBytes, + api: () => currentApi!, + authCalls: () => authCalls, + setAuth: (fn: typeof auth) => { + auth = fn; + }, + cancel: (value: boolean) => { + cancel = value; + }, + }; +} + +for (const memory of [false, true]) + for (const flushed of [false, true]) + test(`U3 Enter public component persists or explicitly reports preflush (${memory}, ${flushed})`, async (t) => { + const f = await fixture(t, memory, flushed), + before = f.protectedBytes(); + await f.enter(); + assert.equal( + f.family().length, + 1, + "Enter appends one public profile record before publication", + ); + assert.equal(f.authCalls(), 0); + assert.deepEqual(f.protectedBytes(), before); + // A flushed in-memory session never corroborates its append (published + // append controller: append-not-corroborated), so Enter adopts nothing. + if (memory && flushed) { + assert.equal(f.binding(), undefined); + assert.match(f.notices.at(-1)?.text ?? "", /Session profile authority is unavailable/); + } else { + assert.equal(f.binding()?.name, "team"); + assert.deepEqual(f.binding()?.modelProfiles, f.profiles.team); + assert.match( + f.notices.at(-1)?.text ?? "", + memory || !flushed ? /not yet persisted/ : /persisted/, + ); + } + await f.runtime.session.reload(); + assert.equal( + f.binding()?.name, + memory || !flushed ? undefined : "team", + "reload restores only disk-corroborated state", + ); + assert.equal( + f.authCalls(), + 0, + "no restoration-time orchestrator application", + ); + }); + +test("U3 Enter composes append, exact publication and existing live controls without shared writes", async (t) => { + const f = await fixture(t), + before = f.protectedBytes(); + f.setAuth(async () => { + assert.equal(f.binding()?.name, "live", "publication precedes live effects"); + assert.equal(f.family().length, 1); + assert.ok( + readFileSync(f.runtime.session.sessionFile!, "utf8").includes( + SESSION_PROFILE_CUSTOM_TYPE, + ), + ); + return { type: "api-key" }; + }); + await f.enter("live"); + assert.equal(f.runtime.session.model?.id, "gpt-5.2"); + assert.equal(f.runtime.session.thinkingLevel, "high"); + assert.equal(f.authCalls(), 1); + assert.deepEqual(f.protectedBytes(), before); + await f.enter("empty"); + assert.deepEqual(f.binding()?.modelProfiles, {}); + assert.equal(f.authCalls(), 1, "empty profile does not switch model"); +}); + +for (const failure of ["before-append", "EISDIR"] as const) + test(`U3 failed Enter ${failure} adopts nothing and applies no live effects`, async (t) => { + const f = await fixture(t); + await f.enter(); + const file = f.manager.getSessionFile()!, + disk = readFileSync(file, "utf8"), + before = f.protectedBytes(); + const append = f.manager.appendCustomEntry.bind(f.manager); + if (failure === "EISDIR") { + renameSync(file, `${file}.saved`); + mkdirSync(file); + } else + f.manager.appendCustomEntry = () => { + throw Error("fixture rejected append"); + }; + await f.enter("live"); + assert.equal(f.authCalls(), 0); + assert.equal(f.runtime.session.model?.id, "gpt-4o"); + assert.equal(f.binding()?.name, failure === "EISDIR" ? undefined : "team"); + assert.equal(f.notices.at(-1)?.severity, "warning"); + assert.doesNotMatch(f.notices.at(-1)?.text ?? "", /bound profile "live"/); + assert.deepEqual(f.protectedBytes(), before); + f.manager.appendCustomEntry = append; + if (failure === "EISDIR") { + rmSync(file, { recursive: true }); + renameSync(`${file}.saved`, file); + } + assert.equal( + readFileSync(file, "utf8"), + disk, + "rejected entry did not reach disk", + ); + await f.runtime.session.reload(); + assert.equal( + f.binding()?.name, + "team", + "quarantine survives module replacement", + ); + assert.equal(f.authCalls(), 0); + }); + +for (const customType of [ + SESSION_PROFILE_CUSTOM_TYPE, + "gentle-pi.session-profile/v99", +]) + test(`U3 terminal invalid/future selection ${customType} blocks until explicit recovery`, async (t) => { + const f = await fixture(t); + await f.enter(); + f.manager.appendCustomEntry(customType, { invalid: true }); + await f.runtime.session.reload(); + assert.equal(f.binding(), undefined); + await f.enter(); + assert.equal(f.binding()?.name, "team"); + assert.equal(f.authCalls(), 0); + }); + +for (const memory of [false, true]) + for (const cut of ["before", "at", "root", "label"] as const) + test(`U3 actual runtime fork retained cut ${cut} (${memory})`, async (t) => { + const f = await fixture(t, memory); + const root = f.manager.getBranch()[0].id; + await f.enter(); + const profile = f.manager.getLeafId()!; + const user = f.manager.appendMessage({ + role: "user", + content: "cut", + timestamp: 2, + }); + f.manager.appendLabelChange(profile, "profile-label"); + const before = readSessionProfileBinding(f.manager.getSessionId()); + const target = + cut === "root" ? root : cut === "at" || cut === "label" ? profile : user; + const result = await f.runtime.fork(target, { + position: cut === "root" || cut === "before" ? "before" : "at", + }); + assert.equal(result.cancelled, false); + assert.equal( + f.binding()?.name, + cut === "root" || memory ? undefined : "team", + "only retained corroborated records restore", + ); + assert.equal(f.authCalls(), 0); + // In memory the flushed append is never corroborated, so nothing was bound. + assert.deepEqual(before?.modelProfiles, memory ? undefined : f.profiles.team); + assert.ok(f.events.includes("shutdown:fork")); + assert.ok(f.events.includes("start:fork")); + }); + +test("U3 public runtime cancellation and unsaved rejection preserve explicit authority", async (t) => { + const f = await fixture(t, false, false); + await f.enter(); + const leaf = f.manager.getLeafId()!; + const branch = f.manager.getBranch(), + before = f.binding(); + f.cancel(true); + assert.equal((await f.runtime.fork(leaf, { position: "at" })).cancelled, true); + assert.equal((await f.runtime.newSession()).cancelled, true); + assert.deepEqual(f.manager.getBranch(), branch); + assert.deepEqual(f.binding(), before); + f.cancel(false); + await assert.rejects( + f.runtime.fork(leaf, { position: "at" }), + /has not been saved/, + ); + assert.deepEqual(f.binding(), before); + assert.equal( + f.events.some((e) => e.startsWith("shutdown")), + false, + ); +}); + +test("U3 actual new/resume/tree/reload revoke stale public contexts without restoration effects", async (t) => { + const f = await fixture(t); + await f.enter(); + const file = f.manager.getSessionFile()!, + old = f.runtime.session; + const profile = f.manager.getLeafId()!; + const later = f.manager.appendCustomEntry("ordinary", {}); + await old.navigateTree(profile); + assert.equal(f.binding()?.name, "team"); + await old.navigateTree(later); + assert.equal(f.binding()?.name, "team"); + await f.runtime.newSession(); + assert.equal(f.binding(), undefined); + await assert.rejects(f.enter("live", old)); + assert.equal(f.authCalls(), 0); + await f.runtime.switchSession(file); + assert.equal(f.binding()?.name, "team"); + await f.runtime.session.reload(); + assert.equal(f.binding()?.name, "team"); + assert.equal(f.authCalls(), 0); + assert.ok(f.events.includes("start:new")); + assert.ok(f.events.includes("start:resume")); +}); + +for (const transition of ["fork", "tree", "reload", "new"] as const) + test(`U3 pending Enter serializes before ${transition} on actual public SDK`, async (t) => { + const f = await fixture(t); + const leaf = f.manager.getLeafId()!; + // Tree navigation to the current leaf is a no-op, so move the leaf away first. + if (transition === "tree") f.manager.appendCustomEntry("ordinary", {}); + let release!: () => void, started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + f.setAuth(() => { + started(); + return new Promise((resolve) => { + release = () => resolve({ type: "api-key" }); + }); + }); + const enter = f.enter("live"); + // RED must fail an assertion, not hang waiting for a nonexistent live call. + await Promise.race([ready, enter]); + assert.equal(f.authCalls(), 1, "Enter reaches the existing live control"); + const branch = JSON.stringify(f.manager.getBranch()), + before = f.binding(); + let transitioned = false; + const move = ( + transition === "fork" + ? f.runtime.fork(leaf, { position: "at" }) + : transition === "tree" + ? f.runtime.session.navigateTree(leaf) + : transition === "reload" + ? f.runtime.session.reload() + : f.runtime.newSession() + ).then(() => { + transitioned = true; + }); + // Fork/tree/reload/new take several ticks unserialized; sample well after that. + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal( + f.events.some((e) => e.startsWith("shutdown")), + false, + "no owner shutdown while Enter awaits authentication", + ); + assert.equal(f.events.includes("tree"), false, "no tree navigation yet"); + assert.equal(transitioned, false, "transition waits for Enter to settle"); + if (transition !== "reload") { + assert.equal( + JSON.stringify(f.manager.getBranch()), + branch, + "branch is not copied or moved while Enter is pending", + ); + assert.deepEqual(f.binding(), before); + } + release(); + await Promise.all([enter, move]); + assert.equal(transitioned, true); + assert.equal( + transition === "tree" + ? f.events.includes("tree") + : f.events.includes(`shutdown:${transition}`), + true, + "transition completes once Enter settles", + ); + assert.equal(f.authCalls(), 1); + }); diff --git a/tests/session-profile-integration.test.ts b/tests/session-profile-integration.test.ts index 236a1c702..d57188a6b 100644 --- a/tests/session-profile-integration.test.ts +++ b/tests/session-profile-integration.test.ts @@ -11,6 +11,7 @@ import { } from "@earendil-works/pi-coding-agent"; import { createSessionProfileIntegration, + hasPendingSessionProfileSelection, readCurrentSessionProfileOutcome, runCurrentSessionProfileSelection, waitCurrentSessionProfileSelection, @@ -815,3 +816,41 @@ test("helpers report no owner as undefined", async () => { assert.equal(await runCurrentSessionProfileSelection(f.manager, async () => "never"), undefined); await waitCurrentSessionProfileSelection(f.manager); }); + +test("selection helpers treat an incompatible registry protocol as nothing pending or selectable", () => { + const moduleUrl = new URL( + "../lib/session-profile-integration.ts", + import.meta.url, + ).href; + const script = ` + import assert from "node:assert/strict"; + import { SessionManager } from "@earendil-works/pi-coding-agent"; + import { + hasPendingSessionProfileSelection, + runCurrentSessionProfileSelection, + waitCurrentSessionProfileSelection, + } from ${JSON.stringify(moduleUrl)}; + globalThis[Symbol.for("gentle-pi.session-profile.lifecycle")] = { version: 2 }; + const manager = SessionManager.inMemory(${JSON.stringify(tmpdir())}); + assert.equal(hasPendingSessionProfileSelection(manager), false); + assert.equal(await waitCurrentSessionProfileSelection(manager), undefined); + let ran = false; + assert.equal( + await runCurrentSessionProfileSelection(manager, async () => { + ran = true; + return "never"; + }), + undefined, + ); + assert.equal(ran, false); + process.stdout.write("ok"); + `; + const result = spawnSync( + process.execPath, + ["--experimental-strip-types", "--input-type=module", "-e", script], + { encoding: "utf8", cwd: process.cwd(), env: process.env }, + ); + assert.equal(result.stderr, ""); + assert.equal(result.status, 0); + assert.equal(result.stdout, "ok"); +});