refactor(coding-agent): replace AgentSessionRuntimeHost with closure-based AgentSessionRuntime

- Replace AgentSessionRuntimeHost and bootstrap abstractions with AgentSessionRuntime
- Runtime creation is now closure-based via CreateAgentSessionRuntimeFactory
- Factory closes over process-global fixed inputs, recreates cwd-bound services per effective cwd
- Session config (model, thinking, tools, scoped models) re-resolved per target cwd
- CLI resource paths resolved once at startup as absolute paths
- Swap lifecycle: teardown old, create next, apply next (hard fail on creation error)
- Unified diagnostics model (info/warning/error) for args, services, session resolution, resources
- No logging or process exits inside creation/parsing logic
- Removed session_directory support
- Removed session_switch and session_fork extension events (use session_start with reason)
- Moved package/config CLI to package-manager-cli.ts
- Fixed theme init for --resume session picker
- Fixed flaky reftable footer test (content-based polling)
- Fixed silent drop of unknown single-dash CLI flags
- Added error diagnostics for missing explicit CLI resource paths
- Updated SDK docs, examples, plans, exports, tests, changelog

fixes #2753
This commit is contained in:
Mario Zechner
2026-04-03 20:14:12 +02:00
parent 042066b982
commit 9f9277ccdd
38 changed files with 2180 additions and 1366 deletions

View File

@@ -14,9 +14,11 @@ import { getModel } from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { AgentSession } from "../src/core/agent-session.js";
import {
type AgentSessionRuntimeHost,
type AgentSessionRuntime,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
AgentSessionRuntimeHost as RuntimeHost,
createAgentSessionServices,
} from "../src/core/agent-session-runtime.js";
import { AuthStorage } from "../src/core/auth-storage.js";
import { SessionManager } from "../src/core/session-manager.js";
@@ -25,7 +27,7 @@ import { API_KEY } from "./utilities.js";
describe.skipIf(!API_KEY)("AgentSession forking", () => {
let session: AgentSession;
let runtimeHost: AgentSessionRuntimeHost;
let runtimeHost: AgentSessionRuntime;
let tempDir: string;
let sessionManager: SessionManager;
@@ -50,23 +52,38 @@ describe.skipIf(!API_KEY)("AgentSession forking", () => {
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
authStorage.setRuntimeApiKey("anthropic", API_KEY!);
const bootstrap = {
const servicesOptions = {
agentDir: tempDir,
authStorage,
model,
tools: codingTools,
resourceLoader: {
resourceLoaderOptions: {
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
...servicesOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model,
tools: codingTools,
})),
services,
diagnostics: services.diagnostics,
};
};
runtimeHost = await createAgentSessionRuntime(createRuntime, {
cwd: tempDir,
agentDir: tempDir,
sessionManager,
});
runtimeHost = new RuntimeHost(bootstrap, runtime);
session = runtimeHost.session;
session.subscribe(() => {});
return session;

View File

@@ -4,9 +4,10 @@ import { join } from "node:path";
import { fauxAssistantMessage, registerFauxProvider } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
} from "../src/core/agent-session-runtime.js";
import { AuthStorage } from "../src/core/auth-storage.js";
import { SessionManager } from "../src/core/session-manager.js";
@@ -19,7 +20,7 @@ import type {
type RecordedSessionEvent = SessionBeforeSwitchEvent | SessionBeforeForkEvent | SessionStartEvent;
describe("AgentSessionRuntimeHost session lifecycle events", () => {
describe("AgentSessionRuntime session lifecycle events", () => {
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
@@ -38,22 +39,38 @@ describe("AgentSessionRuntimeHost session lifecycle events", () => {
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const bootstrap: AgentSessionRuntimeBootstrap = {
const runtimeOptions = {
agentDir: tempDir,
authStorage,
model: faux.getModel(),
resourceLoader: {
resourceLoaderOptions: {
extensionFactories: [extensionFactory],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
...runtimeOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model: faux.getModel(),
})),
services,
diagnostics: services.diagnostics,
};
};
const runtimeHost = await createAgentSessionRuntime(createRuntime, {
cwd: tempDir,
agentDir: tempDir,
sessionManager: SessionManager.create(tempDir),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runtimeHost.session.bindExtensions({});
cleanups.push(async () => {

View File

@@ -270,9 +270,20 @@ describe("parseArgs", () => {
expect(result.messages).toEqual(["explain this"]);
});
test("ignores unknown flags starting with -", () => {
test("captures unknown long flags with string values", () => {
const result = parseArgs(["--unknown-flag", "message"]);
expect(result.messages).toEqual(["message"]);
expect(result.messages).toEqual([]);
expect(result.unknownFlags.get("unknown-flag")).toBe("message");
});
test("captures unknown boolean long flags", () => {
const result = parseArgs(["--unknown-flag"]);
expect(result.unknownFlags.get("unknown-flag")).toBe(true);
});
test("captures unknown long flags with equals syntax", () => {
const result = parseArgs(["--unknown-flag=value"]);
expect(result.unknownFlags.get("unknown-flag")).toBe("value");
});
});

View File

@@ -7,6 +7,7 @@ function createArgs(messages: string[] = []): Args {
messages: [...messages],
fileArgs: [],
unknownFlags: new Map(),
diagnostics: [],
};
}

View File

@@ -2,7 +2,9 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { KeybindingsManager, migrateKeybindingsConfigFile } from "../src/core/keybindings.js";
import { ENV_AGENT_DIR } from "../src/config.js";
import { KeybindingsManager } from "../src/core/keybindings.js";
import { runMigrations } from "../src/migrations.js";
describe("keybindings migration", () => {
const tempDirs: string[] = [];
@@ -25,8 +27,14 @@ describe("keybindings migration", () => {
cursorUp: ["up", "ctrl+p"],
expandTools: "ctrl+x",
});
expect(migrateKeybindingsConfigFile(agentDir)).toBe(true);
const previousAgentDir = process.env[ENV_AGENT_DIR];
process.env[ENV_AGENT_DIR] = agentDir;
runMigrations(agentDir);
if (previousAgentDir === undefined) {
delete process.env[ENV_AGENT_DIR];
} else {
process.env[ENV_AGENT_DIR] = previousAgentDir;
}
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "keybindings.json"), "utf-8")) as Record<
string,
@@ -43,8 +51,14 @@ describe("keybindings migration", () => {
expandTools: "ctrl+x",
"app.tools.expand": "ctrl+y",
});
expect(migrateKeybindingsConfigFile(agentDir)).toBe(true);
const previousAgentDir = process.env[ENV_AGENT_DIR];
process.env[ENV_AGENT_DIR] = agentDir;
runMigrations(agentDir);
if (previousAgentDir === undefined) {
delete process.env[ENV_AGENT_DIR];
} else {
process.env[ENV_AGENT_DIR] = previousAgentDir;
}
const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "keybindings.json"), "utf-8")) as Record<
string,

View File

@@ -1,158 +0,0 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR } from "../src/config.js";
const mocks = vi.hoisted(() => ({
state: {
hookSessionDir: undefined as string | undefined,
capturedSessionDir: undefined as string | undefined,
},
createAgentSession: vi.fn(async (options: { sessionManager?: { getSessionDir(): string } }) => {
mocks.state.capturedSessionDir = options.sessionManager?.getSessionDir();
return {
session: {
model: { id: "test-model", provider: "test", reasoning: false },
thinkingLevel: "off",
setThinkingLevel: vi.fn(),
},
modelFallbackMessage: undefined,
};
}),
runPrintMode: vi.fn(async () => 0),
selectSession: vi.fn(),
}));
vi.mock("../src/core/sdk.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
createAgentSession: mocks.createAgentSession,
};
});
vi.mock("../src/modes/index.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
runPrintMode: mocks.runPrintMode,
};
});
vi.mock("../src/cli/session-picker.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
selectSession: mocks.selectSession,
};
});
vi.mock("../src/core/resource-loader.js", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
DefaultResourceLoader: class {
async reload(): Promise<void> {}
getExtensions() {
const handlers = new Map();
if (mocks.state.hookSessionDir) {
handlers.set("session_directory", [async () => ({ sessionDir: mocks.state.hookSessionDir })]);
}
return {
extensions: [{ path: "/mock-extension.ts", handlers, flags: new Map() }],
errors: [],
runtime: {
pendingProviderRegistrations: [],
flagValues: new Map(),
},
};
}
},
};
});
describe("sessionDir precedence", () => {
let tempDir: string;
let agentDir: string;
let projectDir: string;
let originalCwd: string;
let originalAgentDir: string | undefined;
let originalExitCode: typeof process.exitCode;
let originalIsTTY: boolean | undefined;
beforeEach(() => {
vi.resetModules();
mocks.state.hookSessionDir = "./hook-sessions";
mocks.state.capturedSessionDir = undefined;
mocks.createAgentSession.mockClear();
mocks.runPrintMode.mockClear();
mocks.selectSession.mockReset();
tempDir = join(tmpdir(), `pi-session-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
projectDir = join(tempDir, "project");
mkdirSync(agentDir, { recursive: true });
mkdirSync(join(projectDir, ".pi"), { recursive: true });
originalCwd = process.cwd();
originalAgentDir = process.env[ENV_AGENT_DIR];
originalExitCode = process.exitCode;
originalIsTTY = process.stdin.isTTY;
process.exitCode = undefined;
process.env[ENV_AGENT_DIR] = agentDir;
process.chdir(projectDir);
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
});
afterEach(() => {
process.chdir(originalCwd);
process.exitCode = originalExitCode;
Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true });
if (originalAgentDir === undefined) {
delete process.env[ENV_AGENT_DIR];
} else {
process.env[ENV_AGENT_DIR] = originalAgentDir;
}
rmSync(tempDir, { recursive: true, force: true });
});
it("prefers settings sessionDir over the session_directory hook for new sessions", async () => {
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ sessionDir: "./settings-sessions" }));
const { main } = await import("../src/main.js");
await main(["--print", "test prompt"]);
expect(mocks.state.capturedSessionDir).toBe("./settings-sessions");
expect(mocks.runPrintMode).toHaveBeenCalledOnce();
});
it("prefers CLI --session-dir over settings and the session_directory hook", async () => {
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ sessionDir: "./settings-sessions" }));
const { main } = await import("../src/main.js");
await main(["--print", "--session-dir", "./cli-sessions", "test prompt"]);
expect(mocks.state.capturedSessionDir).toBe("./cli-sessions");
expect(mocks.runPrintMode).toHaveBeenCalledOnce();
});
it("uses settings sessionDir ahead of the session_directory hook for --resume", async () => {
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ sessionDir: "./settings-sessions" }));
const { SessionManager } = await import("../src/core/session-manager.js");
const listSpy = vi.spyOn(SessionManager, "list");
mocks.selectSession.mockImplementation(async (listCurrent: (onProgress: () => void) => Promise<unknown>) => {
await listCurrent(() => {});
return join(projectDir, "picked-session.jsonl");
});
const { main } = await import("../src/main.js");
await main(["--print", "--resume"]);
expect(listSpy).toHaveBeenCalledWith(expect.any(String), "./settings-sessions", expect.any(Function));
expect(mocks.state.capturedSessionDir).toBe("./settings-sessions");
expect(mocks.runPrintMode).toHaveBeenCalledOnce();
});
});

View File

@@ -155,7 +155,7 @@ describe("SettingsManager", () => {
});
describe("reload", () => {
it("should reload global settings from disk", () => {
it("should reload global settings from disk", async () => {
const settingsPath = join(agentDir, "settings.json");
writeFileSync(
settingsPath,
@@ -176,21 +176,21 @@ describe("SettingsManager", () => {
}),
);
manager.reload();
await manager.reload();
expect(manager.getTheme()).toBe("light");
expect(manager.getExtensionPaths()).toEqual(["/after.ts"]);
expect(manager.getDefaultModel()).toBe("claude-sonnet");
});
it("should keep previous settings when file is invalid", () => {
it("should keep previous settings when file is invalid", async () => {
const settingsPath = join(agentDir, "settings.json");
writeFileSync(settingsPath, JSON.stringify({ theme: "dark" }));
const manager = SettingsManager.create(projectDir, agentDir);
writeFileSync(settingsPath, "{ invalid json");
manager.reload();
await manager.reload();
expect(manager.getTheme()).toBe("dark");
});

View File

@@ -1,6 +1,6 @@
# Coding agent suite tests
Use `test/suite/` for the new harness-based test suite around `AgentSession` and `AgentSessionRuntimeHost`.
Use `test/suite/` for the new harness-based test suite around `AgentSession` and `AgentSessionRuntime`.
Rules:
- Use `test/suite/harness.ts`

View File

@@ -4,13 +4,15 @@ import { join } from "node:path";
import { fauxAssistantMessage, registerFauxProvider } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
} from "../../src/core/agent-session-runtime.js";
import { AuthStorage } from "../../src/core/auth-storage.js";
import { SessionManager } from "../../src/core/session-manager.js";
import type {
ExtensionAPI,
ExtensionFactory,
SessionBeforeForkEvent,
SessionBeforeSwitchEvent,
@@ -19,7 +21,7 @@ import type {
type RecordedSessionEvent = SessionBeforeSwitchEvent | SessionBeforeForkEvent | SessionStartEvent;
describe("AgentSessionRuntimeHost characterization", () => {
describe("AgentSessionRuntime characterization", () => {
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
@@ -48,14 +50,14 @@ describe("AgentSessionRuntimeHost characterization", () => {
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const bootstrap: AgentSessionRuntimeBootstrap = {
const runtimeOptions = {
agentDir: tempDir,
authStorage,
model: options?.bootstrapModel === false ? undefined : faux.getModel(),
thinkingLevel: options?.bootstrapThinkingLevel === false ? undefined : undefined,
resourceLoader: {
resourceLoaderOptions: {
extensionFactories: [
(pi) => {
(pi: ExtensionAPI) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
@@ -79,11 +81,28 @@ describe("AgentSessionRuntimeHost characterization", () => {
noThemes: true,
},
};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
...runtimeOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model: runtimeOptions.model,
thinkingLevel: runtimeOptions.thinkingLevel,
})),
services,
diagnostics: services.diagnostics,
};
};
const runtimeHost = await createAgentSessionRuntime(createRuntime, {
cwd: tempDir,
agentDir: tempDir,
sessionManager: SessionManager.create(tempDir),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runtimeHost.session.bindExtensions({});
cleanups.push(async () => {
@@ -99,7 +118,7 @@ describe("AgentSessionRuntimeHost characterization", () => {
it("emits session_before_switch and session_start for new and resume flows", async () => {
const events: RecordedSessionEvent[] = [];
const { runtimeHost } = await createRuntimeHost((pi) => {
const { runtimeHost } = await createRuntimeHost((pi: ExtensionAPI) => {
pi.on("session_before_switch", (event) => {
events.push(event);
});
@@ -140,7 +159,7 @@ describe("AgentSessionRuntimeHost characterization", () => {
it("honors session_before_switch cancellation for new and resume", async () => {
const events: RecordedSessionEvent[] = [];
let cancelReason: "new" | "resume" | undefined;
const { runtimeHost } = await createRuntimeHost((pi) => {
const { runtimeHost } = await createRuntimeHost((pi: ExtensionAPI) => {
pi.on("session_before_switch", (event) => {
events.push(event);
if (event.reason === cancelReason) {
@@ -175,7 +194,7 @@ describe("AgentSessionRuntimeHost characterization", () => {
it("emits session_before_fork and session_start and honors cancellation", async () => {
const events: RecordedSessionEvent[] = [];
let cancelNextFork = false;
const { runtimeHost } = await createRuntimeHost((pi) => {
const { runtimeHost } = await createRuntimeHost((pi: ExtensionAPI) => {
pi.on("session_before_fork", (event) => {
events.push(event);
if (cancelNextFork) {
@@ -222,39 +241,60 @@ describe("AgentSessionRuntimeHost characterization", () => {
const { runtimeHost, faux, tempDir } = await createRuntimeHost(() => {}, { cwd: firstDir });
const otherAuthStorage = AuthStorage.inMemory();
otherAuthStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const otherRuntime = await createAgentSessionRuntime(
{
agentDir: tempDir,
authStorage: otherAuthStorage,
resourceLoader: {
extensionFactories: [
(pi) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
const otherRuntimeOptions = {
agentDir: tempDir,
authStorage: otherAuthStorage,
resourceLoaderOptions: {
extensionFactories: [
(pi: ExtensionAPI) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
{ cwd: secondDir, sessionManager: SessionManager.create(secondDir) },
);
};
const createOtherRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd,
sessionManager,
sessionStartEvent,
}) => {
const services = await createAgentSessionServices({
...otherRuntimeOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
})),
services,
diagnostics: services.diagnostics,
};
};
const otherRuntime = await createAgentSessionRuntime(createOtherRuntime, {
cwd: secondDir,
agentDir: tempDir,
sessionManager: SessionManager.create(secondDir),
});
cleanups.push(async () => {
otherRuntime.session.dispose();
await otherRuntime.dispose();
});
await otherRuntime.session.prompt("other");
const otherSessionFile = otherRuntime.session.sessionFile!;
@@ -274,39 +314,60 @@ describe("AgentSessionRuntimeHost characterization", () => {
mkdirSync(otherDir, { recursive: true });
const otherAuthStorage = AuthStorage.inMemory();
otherAuthStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const otherRuntime = await createAgentSessionRuntime(
{
agentDir: tempDir,
authStorage: otherAuthStorage,
resourceLoader: {
extensionFactories: [
(pi) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
const otherRuntimeOptions = {
agentDir: tempDir,
authStorage: otherAuthStorage,
resourceLoaderOptions: {
extensionFactories: [
(pi: ExtensionAPI) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
{ cwd: otherDir, sessionManager: SessionManager.create(otherDir) },
);
};
const createOtherRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd,
sessionManager,
sessionStartEvent,
}) => {
const services = await createAgentSessionServices({
...otherRuntimeOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
})),
services,
diagnostics: services.diagnostics,
};
};
const otherRuntime = await createAgentSessionRuntime(createOtherRuntime, {
cwd: otherDir,
agentDir: tempDir,
sessionManager: SessionManager.create(otherDir),
});
cleanups.push(async () => {
otherRuntime.session.dispose();
await otherRuntime.dispose();
});
await otherRuntime.session.setModel(faux.getModel("faux-2")!);
otherRuntime.session.setThinkingLevel("off");

View File

@@ -0,0 +1,100 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerFauxProvider } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import {
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
} from "../../../src/core/agent-session-runtime.js";
import { AuthStorage } from "../../../src/core/auth-storage.js";
import { SessionManager } from "../../../src/core/session-manager.js";
describe("issue #2753 reload stale resource settings", () => {
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length > 0) {
cleanups.pop()?.();
}
});
it("applies updated top-level prompt settings on reload after startup", async () => {
const tempDir = join(tmpdir(), `pi-2753-${Date.now()}-${Math.random().toString(36).slice(2)}`);
const agentDir = join(tempDir, "agent");
const promptsDir = join(agentDir, "prompts");
mkdirSync(promptsDir, { recursive: true });
writeFileSync(join(promptsDir, "test.md"), "Echo test prompt\n");
const faux = registerFauxProvider({
models: [{ id: "faux-1", reasoning: false }],
});
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
cwd,
agentDir,
authStorage,
resourceLoaderOptions: {
extensionFactories: [
(pi) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noThemes: true,
},
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model: faux.getModel(),
})),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: tempDir,
agentDir,
sessionManager: SessionManager.create(tempDir),
});
cleanups.push(() => {
runtime.session.dispose();
faux.unregister();
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
expect(runtime.session.promptTemplates.map((prompt) => prompt.name)).toContain("test");
writeFileSync(join(agentDir, "settings.json"), `${JSON.stringify({ prompts: ["-prompts/test.md"] }, null, 2)}\n`);
await runtime.session.reload();
expect(runtime.services.settingsManager.getGlobalSettings().prompts).toEqual(["-prompts/test.md"]);
expect(runtime.session.promptTemplates.map((prompt) => prompt.name)).not.toContain("test");
});
});