mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
docs(coding-agent): document session loadouts
This commit is contained in:
parent
ef03c1328f
commit
a50ee9d1d0
15 changed files with 178 additions and 41 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ export {
|
|||
type LoadoutResourceScope,
|
||||
type LoadoutResourceType,
|
||||
type LoadoutSnapshot,
|
||||
loadoutOverridesEqual,
|
||||
type PackageLoadoutResourceReference,
|
||||
parseLoadoutEntryPayload,
|
||||
type ResolvedLoadoutOverlay,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue