fix(coding-agent): harden extension unload on reload

This commit is contained in:
Armin Ronacher 2026-07-26 15:44:38 +02:00
parent 6000b0170a
commit ef03c1328f
10 changed files with 418 additions and 33 deletions

View file

@ -153,9 +153,10 @@ export async function createAgentSessionServices(
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
const extensionsResult = resourceLoader.getExtensions();
modelRuntime.resetSessionExtensionProviders();
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
try {
modelRuntime.registerProvider(name, config);
modelRuntime.registerSessionExtensionProvider(name, config);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diagnostics.push({
@ -167,7 +168,7 @@ export async function createAgentSessionServices(
extensionsResult.runtime.pendingProviderRegistrations = [];
for (const { provider, extensionPath } of extensionsResult.runtime.pendingNativeProviderRegistrations) {
try {
modelRuntime.registerNativeProvider(provider);
modelRuntime.registerSessionNativeExtensionProvider(provider);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diagnostics.push({

View file

@ -256,6 +256,11 @@ export interface ModelCycleResult {
isScoped: boolean;
}
export interface ReloadResult {
/** User-facing diagnostic when reload removed the active model. */
modelFallbackMessage?: string;
}
/** Session statistics for /session command */
export interface SessionStats {
sessionFile: string | undefined;
@ -2316,16 +2321,45 @@ export class AgentSession {
private _refreshCurrentModelFromRegistry(): void {
const currentModel = this.model;
if (!currentModel) {
return;
}
if (!currentModel) return;
const refreshedModel = this._modelRuntime.getModel(currentModel.provider, currentModel.id);
if (!refreshedModel || refreshedModel === currentModel) {
return;
if (refreshedModel && refreshedModel !== currentModel) this.agent.state.model = refreshedModel;
}
private _reconcileModelAfterReload(previousModel: Model<any> | undefined): string | undefined {
this._scopedModels = this._scopedModels.flatMap((scoped) => {
const model = this._modelRuntime.getModel(scoped.model.provider, scoped.model.id);
return model ? [{ ...scoped, model }] : [];
});
if (!previousModel) return undefined;
const refreshedModel = this._modelRuntime.getModel(previousModel.provider, previousModel.id);
if (refreshedModel) {
this.agent.state.model = refreshedModel;
return undefined;
}
this.agent.state.model = refreshedModel;
const fallback = this._modelRuntime.getAvailableSnapshot()[0];
if (fallback) {
this.agent.state.model = fallback;
this.agent.state.thinkingLevel = clampThinkingLevel(fallback, this.thinkingLevel) as ThinkingLevel;
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.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.`;
}
private _bindExtensionCore(runner: ExtensionRunner): void {
@ -2436,15 +2470,15 @@ export class AgentSession {
},
{
registerProvider: (name, config) => {
this._modelRuntime.registerProvider(name, config);
this._modelRuntime.registerSessionExtensionProvider(name, config);
this._refreshCurrentModelFromRegistry();
},
registerNativeProvider: (provider) => {
this._modelRuntime.registerNativeProvider(provider);
this._modelRuntime.registerSessionNativeExtensionProvider(provider);
this._refreshCurrentModelFromRegistry();
},
unregisterProvider: (name) => {
this._modelRuntime.unregisterProvider(name);
this._modelRuntime.unregisterSessionExtensionProvider(name);
this._refreshCurrentModelFromRegistry();
},
},
@ -2598,15 +2632,28 @@ export class AgentSession {
});
}
async reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void> {
const previousFlagValues = this._extensionRunner.getFlagValues();
await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
async reload(options?: {
beforeExtensionInvalidate?: () => void;
beforeSessionStart?: () => void | Promise<void>;
}): Promise<ReloadResult> {
const oldRunner = this._extensionRunner;
const previousFlagValues = oldRunner.getFlagValues();
const previousActiveToolNames = this.getActiveToolNames();
const previousModel = this.model;
await emitSessionShutdownEvent(oldRunner, { type: "session_shutdown", reason: "reload" });
try {
options?.beforeExtensionInvalidate?.();
} finally {
oldRunner.invalidate();
}
await this.settingsManager.reload();
this.syncQueueModesFromSettings();
resetApiProviders();
this._modelRuntime.resetSessionExtensionProviders();
await this._resourceLoader.reload();
this._buildRuntime({
activeToolNames: this.getActiveToolNames(),
activeToolNames: previousActiveToolNames,
flagValues: previousFlagValues,
includeAllExtensionTools: true,
});
@ -2621,6 +2668,8 @@ export class AgentSession {
await this._extensionRunner.emit({ type: "session_start", reason: "reload" });
await this.extendResourcesFromExtensions("reload");
}
await this._modelRuntime.refresh({ allowNetwork: false });
return { modelFallbackMessage: this._reconcileModelAfterReload(previousModel) };
}
// =========================================================================

View file

@ -172,6 +172,7 @@ export function createExtensionRuntime(): ExtensionRuntime {
throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading.");
};
const state: { staleMessage?: string } = {};
const eventBusUnsubscribers = new Set<() => void>();
const assertActive = () => {
if (state.staleMessage) {
throw new Error(state.staleMessage);
@ -199,9 +200,23 @@ export function createExtensionRuntime(): ExtensionRuntime {
pendingNativeProviderRegistrations: [],
assertActive,
invalidate: (message) => {
state.staleMessage ??=
if (state.staleMessage) return;
state.staleMessage =
message ??
"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().";
for (const unsubscribe of eventBusUnsubscribers) unsubscribe();
eventBusUnsubscribers.clear();
},
trackEventBusSubscription: (unsubscribe) => {
let active = true;
const trackedUnsubscribe = () => {
if (!active) return;
active = false;
eventBusUnsubscribers.delete(trackedUnsubscribe);
unsubscribe();
};
eventBusUnsubscribers.add(trackedUnsubscribe);
return trackedUnsubscribe;
},
// Pre-bind: queue registrations so bindCore() can flush them once the
// model registry is available. bindCore() replaces both with direct calls.
@ -386,7 +401,16 @@ function createExtensionAPI(
runtime.unregisterProvider(name, extension.path);
},
events: eventBus,
events: {
emit(channel, data) {
runtime.assertActive();
eventBus.emit(channel, data);
},
on(channel, handler) {
runtime.assertActive();
return runtime.trackEventBusSubscription(eventBus.on(channel, handler));
},
},
} as ExtensionAPI;
return api;

View file

@ -1577,6 +1577,8 @@ export interface ExtensionRuntimeState {
assertActive: () => void;
/** Marks this extension instance as stale after runtime replacement or reload. */
invalidate: (message?: string) => void;
/** Track an event-bus subscription so invalidating this runtime removes it. */
trackEventBusSubscription: (unsubscribe: () => void) => () => void;
/**
* Register or unregister a provider.
*

View file

@ -98,6 +98,10 @@ export class ModelRuntime implements Models {
private readonly builtins = new Map<string, Provider>();
private readonly nativeExtensionProviders = new Map<string, Provider>();
private readonly extensionProviders = new Map<string, ProviderConfigInput>();
private readonly sessionExtensionProviders = new Map<
string,
{ nativeProvider?: Provider; config?: ProviderConfigInput }
>();
private readonly compositionErrors = new Map<string, string>();
private readonly modelsPath: string | undefined;
private readonly modelNetworkEnabled: boolean;
@ -194,12 +198,26 @@ export class ModelRuntime implements Models {
...this.nativeExtensionProviders.keys(),
...this.config.getProviderIds(),
...this.extensionProviders.keys(),
...this.sessionExtensionProviders.keys(),
]);
}
private getEffectiveRegisteredProvider(providerId: string): {
nativeProvider?: Provider;
config?: ProviderConfigInput;
} {
const sessionProvider = this.sessionExtensionProviders.get(providerId);
if (sessionProvider) return sessionProvider;
return {
nativeProvider: this.nativeExtensionProviders.get(providerId),
config: this.extensionProviders.get(providerId),
};
}
private recomposeProvider(providerId: string): void {
const base = this.nativeExtensionProviders.get(providerId) ?? this.builtins.get(providerId);
const extension = this.extensionProviders.get(providerId);
const registered = this.getEffectiveRegisteredProvider(providerId);
const base = registered.nativeProvider ?? this.builtins.get(providerId);
const extension = registered.config;
if (!base && !this.config.getProvider(providerId) && !extension) {
this.models.deleteProvider(providerId);
this.compositionErrors.delete(providerId);
@ -343,15 +361,18 @@ export class ModelRuntime implements Models {
}
getRegisteredProviderConfig(providerId: string): ProviderConfigInput | undefined {
return this.extensionProviders.get(providerId);
return this.getEffectiveRegisteredProvider(providerId).config;
}
getRegisteredProviderIds(): readonly string[] {
return [...new Set([...this.extensionProviders.keys(), ...this.nativeExtensionProviders.keys()])];
return [...this.providerIds()].filter((providerId) => {
const registered = this.getEffectiveRegisteredProvider(providerId);
return registered.config !== undefined || registered.nativeProvider !== undefined;
});
}
getRegisteredNativeProvider(providerId: string): Provider | undefined {
return this.nativeExtensionProviders.get(providerId);
return this.getEffectiveRegisteredProvider(providerId).nativeProvider;
}
/** @internal Compatibility fallback for ModelRegistry when provider auth is unconfigured. */
@ -359,7 +380,7 @@ export class ModelRuntime implements Models {
return resolveCompatibilityRequestConfig(
model,
this.config.getProvider(model.provider),
this.extensionProviders.get(model.provider),
this.getEffectiveRegisteredProvider(model.provider).config,
);
}
@ -383,7 +404,7 @@ export class ModelRuntime implements Models {
const configuredHeaders = resolveConfiguredModelHeaders(
providerOrModel,
this.config.getProvider(providerOrModel.provider),
this.extensionProviders.get(providerOrModel.provider),
this.getEffectiveRegisteredProvider(providerOrModel.provider).config,
{ ...(resolution.env ?? {}), ...(overrides.env ?? {}) },
);
return {
@ -428,7 +449,7 @@ export class ModelRuntime implements Models {
if (this.snapshot.storedProviders.has(providerId)) return { configured: true, source: "stored" };
const configured = configuredRequestAuthStatus(
this.config.getProvider(providerId),
this.extensionProviders.get(providerId),
this.getEffectiveRegisteredProvider(providerId).config,
);
if (configured) return configured;
const check = this.snapshot.auth.get(providerId);
@ -590,4 +611,48 @@ export class ModelRuntime implements Models {
this.updateModelSnapshot();
void this.refresh({ allowNetwork: false });
}
/** Clear provider registrations owned by the current extension runtime. */
resetSessionExtensionProviders(): void {
const providerIds = [...this.sessionExtensionProviders.keys()];
this.sessionExtensionProviders.clear();
for (const providerId of providerIds) this.recomposeProvider(providerId);
this.updateModelSnapshot();
}
/** Register a provider in the current extension-runtime layer. */
registerSessionExtensionProvider(providerId: string, config: ProviderConfigInput): void {
const previous = this.getEffectiveRegisteredProvider(providerId).config;
validateExtensionProvider(
providerId,
this.getEffectiveRegisteredProvider(providerId).nativeProvider ?? this.builtins.get(providerId),
this.config.getProvider(providerId),
config,
);
const effective: ProviderConfigInput = { ...previous };
for (const [key, value] of Object.entries(config)) {
if (value !== undefined) (effective as Record<string, unknown>)[key] = value;
}
this.sessionExtensionProviders.set(providerId, { config: effective });
this.recomposeProvider(providerId);
this.updateModelSnapshot();
void this.refresh({ allowNetwork: false });
}
/** Register a native provider in the current extension-runtime layer. */
registerSessionNativeExtensionProvider(provider: Provider): void {
if (!provider.id.trim()) throw new Error("Provider id must not be empty.");
this.sessionExtensionProviders.set(provider.id, { nativeProvider: provider });
this.recomposeProvider(provider.id);
this.updateModelSnapshot();
void this.refresh({ allowNetwork: false });
}
/** Unregister a provider from the current extension-runtime layer. */
unregisterSessionExtensionProvider(providerId: string): void {
this.sessionExtensionProviders.set(providerId, {});
this.recomposeProvider(providerId);
this.updateModelSnapshot();
void this.refresh({ allowNetwork: false });
}
}

View file

@ -5427,8 +5427,10 @@ export class InteractiveMode {
this.showWarning("Wait for compaction to finish before reloading.");
return;
}
this.resetExtensionUI();
if (this.session.isBashRunning) {
this.showWarning("Wait for the bash command to finish before reloading.");
return;
}
const reloadBox = new Container();
const borderColor = (s: string) => theme.fg("border", s);
@ -5444,7 +5446,6 @@ export class InteractiveMode {
reloadBox.addChild(new Spacer(1));
reloadBox.addChild(new DynamicBorder(borderColor));
const previousEditor = this.editor;
this.editorContainer.clear();
this.editorContainer.addChild(reloadBox);
this.ui.setFocus(reloadBox);
@ -5471,7 +5472,16 @@ export class InteractiveMode {
};
try {
await this.session.reload({ beforeSessionStart: restoreChatBeforeSessionStart });
const reloadResult = await this.session.reload({
beforeExtensionInvalidate: () => {
this.resetExtensionUI();
this.editorContainer.clear();
this.editorContainer.addChild(reloadBox);
this.ui.setFocus(reloadBox);
this.ui.requestRender();
},
beforeSessionStart: restoreChatBeforeSessionStart,
});
restoreChatBeforeSessionStart();
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.keybindings.reload();
@ -5507,6 +5517,9 @@ export class InteractiveMode {
if (modelsJsonError) {
this.showError(`models.json error: ${modelsJsonError}`);
}
if (reloadResult.modelFallbackMessage) {
this.showWarning(reloadResult.modelFallbackMessage);
}
this.showStatus(
savedImplicitProjectTrust
? "Reloaded keybindings, extensions, skills, prompts, themes, and context files; saved project trust"
@ -5516,7 +5529,7 @@ export class InteractiveMode {
reloadBoxDismissed = true;
} catch (error) {
if (!reloadBoxDismissed) {
dismissReloadBox(previousEditor as Component);
dismissReloadBox(this.editor as Component);
}
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
}

View file

@ -176,4 +176,91 @@ describe("AgentSession dynamic provider registration", () => {
session.dispose();
});
it("replays duplicate provider registrations from a clean extension layer", async () => {
let generation = 1;
const session = await createSession([
(pi) => {
pi.registerProvider("reload-provider", {
baseUrl: `https://generation-${generation}.test`,
apiKey: "test-key",
api: "openai-completions",
...(generation === 1 ? { headers: { "x-stale": "yes" } } : {}),
models: [
{
id: `model-${generation}`,
name: `Model ${generation}`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
},
],
});
},
(pi) => {
pi.registerProvider("reload-provider", { name: `Winner ${generation}` });
},
]);
await session.modelRuntime.refresh({ allowNetwork: false });
expect(session.modelRuntime.getRegisteredProviderConfig("reload-provider")).toMatchObject({
name: "Winner 1",
headers: { "x-stale": "yes" },
});
expect(session.modelRuntime.getModel("reload-provider", "model-1")).toBeDefined();
generation = 2;
await session.reload();
expect(session.modelRuntime.getRegisteredProviderConfig("reload-provider")).toMatchObject({
name: "Winner 2",
baseUrl: "https://generation-2.test",
});
expect(session.modelRuntime.getRegisteredProviderConfig("reload-provider")?.headers).toBeUndefined();
expect(session.modelRuntime.getModel("reload-provider", "model-1")).toBeUndefined();
expect(session.modelRuntime.getModel("reload-provider", "model-2")).toBeDefined();
session.dispose();
});
it("removes disabled providers and falls back when their model was active", 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 });
const temporaryModel = session.modelRuntime.getModel("temporary-provider", "temporary-model")!;
await session.setModel(temporaryModel);
enabled = false;
const result = await session.reload();
expect(session.modelRuntime.getProvider("temporary-provider")).toBeUndefined();
expect(session.modelRuntime.getModel("temporary-provider", "temporary-model")).toBeUndefined();
expect(session.model).toBeDefined();
expect(session.model?.provider).not.toBe("temporary-provider");
expect(result.modelFallbackMessage).toContain(
'Active model "temporary-provider/temporary-model" is no longer available after reload.',
);
session.dispose();
});
});

View file

@ -8,7 +8,13 @@ import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { createExtensionRuntime, discoverAndLoadExtensions, loadExtensions } from "../src/core/extensions/loader.ts";
import { createEventBus } from "../src/core/event-bus.ts";
import {
createExtensionRuntime,
discoverAndLoadExtensions,
loadExtensionFromFactory,
loadExtensions,
} from "../src/core/extensions/loader.ts";
import { ExtensionRunner, emitProjectTrustEvent } from "../src/core/extensions/runner.ts";
import type {
ExtensionActions,
@ -817,6 +823,60 @@ describe("ExtensionRunner", () => {
});
});
describe("shared event bus", () => {
it("removes listeners owned by an invalidated runtime without breaking replacement communication", async () => {
const eventBus = createEventBus();
const oldRuntime = createExtensionRuntime();
let oldApi: Parameters<Parameters<typeof loadExtensionFromFactory>[0]>[0] | undefined;
let peerApi: Parameters<Parameters<typeof loadExtensionFromFactory>[0]>[0] | undefined;
let oldCalls = 0;
const oldExtension = await loadExtensionFromFactory(
(pi) => {
oldApi = pi;
pi.events.on("reload:test", () => oldCalls++);
},
tempDir,
eventBus,
oldRuntime,
"<old>",
);
await loadExtensionFromFactory(
(pi) => {
peerApi = pi;
},
tempDir,
eventBus,
oldRuntime,
"<peer>",
);
const oldRunner = new ExtensionRunner([oldExtension], oldRuntime, tempDir, sessionManager, modelRegistry);
peerApi!.events.emit("reload:test", undefined);
expect(oldCalls).toBe(1);
oldRunner.invalidate();
expect(() => oldApi!.events.emit("reload:test", undefined)).toThrow(
"stale after session replacement or reload",
);
const newRuntime = createExtensionRuntime();
let newApi: typeof oldApi;
let newCalls = 0;
await loadExtensionFromFactory(
(pi) => {
newApi = pi;
pi.events.on("reload:test", () => newCalls++);
},
tempDir,
eventBus,
newRuntime,
"<new>",
);
newApi!.events.emit("reload:test", undefined);
expect(oldCalls).toBe(1);
expect(newCalls).toBe(1);
});
});
describe("provider registration", () => {
it("bindCore ignores invalid queued registrations and reports extension error", async () => {
const runtime = createExtensionRuntime();

View file

@ -2,7 +2,8 @@ import type { AgentTool, ThinkingLevel } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall, type Model, type Usage } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import type { BuildSystemPromptOptions, ExtensionAPI } from "../../src/index.ts";
import type { BuildSystemPromptOptions, ExtensionAPI, ExtensionFactory, ResourceLoader } from "../../src/index.ts";
import { createTestExtensionsResult } from "../utilities.ts";
import { createHarness, getAssistantTexts, type Harness } from "./harness.ts";
describe("AgentSession model and extension characterization", () => {
@ -394,4 +395,70 @@ describe("AgentSession model and extension characterization", () => {
expect(lifecycleEvents).toEqual(["start:startup", "shutdown:reload", "start:reload"]);
});
it("invalidates the old runtime after host cleanup and removes disabled extension resources", async () => {
let enabled = true;
const phases: string[] = [];
const factory: ExtensionFactory = (pi) => {
if (!enabled) return;
pi.on("session_shutdown", () => {
phases.push("shutdown");
});
pi.on("input", () => ({ action: "continue" }));
pi.registerCommand("removed-command", { handler: async () => {} });
pi.registerShortcut("ctrl+shift+y", { handler: () => {} });
pi.registerMessageRenderer("removed-message", () => undefined);
pi.registerEntryRenderer("removed-entry", () => undefined);
pi.registerTool({
name: "removed_tool",
label: "Removed Tool",
description: "Removed on reload",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
});
};
let extensionsResult = await createTestExtensionsResult([factory]);
const resourceLoader: ResourceLoader = {
getExtensions: () => extensionsResult,
getSkills: () => ({ skills: [], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined,
getAppendSystemPrompt: () => [],
extendResources: () => {},
reload: async () => {
extensionsResult = await createTestExtensionsResult([factory]);
},
};
const harness = await createHarness({ resourceLoader });
harnesses.push(harness);
await harness.session.bindExtensions({ shutdownHandler: () => {} });
const oldRunner = harness.session.extensionRunner;
const oldContext = oldRunner.createContext();
expect(oldRunner.getCommand("removed-command")).toBeDefined();
expect(harness.session.getAllTools().map((tool) => tool.name)).toContain("removed_tool");
expect(oldRunner.hasHandlers("input")).toBe(true);
expect(oldRunner.getMessageRenderer("removed-message")).toBeDefined();
expect(oldRunner.getEntryRenderer("removed-entry")).toBeDefined();
enabled = false;
await harness.session.reload({
beforeExtensionInvalidate: () => {
phases.push("host-ui-cleanup");
expect(oldContext.cwd).toBe(harness.tempDir);
},
});
expect(phases).toEqual(["shutdown", "host-ui-cleanup"]);
expect(() => oldContext.cwd).toThrow("stale after session replacement or reload");
expect(harness.session.extensionRunner.getCommand("removed-command")).toBeUndefined();
expect(harness.session.getAllTools().map((tool) => tool.name)).not.toContain("removed_tool");
expect(harness.session.getActiveToolNames()).not.toContain("removed_tool");
expect(harness.session.extensionRunner.hasHandlers("input")).toBe(false);
expect(harness.session.extensionRunner.getShortcuts({}).size).toBe(0);
expect(harness.session.extensionRunner.getMessageRenderer("removed-message")).toBeUndefined();
expect(harness.session.extensionRunner.getEntryRenderer("removed-entry")).toBeUndefined();
});
});

View file

@ -37,10 +37,16 @@ type ApplyContext = {
showWarning: (message: string) => void;
};
type ReloadBlockedContext = {
session: { isStreaming: boolean; isCompacting: boolean; isBashRunning: boolean };
showWarning: (message: string) => void;
};
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>;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrivate;
@ -123,6 +129,17 @@ describe("interactive session loadout restore", () => {
});
});
describe("interactive reload blocking", () => {
it("blocks ordinary reload while direct user bash is running", async () => {
const showWarning = vi.fn();
await interactiveModePrototype.handleReloadCommand.call({
session: { isStreaming: false, isCompacting: false, isBashRunning: true },
showWarning,
});
expect(showWarning).toHaveBeenCalledWith("Wait for the bash command to finish before reloading.");
});
});
describe("interactive /loadout application", () => {
it("persists a deliberate change, reloads once, and reports unmatched resources", async () => {
const harness = await createTestHarness();