docs(coding-agent): document session loadouts

This commit is contained in:
Armin Ronacher 2026-07-26 15:55:26 +02:00
parent ef03c1328f
commit a50ee9d1d0
15 changed files with 178 additions and 41 deletions

View file

@ -254,6 +254,16 @@ export class Agent {
return this._state;
}
/** @internal Whether a model has been selected for future turns. */
get hasModel(): boolean {
return this._state.model !== DEFAULT_MODEL;
}
/** @internal Clear the selected model while retaining the agent's internal sentinel. */
clearModel(): void {
this._state.model = DEFAULT_MODEL;
}
/** Controls how queued steering messages are drained. */
set steeringMode(mode: QueueMode) {
this.steeringQueue.mode = mode;

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added persistent resource management through `pi loadout` (`pi config` remains an alias) and interactive `/loadout` session overrides with staged reloads, append-only session persistence, best-effort resume restoration, and safe extension unloading.
## [0.82.1] - 2026-07-25
### New Features

View file

@ -180,6 +180,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
| `/model` | Switch models |
| `/scoped-models` | Enable/disable models for Ctrl+P cycling |
| `/settings` | Thinking level, theme, message delivery, transport |
| `/loadout` | Stage session-only extensions, skills, prompts, and themes, then reload |
| `/resume` | Pick from previous sessions |
| `/new` | Start a new session |
| `/name <name>` | Set session display name |
@ -268,6 +269,12 @@ Use `/session` in interactive mode to see the current session ID before reusing
**`--fork <path|id>`** - Fork an existing session file or partial session UUID directly from the CLI. This copies the full source session into a new session file in the current project.
### Session Loadouts
Use `/loadout` to stage session-only overrides for extensions, skills, prompt templates, and themes. Space toggles a resource, Enter applies the staged set with one reload, and Escape discards it. These overrides do not change global or project settings, and ordinary `/reload` preserves them.
A deliberate non-empty loadout is saved as session metadata. Clearing a previously saved loadout writes a reset marker; opening the selector or reloading does not create duplicate entries. When an existing session has a saved non-empty loadout, interactive mode asks before restoring it. Missing or no-longer-trusted resources are skipped with warnings. Print, JSON, and RPC modes do not restore or prompt for saved loadouts.
### Compaction
Long sessions can exhaust context windows. Compaction summarizes older messages while keeping recent ones.
@ -301,7 +308,7 @@ Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trus
If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`.
`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
`pi loadout` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect.
@ -428,7 +435,7 @@ pi update --models # refresh model catalogs only
pi update --self # update pi only
pi update --self --force # reinstall pi even if current
pi update npm:@foo/pi-tools # update one package
pi config # enable/disable extensions, skills, prompts, themes
pi loadout # persistently enable/disable extensions, skills, prompts, themes
```
Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update --extensions` and `pi update --all`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`.
@ -528,10 +535,10 @@ pi update --self # Update pi only
pi update --self --force # Reinstall pi even if current
pi update --extension <src> # Update one package
pi list # List installed packages
pi config # Enable/disable package resources
pi loadout # Persistently enable/disable package resources
```
`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust.
`pi config` remains accepted as a backwards-compatible alias for `pi loadout`. `pi loadout` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust.
### Modes

View file

@ -223,6 +223,8 @@ Extension factories may run in invocations that never start a session. Do not st
Defer background resource startup until `session_start` or the command/tool/event that needs the resource. Register an idempotent `session_shutdown` handler to close any session-scoped resources you start.
`/reload` and interactive `/loadout` can unload an extension. Pi emits `session_shutdown` before removing the old runtime, then invalidates captured `pi` and context APIs; using them after reload throws. Event-bus subscriptions made through `pi.events.on()` are removed with that runtime. Pi cannot clean up arbitrary timers, processes, sockets, or file watchers, so extensions remain responsible for releasing those in `session_shutdown`. Commands, tools, hooks, shortcuts, renderers, UI, and provider registrations owned by an unloaded extension are removed.
### Extension Styles
**Single file** - simplest, for small extensions:
@ -1286,7 +1288,7 @@ pi.registerCommand("reload-runtime", {
Important behavior:
- `await ctx.reload()` emits `session_shutdown` for the current extension runtime
- It then reloads resources and emits `session_start` with `reason: "reload"` and `resources_discover` with reason `"reload"`
- It then invalidates that runtime, removes runtime-owned UI and event-bus subscriptions, reloads resources, and emits `session_start` with `reason: "reload"` and `resources_discover` with reason `"reload"`
- The currently running command handler still continues in the old call frame
- Code after `await ctx.reload()` still runs from the pre-reload version
- Code after `await ctx.reload()` must not assume old in-memory extension state is still valid

View file

@ -36,6 +36,7 @@ pi update --self # update pi only
pi update --self --force # reinstall pi even if current
pi update npm:@foo/bar # update one package
pi update --extension npm:@foo/bar
pi loadout # persistently enable or disable resources
```
These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall).
@ -217,7 +218,9 @@ Filter what a package loads using the object form in settings:
## Enable and Disable Resources
Use `pi config` to enable or disable extensions, skills, prompt templates, and themes from installed packages and local directories. `pi config` starts in global settings (`~/.pi/agent/settings.json`); press Tab to switch between global and project-local modes. Use `pi config -l` to start in project overrides (`.pi/settings.json`) with inherited global resources dimmed.
Use `pi loadout` to persistently enable or disable extensions, skills, prompt templates, and themes from installed packages and local directories. It starts in global settings (`~/.pi/agent/settings.json`); press Tab to switch between global and project-local modes. Use `pi loadout -l` to start in project overrides (`.pi/settings.json`) with inherited global resources dimmed. `pi config` remains accepted as a backwards-compatible alias.
Inside interactive mode, `/loadout` instead stages overrides for the current session. It never writes either settings file. Enter applies the staged set and reloads resources once; Escape discards it. Ordinary `/reload` preserves active session overrides. Deliberate non-empty overrides are stored in the session, and interactive resume asks before restoring them. Restoration matches only currently discovered resources, so it cannot install packages, introduce paths from session data, or bypass project trust; unavailable resources are skipped with warnings.
## Scope and Deduplication

View file

@ -134,7 +134,7 @@ interface BashExecutionMessage {
interface CustomMessage {
role: "custom";
customType: string; // Extension identifier
customType: string; // Extension or built-in identifier
content: string | (TextContent | ImageContent)[];
display: boolean; // Show in TUI
details?: any; // Extension-specific metadata
@ -268,6 +268,14 @@ Extension state persistence. Does NOT participate in LLM context.
Use `customType` to identify your extension's entries on reload. Interactive mode can render custom entries via `pi.registerEntryRenderer(customType, renderer)`, but they still do not participate in LLM context.
Pi also uses namespaced custom entries for built-in session metadata. Session loadouts use `customType: "pi.loadout"` with a versioned payload:
```json
{"type":"custom","id":"l8i9j0k1","parentId":"h8i9j0k1","timestamp":"2024-12-03T14:22:00.000Z","customType":"pi.loadout","data":{"version":1,"overrides":[{"reference":{"type":"skill","origin":"top-level","scope":"user","relativePath":"skills/review/SKILL.md"},"enabled":false}]}}
```
Loadout entries are session-wide metadata, not conversation context. Pi reads the latest valid entry in physical file order rather than following the current `/tree` branch. Non-empty entries represent deliberate `/loadout` changes; an empty `overrides` array is a reset marker written only when clearing an earlier saved loadout. Forked or extracted session files retain only loadout entries copied into their retained path.
### CustomMessageEntry
Extension-injected messages that DO participate in LLM context.

View file

@ -17,7 +17,7 @@ Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trus
If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`.
`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
`pi loadout` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. `pi config` is an alias for `pi loadout`.
Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect.
@ -229,7 +229,9 @@ When multiple sources specify a session directory, precedence is `--session-dir`
### Resources
These settings define where to load extensions, skills, prompts, and themes from.
These settings define where to load extensions, skills, prompts, and themes from. Use `pi loadout` to edit their persistent global or project selection.
Interactive `/loadout` is separate: it applies session-only overrides and never writes either settings file. A normal `/reload` keeps those active overrides.
Paths in `~/.pi/agent/settings.json` resolve relative to `~/.pi/agent`. Paths in `.pi/settings.json` resolve relative to `.pi`. Absolute paths and `~` are supported.

View file

@ -41,6 +41,7 @@ Type `/` in the editor to open command completion. Extensions can register custo
| `/model` | Switch models |
| `/scoped-models` | Enable/disable models for Ctrl+P cycling |
| `/settings` | Thinking level, theme, message delivery, transport |
| `/loadout` | Stage session-only extensions, skills, prompts, and themes, then reload |
| `/resume` | Pick from previous sessions |
| `/new` | Start a new session |
| `/name <name>` | Set session display name |
@ -95,6 +96,12 @@ Useful session commands:
See [Sessions](sessions.md) and [Compaction](compaction.md) for details.
### Session Loadouts
`/loadout` changes the current session's effective extensions, skills, prompt templates, and themes without writing global or project settings. Changes are staged: Space toggles, Enter applies and reloads once, and Escape discards. Ordinary `/reload` keeps the active session overrides.
Pi saves only deliberate loadout changes as non-context session metadata. A non-empty loadout is offered for restoration when that existing session is opened interactively; declining leaves normal settings active and may prompt again on a future resume. Missing resources are skipped with warnings, and saved data cannot install packages or bypass project trust. Print, JSON, and RPC modes ignore saved loadouts.
## Context Files
Pi loads `AGENTS.md` or `CLAUDE.md` at startup from:
@ -124,7 +131,7 @@ Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trus
If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`.
`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
`pi loadout` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect.
@ -156,10 +163,10 @@ pi update --models # Refresh model catalogs only
pi update --self # Update pi only
pi update --extension <src> # Update one package
pi list # List installed packages
pi config # Enable/disable package resources
pi loadout # Persistently enable/disable package resources
```
These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust.
These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` remains accepted as an alias for `pi loadout`. `pi loadout` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust.
See [Pi Packages](packages.md) for package sources and security notes.

View file

@ -869,7 +869,7 @@ export class AgentSession {
/** Current model (may be undefined if not yet selected) */
get model(): Model<any> | undefined {
return this.agent.state.model;
return this.agent.hasModel ? this.agent.state.model : undefined;
}
/** Current thinking level */
@ -2346,18 +2346,7 @@ export class AgentSession {
return `Active model "${previousModel.provider}/${previousModel.id}" is no longer available after reload. Switched to "${fallback.provider}/${fallback.id}".`;
}
this.agent.state.model = {
...previousModel,
id: "unknown",
name: "unknown",
api: "unknown",
provider: "unknown",
baseUrl: "",
reasoning: false,
input: [],
contextWindow: 0,
maxTokens: 0,
};
this.agent.clearModel();
this.agent.state.thinkingLevel = "off";
return `Active model "${previousModel.provider}/${previousModel.id}" is no longer available after reload. No configured model is available; use /model or /login to select one.`;
}

View file

@ -332,7 +332,7 @@ export function getSessionLoadout(sessionManager: Pick<SessionManager, "getEntri
return getLatestLoadoutEntry(sessionManager.getEntries());
}
function overrideSetsEqual(left: readonly LoadoutOverride[], right: readonly LoadoutOverride[]): boolean {
export function loadoutOverridesEqual(left: readonly LoadoutOverride[], right: readonly LoadoutOverride[]): boolean {
if (left.length !== right.length) return false;
const rightByKey = new Map(
right.map((override) => [getLoadoutResourceReferenceKey(override.reference), override.enabled]),
@ -355,7 +355,7 @@ export function appendSessionLoadout(
const latest = getLatestLoadoutEntry(sessionManager.getEntries());
if (payload.overrides.length === 0) {
if (!latest || latest.overrides.length === 0) return undefined;
} else if (latest && overrideSetsEqual(latest.overrides, payload.overrides)) {
} else if (latest && loadoutOverridesEqual(latest.overrides, payload.overrides)) {
return undefined;
}
return sessionManager.appendCustomEntry(LOADOUT_CUSTOM_TYPE, payload);

View file

@ -182,6 +182,7 @@ export {
type LoadoutResourceScope,
type LoadoutResourceType,
type LoadoutSnapshot,
loadoutOverridesEqual,
type PackageLoadoutResourceReference,
parseLoadoutEntryPayload,
type ResolvedLoadoutOverlay,

View file

@ -75,7 +75,12 @@ import type {
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts";
import { appendSessionLoadout, getSessionLoadout, type LoadoutOverride } from "../../core/loadout.ts";
import {
appendSessionLoadout,
getSessionLoadout,
type LoadoutOverride,
loadoutOverridesEqual,
} from "../../core/loadout.ts";
import { createCompactionSummaryMessage } from "../../core/messages.ts";
import {
defaultModelPerProvider,
@ -4181,9 +4186,15 @@ export class InteractiveMode {
this.showWarning("Session loadouts are unavailable with the configured resource loader.");
return;
}
const previousOverrides = loader.getLoadoutSnapshot().overrides;
loader.setLoadoutOverrides(overrides);
if (persist) appendSessionLoadout(this.sessionManager, overrides);
await this.handleReloadCommand();
if (!(await this.handleReloadCommand())) {
loader.setLoadoutOverrides(previousOverrides);
return;
}
if (persist && !loadoutOverridesEqual(previousOverrides, overrides)) {
appendSessionLoadout(this.sessionManager, overrides);
}
this.showLoadoutDiagnostics();
}
@ -5195,7 +5206,7 @@ export class InteractiveMode {
let selectedModel: Model<any> | undefined;
let selectionError: string | undefined;
if (isUnknownModel(previousModel)) {
if (!previousModel || isUnknownModel(previousModel)) {
const availableModels = await this.session.modelRuntime.getAvailable();
const providerModels = availableModels.filter((model) => model.provider === providerId);
if (!hasDefaultModelProvider(providerId)) {
@ -5418,18 +5429,18 @@ export class InteractiveMode {
// Command handlers
// =========================================================================
private async handleReloadCommand(): Promise<void> {
private async handleReloadCommand(): Promise<boolean> {
if (this.session.isStreaming) {
this.showWarning("Wait for the current response to finish before reloading.");
return;
return false;
}
if (this.session.isCompacting) {
this.showWarning("Wait for compaction to finish before reloading.");
return;
return false;
}
if (this.session.isBashRunning) {
this.showWarning("Wait for the bash command to finish before reloading.");
return;
return false;
}
const reloadBox = new Container();
@ -5527,11 +5538,13 @@ export class InteractiveMode {
);
dismissReloadBox(this.editor as Component);
reloadBoxDismissed = true;
return true;
} catch (error) {
if (!reloadBoxDismissed) {
dismissReloadBox(this.editor as Component);
}
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}

View file

@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Provider } from "@earendil-works/pi-ai";
import { getModel } from "@earendil-works/pi-ai/compat";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRuntime } from "../src/core/model-runtime.ts";
import { DefaultResourceLoader } from "../src/core/resource-loader.ts";
@ -263,4 +263,40 @@ describe("AgentSession dynamic provider registration", () => {
);
session.dispose();
});
it("clears a removed active model when reload has no fallback", async () => {
let enabled = true;
const session = await createSession([
(pi) => {
if (!enabled) return;
pi.registerProvider("temporary-provider", {
baseUrl: "https://temporary.test",
apiKey: "test-key",
api: "openai-completions",
models: [
{
id: "temporary-model",
name: "Temporary Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
},
],
});
},
]);
await session.modelRuntime.refresh({ allowNetwork: false });
await session.setModel(session.modelRuntime.getModel("temporary-provider", "temporary-model")!);
vi.spyOn(session.modelRuntime, "getAvailableSnapshot").mockReturnValue([]);
enabled = false;
const result = await session.reload();
expect(session.model).toBeUndefined();
expect(session.thinkingLevel).toBe("off");
expect(result.modelFallbackMessage).toContain("No configured model is available");
session.dispose();
});
});

View file

@ -19,6 +19,7 @@ type ImportCommandContext = {
renderCurrentSessionState: () => void;
handleFatalRuntimeError: (prefix: string, error: unknown) => Promise<never>;
promptForMissingSessionCwd: (error: unknown) => Promise<string | undefined>;
maybeRestoreSavedLoadout: () => Promise<void>;
getPathCommandArgument: (text: string, command: PathCommand) => string | undefined;
};
@ -55,6 +56,7 @@ describe("InteractiveMode /import parsing", () => {
const showExtensionConfirm = vi.fn(async () => true);
const showStatus = vi.fn();
const showError = vi.fn();
const maybeRestoreSavedLoadout = vi.fn(async () => {});
const context: ImportCommandContext = {
clearStatusIndicator: vi.fn(),
@ -68,6 +70,7 @@ describe("InteractiveMode /import parsing", () => {
throw new Error("unexpected fatal error");
}),
promptForMissingSessionCwd: vi.fn(async () => undefined),
maybeRestoreSavedLoadout,
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
};
@ -78,6 +81,7 @@ describe("InteractiveMode /import parsing", () => {
"Replace current session with path/to/session.jsonl?",
);
expect(importFromJsonl).toHaveBeenCalledWith("path/to/session.jsonl");
expect(maybeRestoreSavedLoadout).toHaveBeenCalledOnce();
expect(showError).not.toHaveBeenCalled();
expect(showStatus).toHaveBeenCalledWith("Session imported from: path/to/session.jsonl");
});
@ -87,6 +91,7 @@ describe("InteractiveMode /import parsing", () => {
const showExtensionConfirm = vi.fn(async () => true);
const showStatus = vi.fn();
const showError = vi.fn();
const maybeRestoreSavedLoadout = vi.fn(async () => {});
const context: ImportCommandContext = {
clearStatusIndicator: vi.fn(),
@ -100,12 +105,14 @@ describe("InteractiveMode /import parsing", () => {
throw new Error("unexpected fatal error");
}),
promptForMissingSessionCwd: vi.fn(async () => undefined),
maybeRestoreSavedLoadout,
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
};
await interactiveModePrototype.handleImportCommand.call(context, "/import john's/session.jsonl");
expect(importFromJsonl).toHaveBeenCalledWith("john's/session.jsonl");
expect(maybeRestoreSavedLoadout).toHaveBeenCalledOnce();
expect(showError).not.toHaveBeenCalled();
expect(showStatus).toHaveBeenCalledWith("Session imported from: john's/session.jsonl");
});
@ -131,6 +138,7 @@ describe("InteractiveMode /import parsing", () => {
renderCurrentSessionState: vi.fn(),
handleFatalRuntimeError,
promptForMissingSessionCwd: vi.fn(async () => undefined),
maybeRestoreSavedLoadout: vi.fn(async () => {}),
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
};

View file

@ -32,7 +32,7 @@ type ApplyContext = {
setLoadoutOverrides: (overrides: readonly LoadoutOverride[]) => void;
};
loadoutActionBlocked: (action: "opening" | "applying") => boolean;
handleReloadCommand: () => Promise<void>;
handleReloadCommand: () => Promise<boolean>;
showLoadoutDiagnostics: () => void;
showWarning: (message: string) => void;
};
@ -46,7 +46,7 @@ type InteractiveModePrivate = {
maybeRestoreSavedLoadout(this: RestoreContext): Promise<void>;
applySessionLoadout(this: ApplyContext, overrides: LoadoutOverride[], persist: boolean): Promise<void>;
loadoutActionBlocked(this: Pick<ApplyContext, "session" | "showWarning">, action: "opening" | "applying"): boolean;
handleReloadCommand(this: ReloadBlockedContext): Promise<void>;
handleReloadCommand(this: ReloadBlockedContext): Promise<boolean>;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrivate;
@ -144,11 +144,11 @@ describe("interactive /loadout application", () => {
it("persists a deliberate change, reloads once, and reports unmatched resources", async () => {
const harness = await createTestHarness();
const setLoadoutOverrides = vi.fn();
const handleReloadCommand = vi.fn(async () => {});
const handleReloadCommand = vi.fn(async () => true);
const showWarning = vi.fn();
const snapshot: LoadoutSnapshot = {
resources: [],
overrides,
overrides: [],
diagnostics: [{ type: "warning", message: "Saved loadout skill is unavailable" }],
};
const context: ApplyContext = {
@ -173,6 +173,53 @@ describe("interactive /loadout application", () => {
expect(showWarning).toHaveBeenCalledWith("Saved loadout skill is unavailable");
});
it("does not write a reset marker when the declined saved loadout is already inactive", async () => {
const harness = await createTestHarness();
appendSessionLoadout(harness.sessionManager, overrides);
const setLoadoutOverrides = vi.fn();
const context: ApplyContext = {
session: { isStreaming: false, isCompacting: false, isBashRunning: false },
sessionManager: harness.sessionManager,
getLoadoutLoader: () => ({
getLoadoutSnapshot: () => ({ resources: [], overrides: [], diagnostics: [] }),
setLoadoutOverrides,
}),
loadoutActionBlocked: () => false,
handleReloadCommand: async () => true,
showLoadoutDiagnostics: () => {},
showWarning: vi.fn(),
};
await interactiveModePrototype.applySessionLoadout.call(context, [], true);
expect(setLoadoutOverrides).toHaveBeenCalledOnce();
expect(getSessionLoadout(harness.sessionManager)?.overrides).toEqual(overrides);
});
it("rolls back in-memory overrides and persistence when reload fails", async () => {
const harness = await createTestHarness();
const setLoadoutOverrides = vi.fn();
const context: ApplyContext = {
session: { isStreaming: false, isCompacting: false, isBashRunning: false },
sessionManager: harness.sessionManager,
getLoadoutLoader: () => ({
getLoadoutSnapshot: () => ({ resources: [], overrides: [], diagnostics: [] }),
setLoadoutOverrides,
}),
loadoutActionBlocked: () => false,
handleReloadCommand: async () => false,
showLoadoutDiagnostics: vi.fn(),
showWarning: vi.fn(),
};
await interactiveModePrototype.applySessionLoadout.call(context, overrides, true);
expect(setLoadoutOverrides).toHaveBeenNthCalledWith(1, overrides);
expect(setLoadoutOverrides).toHaveBeenNthCalledWith(2, []);
expect(getSessionLoadout(harness.sessionManager)).toBeUndefined();
expect(context.showLoadoutDiagnostics).not.toHaveBeenCalled();
});
it.each([
["streaming", { isStreaming: true, isCompacting: false, isBashRunning: false }],
["compaction", { isStreaming: false, isCompacting: true, isBashRunning: false }],
@ -180,7 +227,7 @@ describe("interactive /loadout application", () => {
])("blocks application during %s", async (_name, session) => {
const harness = await createTestHarness();
const setLoadoutOverrides = vi.fn();
const handleReloadCommand = vi.fn(async () => {});
const handleReloadCommand = vi.fn(async () => true);
const showWarning = vi.fn();
const context: ApplyContext = {
session,