feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
@@ -0,0 +1,95 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, type Harness } from "../harness.ts";
function createEchoTool(): AgentTool {
return {
name: "echo",
label: "Echo",
description: "Echo text back",
parameters: Type.Object({ text: Type.String() }),
execute: async (_toolCallId, params) => {
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
return { content: [{ type: "text", text }], details: { text } };
},
};
}
describe("regressions #1717/#2113: agent session event settlement", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("keeps persisted assistant/toolResult message order when extension message_end handlers yield", async () => {
const harness = await createHarness({
tools: [createEchoTool()],
extensionFactories: [
(pi) => {
pi.on("message_end", async (event) => {
if (event.message.role === "assistant") {
await new Promise((resolve) => setTimeout(resolve, 20));
}
});
},
],
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("echo", { text: "one" }), fauxToolCall("echo", { text: "two" })], {
stopReason: "toolUse",
}),
fauxAssistantMessage("done"),
]);
await harness.session.prompt("run tools");
const branchMessages = harness.sessionManager
.getBranch()
.filter((entry) => entry.type === "message")
.map((entry) => entry.message);
expect(branchMessages.map((message) => message.role)).toEqual([
"user",
"assistant",
"toolResult",
"toolResult",
"assistant",
]);
const firstToolResultIndex = branchMessages.findIndex((message) => message.role === "toolResult");
expect(firstToolResultIndex).toBeGreaterThan(0);
expect(branchMessages[firstToolResultIndex - 1]?.role).toBe("assistant");
});
it("runs tool_call handlers after the assistant tool-use message is settled in the session", async () => {
let harness: Harness;
const branchRolesAtToolCall: string[][] = [];
harness = await createHarness({
tools: [createEchoTool()],
extensionFactories: [
(pi) => {
pi.on("tool_call", () => {
branchRolesAtToolCall.push(
harness.sessionManager
.getBranch()
.filter((entry) => entry.type === "message")
.map((entry) => entry.message.role),
);
});
},
],
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
]);
await harness.session.prompt("run tool");
expect(branchRolesAtToolCall).toEqual([["user", "assistant"]]);
});
});
@@ -0,0 +1,80 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, getAssistantTexts, getUserTexts, type Harness } from "../harness.ts";
describe("issue #2023 queued slash-command follow-up", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("treats extension-origin queued slash-command follow-ups as raw user text instead of dispatching the command", async () => {
let extensionApi: ExtensionAPI | undefined;
const commandRuns: string[] = [];
let releaseToolExecution: (() => void) | undefined;
const toolRelease = new Promise<void>((resolve) => {
releaseToolExecution = resolve;
});
const waitTool: AgentTool = {
name: "wait",
label: "Wait",
description: "Wait for the test to release execution",
parameters: Type.Object({}),
execute: async () => {
await toolRelease;
return {
content: [{ type: "text", text: "released" }],
details: {},
};
},
};
const harness = await createHarness({
tools: [waitTool],
extensionFactories: [
(pi) => {
extensionApi = pi;
pi.registerCommand("testcmd", {
description: "Test command",
handler: async (args) => {
commandRuns.push(args);
},
});
},
],
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
fauxAssistantMessage("first turn complete"),
fauxAssistantMessage("queued follow-up handled by model"),
]);
const sawToolStart = new Promise<void>((resolve) => {
const unsubscribe = harness.session.subscribe((event) => {
if (event.type === "tool_execution_start" && event.toolName === "wait") {
unsubscribe();
resolve();
}
});
});
const promptPromise = harness.session.prompt("start");
await sawToolStart;
await new Promise((resolve) => setTimeout(resolve, 0));
extensionApi?.sendUserMessage("/testcmd queued", { deliverAs: "followUp" });
releaseToolExecution?.();
await promptPromise;
expect(commandRuns).toEqual([]);
expect(getUserTexts(harness)).toEqual(["start", "/testcmd queued"]);
expect(getAssistantTexts(harness)).toContain("queued follow-up handled by model");
});
});
@@ -0,0 +1,105 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerFauxProvider } from "@earendil-works/pi-ai/compat";
import { afterEach, describe, expect, it } from "vitest";
import {
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
} from "../../../src/core/agent-session-runtime.ts";
import { AuthStorage } from "../../../src/core/auth-storage.ts";
import { ModelRuntime } from "../../../src/core/model-runtime.ts";
import { SessionManager } from "../../../src/core/session-manager.ts";
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();
await authStorage.modify(faux.getModel().provider, async () => ({ type: "api_key", key: "faux-key" }));
const modelRuntime = await ModelRuntime.create({
credentials: authStorage,
modelsPath: join(agentDir, "models.json"),
});
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
cwd,
agentDir,
modelRuntime,
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");
});
});
@@ -0,0 +1,120 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts";
describe("issue #2781 skill collision precedence: user skills should override package skills", () => {
let tempDir: string;
let agentDir: string;
let cwd: string;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-2781-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
cwd = join(tempDir, "project");
mkdirSync(agentDir, { recursive: true });
mkdirSync(cwd, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function createPackageWithSkill(name: string, description: string): string {
const pkgDir = join(tempDir, `fake-package-${name}`);
const skillDir = join(pkgDir, "skills", name);
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({ name: `fake-pkg-${name}`, version: "1.0.0", pi: { skills: [`skills/${name}`] } }, null, 2),
);
writeFileSync(
join(skillDir, "SKILL.md"),
`---\nname: ${name}\ndescription: ${description}\n---\nPackage skill content`,
);
return pkgDir;
}
function createUserSkill(name: string, description: string): string {
const skillDir = join(agentDir, "skills", name);
mkdirSync(skillDir, { recursive: true });
const skillPath = join(skillDir, "SKILL.md");
writeFileSync(skillPath, `---\nname: ${name}\ndescription: ${description}\n---\nUser skill content`);
return skillPath;
}
function createProjectSkill(name: string, description: string): string {
const skillDir = join(cwd, ".pi", "skills", name);
mkdirSync(skillDir, { recursive: true });
const skillPath = join(skillDir, "SKILL.md");
writeFileSync(skillPath, `---\nname: ${name}\ndescription: ${description}\n---\nProject skill content`);
return skillPath;
}
function createSettingsWithPackage(pkgDir: string, scope: "user" | "project"): void {
const settingsDir = scope === "user" ? agentDir : join(cwd, ".pi");
mkdirSync(settingsDir, { recursive: true });
writeFileSync(join(settingsDir, "settings.json"), JSON.stringify({ packages: [pkgDir] }, null, 2));
}
it("user auto-discovered skill should override package skill with same name", async () => {
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
const userSkillPath = createUserSkill("web-fetch", "User web-fetch override");
createSettingsWithPackage(pkgDir, "user");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
const { skills } = loader.getSkills();
const webFetch = skills.find((s) => s.name === "web-fetch");
expect(webFetch).toBeDefined();
expect(webFetch!.filePath).toBe(userSkillPath);
expect(webFetch!.description).toBe("User web-fetch override");
});
it("project auto-discovered skill should override package skill with same name", async () => {
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
const projectSkillPath = createProjectSkill("web-fetch", "Project web-fetch override");
createSettingsWithPackage(pkgDir, "user");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
const { skills } = loader.getSkills();
const webFetch = skills.find((s) => s.name === "web-fetch");
expect(webFetch).toBeDefined();
expect(webFetch!.filePath).toBe(projectSkillPath);
expect(webFetch!.description).toBe("Project web-fetch override");
});
it("project skill should override user skill which should override package skill", async () => {
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
createUserSkill("web-fetch", "User web-fetch override");
const projectSkillPath = createProjectSkill("web-fetch", "Project web-fetch override");
createSettingsWithPackage(pkgDir, "user");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
const { skills } = loader.getSkills();
const webFetch = skills.find((s) => s.name === "web-fetch");
expect(webFetch).toBeDefined();
expect(webFetch!.filePath).toBe(projectSkillPath);
expect(webFetch!.description).toBe("Project web-fetch override");
});
it("collision diagnostics should report package skill as loser when user skill wins", async () => {
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
createUserSkill("web-fetch", "User web-fetch override");
createSettingsWithPackage(pkgDir, "user");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
const { diagnostics } = loader.getSkills();
const collision = diagnostics.find((d) => d.type === "collision" && d.collision?.name === "web-fetch");
expect(collision).toBeDefined();
expect(collision!.collision!.loserPath).toContain("fake-package");
});
});
@@ -0,0 +1,106 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
/**
* Regression test for https://github.com/earendil-works/pi-mono/issues/2791
*
* fs.watch() returns an FSWatcher (EventEmitter). If the watcher emits an
* 'error' event after creation and no error handler is attached, Node.js
* treats it as an uncaught exception and terminates the process.
*
* We test this by spawning a child process that:
* 1. Sets up a custom theme with the watcher enabled
* 2. Finds the FSWatcher via process._getActiveHandles()
* 3. Emits a synthetic 'error' event on it
* 4. If the watcher has no error handler -> crash (exit != 0) -> bug present
* 5. If the watcher has an error handler -> clean exit (exit 0) -> bug fixed
*/
describe("issue #2791 fs.watch error event crashes process", () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "pi-2791-"));
const agentDir = join(tempRoot, "agent");
const themesDir = join(agentDir, "themes");
mkdirSync(themesDir, { recursive: true });
// Copy dark.json as "custom-test" theme
const darkThemePath = join(__dirname, "../../../src/modes/interactive/theme/dark.json");
const darkTheme = JSON.parse(readFileSync(darkThemePath, "utf-8"));
darkTheme.name = "custom-test";
writeFileSync(join(themesDir, "custom-test.json"), JSON.stringify(darkTheme, null, 2));
});
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
it("process should survive an error event on the theme FSWatcher", () => {
const themeModulePath = join(__dirname, "../../../src/modes/interactive/theme/theme.ts").replace(/\\/g, "/");
const agentDir = join(tempRoot, "agent").replace(/\\/g, "/");
// Script that sets up the watcher and emits a synthetic error on it.
// If no .on('error') handler is attached, EventEmitter.emit('error')
// throws, which either crashes the process or gets caught by our try/catch.
const scriptPath = join(tempRoot, "test-watcher-error.mts");
writeFileSync(
scriptPath,
`
import { setTheme, stopThemeWatcher } from "${themeModulePath}";
process.env.PI_CODING_AGENT_DIR = "${agentDir}";
setTheme("custom-test", true);
// Find the FSWatcher among active handles
const handles = (process as any)._getActiveHandles();
const fsWatcher = handles.find((h: any) => h.constructor?.name === "FSWatcher");
if (!fsWatcher) {
process.stderr.write("no FSWatcher found among active handles\\n");
process.exit(2);
}
const errorListenerCount = fsWatcher.listenerCount("error");
if (errorListenerCount === 0) {
process.stderr.write("BUG: FSWatcher has no error handler (issue #2791)\\n");
}
// Emitting 'error' on an EventEmitter with no error listener throws.
// This simulates an async OS error (e.g. ReadDirectoryChangesW invalidation).
try {
fsWatcher.emit("error", new Error("simulated OS watcher failure"));
} catch {
process.stderr.write("error event was unhandled and threw\\n");
process.exit(1);
}
stopThemeWatcher();
process.exit(0);
`,
);
let _stdout = "";
let stderr = "";
let exitCode: number;
try {
_stdout = execFileSync(process.execPath, [scriptPath], {
timeout: 10000,
encoding: "utf-8",
env: { ...process.env, PI_CODING_AGENT_DIR: agentDir },
stdio: ["pipe", "pipe", "pipe"],
});
exitCode = 0;
} catch (err: unknown) {
const e = err as { status: number; stdout: string; stderr: string };
_stdout = e.stdout ?? "";
stderr = e.stderr ?? "";
exitCode = e.status ?? 1;
}
expect(exitCode, `Child crashed (exit ${exitCode}). stderr: ${stderr.trim()}`).toBe(0);
});
});
@@ -0,0 +1,94 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getModel } from "@earendil-works/pi-ai/compat";
import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts";
import { createAgentSession } from "../../../src/core/sdk.ts";
import { SessionManager } from "../../../src/core/session-manager.ts";
import { SettingsManager } from "../../../src/core/settings-manager.ts";
describe("regression #2835: tool allowlists filter extension tools", () => {
let tempDir: string;
let agentDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-tools-filter-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
mkdirSync(agentDir, { recursive: true });
});
afterEach(() => {
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
async function createSession(allowedToolNames?: string[]) {
const settingsManager = SettingsManager.create(tempDir, agentDir);
const sessionManager = SessionManager.inMemory(tempDir);
const resourceLoader = new DefaultResourceLoader({
cwd: tempDir,
agentDir,
settingsManager,
extensionFactories: [
(pi) => {
pi.on("session_start", () => {
pi.registerTool({
name: "dynamic_tool",
label: "Dynamic Tool",
description: "Tool registered from session_start",
promptSnippet: "Run dynamic test behavior",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "ok" }],
details: {},
}),
});
});
},
],
});
await resourceLoader.reload();
const { session } = await createAgentSession({
cwd: tempDir,
agentDir,
model: getModel("anthropic", "claude-sonnet-4-5")!,
settingsManager,
sessionManager,
resourceLoader,
tools: allowedToolNames,
});
await session.bindExtensions({});
return session;
}
it("allows only explicitly listed built-in and extension tools", async () => {
const session = await createSession(["read", "dynamic_tool"]);
expect(
session
.getAllTools()
.map((tool) => tool.name)
.sort(),
).toEqual(["dynamic_tool", "read"]);
expect(session.getActiveToolNames().sort()).toEqual(["dynamic_tool", "read"]);
expect(session.systemPrompt).toContain("- read: Read file contents");
expect(session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior");
expect(session.systemPrompt).not.toContain("- bash:");
expect(session.systemPrompt).not.toContain("- edit:");
session.dispose();
});
it("disables all tools when the allowlist is empty", async () => {
const session = await createSession([]);
expect(session.getAllTools()).toEqual([]);
expect(session.getActiveToolNames()).toEqual([]);
expect(session.systemPrompt).toContain("Available tools:\n(none)");
expect(session.systemPrompt).not.toContain("dynamic_tool");
session.dispose();
});
});
@@ -0,0 +1,279 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai/compat";
import { afterEach, describe, expect, it } from "vitest";
import type { AgentSession } from "../../../src/core/agent-session.ts";
import {
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
} from "../../../src/core/agent-session-runtime.ts";
import { AuthStorage } from "../../../src/core/auth-storage.ts";
import { ModelRuntime } from "../../../src/core/model-runtime.ts";
import { SessionManager } from "../../../src/core/session-manager.ts";
import type { ExtensionAPI, ExtensionCommandContext, ExtensionFactory } from "../../../src/index.ts";
function getText(message: AgentSession["messages"][number]): string {
if (!("content" in message)) {
return "";
}
return typeof message.content === "string"
? message.content
: message.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("");
}
describe("regression #2860: replaced session callbacks", () => {
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
while (cleanups.length > 0) {
await cleanups.pop()?.();
}
});
async function createRuntimeForTest(extensionFactory: ExtensionFactory, responses: string[]) {
const tempDir = join(tmpdir(), `pi-2860-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
const faux = registerFauxProvider({
models: [{ id: "faux-1", reasoning: false }],
});
faux.setResponses(responses.map((response) => fauxAssistantMessage(response)));
const authStorage = AuthStorage.inMemory();
await authStorage.modify(faux.getModel().provider, async () => ({ type: "api_key", key: "faux-key" }));
const modelRuntime = await ModelRuntime.create({
credentials: authStorage,
modelsPath: join(tempDir, "models.json"),
});
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
cwd,
agentDir: tempDir,
modelRuntime,
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,
})),
});
extensionFactory(pi);
},
],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model: faux.getModel(),
})),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: tempDir,
agentDir: tempDir,
sessionManager: SessionManager.create(tempDir),
});
const rebindSession = async (): Promise<void> => {
const session = runtime.session;
await session.bindExtensions({
commandContextActions: {
waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => runtime.newSession(options),
fork: async (entryId, options) => {
const result = await runtime.fork(entryId, options);
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
const result = await session.navigateTree(targetId, {
summarize: options?.summarize,
customInstructions: options?.customInstructions,
replaceInstructions: options?.replaceInstructions,
label: options?.label,
});
return { cancelled: result.cancelled };
},
switchSession: async (sessionPath, options) => runtime.switchSession(sessionPath, options),
reload: async () => {
await session.reload();
},
},
});
};
runtime.setRebindSession(async () => {
await rebindSession();
});
await rebindSession();
cleanups.push(async () => {
await runtime.dispose();
faux.unregister();
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
return { runtime, faux };
}
it("rebinds before withSession, targets the replacement session, and invalidates stale pi/ctx", async () => {
const events: string[] = [];
let oldCtx: ExtensionCommandContext | undefined;
let oldPi: ExtensionAPI | undefined;
let oldSessionFile: string | undefined;
let staleCtxThrows = false;
let stalePiThrows = false;
let replacementSessionFile: string | undefined;
let instanceId = 0;
const { runtime } = await createRuntimeForTest(
(pi) => {
const currentInstance = ++instanceId;
pi.on("session_start", () => {
events.push(`start:${currentInstance}`);
});
pi.on("session_shutdown", () => {
events.push(`shutdown:${currentInstance}`);
});
pi.registerCommand("repro", {
description: "repro",
handler: async (_args, ctx) => {
oldCtx = ctx;
oldPi = pi;
oldSessionFile = ctx.sessionManager.getSessionFile();
await ctx.newSession({
parentSession: oldSessionFile,
withSession: async (replacedCtx) => {
events.push(`with:${currentInstance}`);
replacementSessionFile = replacedCtx.sessionManager.getSessionFile();
try {
oldCtx?.sessionManager.getSessionFile();
} catch {
staleCtxThrows = true;
}
try {
oldPi?.sendUserMessage("stale message");
} catch {
stalePiThrows = true;
}
await replacedCtx.sendUserMessage("Hello from the new session!");
},
});
},
});
},
["hello reply"],
);
expect(events).toEqual(["start:1"]);
await runtime.session.prompt("/repro");
expect(events).toEqual(["start:1", "shutdown:1", "start:2", "with:1"]);
expect(replacementSessionFile).toBeDefined();
expect(replacementSessionFile).not.toBe(oldSessionFile);
expect(staleCtxThrows).toBe(true);
expect(stalePiThrows).toBe(true);
expect(runtime.session.messages.map((message) => `${message.role}:${getText(message)}`)).toEqual([
"user:Hello from the new session!",
"assistant:hello reply",
]);
});
it("supports withSession for fork", async () => {
const { runtime } = await createRuntimeForTest(
(pi) => {
pi.registerCommand("fork-it", {
description: "fork-it",
handler: async (_args, ctx) => {
const leafId = ctx.sessionManager.getLeafId();
if (!leafId) {
throw new Error("Missing leaf id");
}
await ctx.fork(leafId, {
position: "at",
withSession: async (replacedCtx) => {
await replacedCtx.sendUserMessage("fork callback message");
},
});
},
});
},
["seed reply", "fork reply"],
);
await runtime.session.prompt("seed");
await runtime.session.prompt("/fork-it");
expect(runtime.session.messages.map((message) => `${message.role}:${getText(message)}`)).toEqual([
"user:seed",
"assistant:seed reply",
"user:fork callback message",
"assistant:fork reply",
]);
});
it("supports withSession for switchSession", async () => {
let targetSessionPath = "";
const { runtime } = await createRuntimeForTest(
(pi) => {
pi.registerCommand("switch-it", {
description: "switch-it",
handler: async (_args, ctx) => {
await ctx.switchSession(targetSessionPath, {
withSession: async (replacedCtx) => {
await replacedCtx.sendUserMessage("switch callback message");
},
});
},
});
},
["root reply", "target reply", "switch reply"],
);
await runtime.session.prompt("root");
const originalSessionPath = runtime.session.sessionFile;
const newSessionResult = await runtime.newSession();
expect(newSessionResult.cancelled).toBe(false);
await runtime.session.prompt("target");
targetSessionPath = runtime.session.sessionFile!;
await runtime.switchSession(originalSessionPath!);
await runtime.session.prompt("/switch-it");
expect(runtime.session.sessionFile).toBe(targetSessionPath);
expect(runtime.session.messages.map((message) => `${message.role}:${getText(message)}`)).toEqual([
"user:target",
"assistant:target reply",
"user:switch callback message",
"assistant:switch reply",
]);
});
});
@@ -0,0 +1,104 @@
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts";
import { ScopedModelsSelectorComponent } from "../../../src/modes/interactive/components/scoped-models-selector.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
import { createHarness, type Harness } from "../harness.ts";
function createFakeTui(): TUI {
return {
requestRender: () => {},
} as unknown as TUI;
}
describe("issue #3217 scoped model ordering", () => {
const harnesses: Harness[] = [];
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
// Ensure test isolation: keybindings are a global singleton
setKeybindings(new KeybindingsManager());
});
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("propagates reordered scoped models back to the session state", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
{ id: "faux-3", name: "Three", reasoning: true },
],
});
harnesses.push(harness);
const orderedIds = harness.models.map((model) => `${model.provider}/${model.id}`);
const changes: Array<string[] | null> = [];
const selector = new ScopedModelsSelectorComponent(
{
allModels: [...harness.models],
enabledModelIds: orderedIds,
},
{
onChange: (enabledModelIds) => {
changes.push(enabledModelIds);
},
onPersist: () => {},
onCancel: () => {},
},
);
selector.handleInput("\x1b[1;3B");
expect(changes).toEqual([[orderedIds[1], orderedIds[0], orderedIds[2]]]);
});
it("preserves scoped model order in the /model scoped tab", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
{ id: "faux-3", name: "Three", reasoning: true },
],
});
harnesses.push(harness);
const modelOne = harness.getModel("faux-1")!;
const modelTwo = harness.getModel("faux-2")!;
const modelThree = harness.getModel("faux-3")!;
const selector = new ModelSelectorComponent(
createFakeTui(),
modelOne,
harness.settingsManager,
harness.session.modelRuntime,
[{ model: modelTwo }, { model: modelOne }, { model: modelThree }],
() => {},
() => {},
);
await vi.waitFor(() => {
const rendered = stripAnsi(selector.render(120).join("\n"));
expect(rendered).toContain(`[${modelOne.provider}]`);
expect(rendered).toContain("Model catalogs refreshed.");
});
const renderedLines = stripAnsi(selector.render(120).join("\n"))
.split("\n")
.filter((line) => line.includes(`[${modelOne.provider}]`));
const orderedIds = renderedLines.slice(0, 3).map((line) => {
const [modelId] = line.trim().replace(/^→\s*/, "").split(" [");
return modelId?.trim() ?? "";
});
expect(orderedIds).toEqual([modelTwo.id, modelOne.id, modelThree.id]);
});
});
@@ -0,0 +1,72 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createFindToolDefinition } from "../../../src/core/tools/find.ts";
/**
* Regression test for https://github.com/earendil-works/pi-mono/issues/3302
*
* The `find` tool advertises glob patterns like `src/**\/*.spec.ts`, but the
* default fd-backed implementation used `fd --glob <pattern>` without
* `--full-path`, which makes fd match only against the basename. Any pattern
* containing a `/` therefore silently returned no matches.
*
* The fix switches fd into full-path mode when the pattern contains a `/`
* and prepends `**\/` so the pattern can match against the absolute candidate
* path that fd feeds to the matcher.
*/
describe("issue #3302 find returns no results for path-based glob patterns", () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "pi-3302-"));
mkdirSync(join(tempRoot, "some", "parent", "child"), { recursive: true });
mkdirSync(join(tempRoot, "src", "foo", "bar"), { recursive: true });
writeFileSync(join(tempRoot, "some", "parent", "child", "file.ext"), "");
writeFileSync(join(tempRoot, "some", "parent", "child", "test.spec.ts"), "");
writeFileSync(join(tempRoot, "src", "foo", "bar", "example.spec.ts"), "");
});
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
async function runFind(pattern: string): Promise<string[]> {
const def = createFindToolDefinition(tempRoot);
// The find tool implementation does not touch ctx; pass a minimal stub.
const ctx = {} as Parameters<typeof def.execute>[4];
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
content: Array<{ type: string; text?: string }>;
};
const text = result.content[0]?.text ?? "";
if (text === "No files found matching pattern") return [];
return text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith("["));
}
it("basename pattern still matches (regression-safe)", async () => {
const files = await runFind("*.spec.ts");
expect(files.sort()).toEqual(["some/parent/child/test.spec.ts", "src/foo/bar/example.spec.ts"]);
});
it("directory-prefixed pattern with ** tail matches subtree", async () => {
const files = await runFind("some/parent/child/**");
// Matches files (and possibly directories) under the subtree. Assert the two files are present.
expect(files).toContain("some/parent/child/file.ext");
expect(files).toContain("some/parent/child/test.spec.ts");
});
it("leading ** wildcard with path segments matches", async () => {
const files = await runFind("**/parent/child/*");
expect(files.sort()).toContain("some/parent/child/file.ext");
expect(files.sort()).toContain("some/parent/child/test.spec.ts");
});
it("src/**/*.spec.ts matches nested spec file", async () => {
const files = await runFind("src/**/*.spec.ts");
expect(files).toEqual(["src/foo/bar/example.spec.ts"]);
});
});
@@ -0,0 +1,83 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createFindToolDefinition } from "../../../src/core/tools/find.ts";
/**
* Regression test for https://github.com/earendil-works/pi-mono/issues/3303
*
* The `find` tool previously collected every `.gitignore` under the search
* path and passed them to `fd` via `--ignore-file`. fd treats `--ignore-file`
* entries as a single global ignore source, so rules from `a/.gitignore`
* also filtered files under sibling `b/`. The fix switches to fd's
* hierarchical `.gitignore` handling via `--no-require-git` and drops the
* manual collection.
*/
describe("issue #3303 nested .gitignore rules leak into sibling directories", () => {
let tempRoot: string;
async function runFind(pattern: string): Promise<string[]> {
const def = createFindToolDefinition(tempRoot);
const ctx = {} as Parameters<typeof def.execute>[4];
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
content: Array<{ type: string; text?: string }>;
};
const text = result.content[0]?.text ?? "";
if (text === "No files found matching pattern") return [];
return text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith("["))
.sort();
}
afterEach(() => {
if (tempRoot) rmSync(tempRoot, { recursive: true, force: true });
});
describe("flat sibling case", () => {
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-flat-"));
mkdirSync(join(tempRoot, "a"), { recursive: true });
mkdirSync(join(tempRoot, "b"), { recursive: true });
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
writeFileSync(join(tempRoot, "root.txt"), "");
});
it("applies a/.gitignore only inside a/ and leaves b/ untouched", async () => {
const files = await runFind("**/*.txt");
expect(files).toEqual(["a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
});
});
describe("deeply nested case", () => {
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-deep-"));
mkdirSync(join(tempRoot, "a", "deep"), { recursive: true });
mkdirSync(join(tempRoot, "b"), { recursive: true });
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
writeFileSync(join(tempRoot, "a", "deep", ".gitignore"), "secret.txt\n");
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
writeFileSync(join(tempRoot, "a", "deep", "ignored.txt"), "");
writeFileSync(join(tempRoot, "a", "deep", "secret.txt"), "");
writeFileSync(join(tempRoot, "a", "deep", "kept.txt"), "");
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
writeFileSync(join(tempRoot, "root.txt"), "");
});
it("scopes each .gitignore to its own subtree", async () => {
const files = await runFind("**/*.txt");
// a/.gitignore ignores 'ignored.txt' within a/ and a/deep/.
// a/deep/.gitignore additionally ignores 'secret.txt' within a/deep/.
// b/ is untouched by either.
expect(files).toEqual(["a/deep/kept.txt", "a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
});
});
});
@@ -0,0 +1,33 @@
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, getAssistantTexts, type Harness } from "../harness.ts";
describe("issue #3317 network connection lost retry", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it('retries transient "Network connection lost." failures', async () => {
const harness = await createHarness({
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage("", { stopReason: "error", errorMessage: "Network connection lost." }),
fauxAssistantMessage("recovered after reconnect"),
]);
await harness.session.prompt("test");
expect(harness.faux.state.callCount).toBe(2);
expect(harness.eventsOfType("auto_retry_start").map((event) => event.errorMessage)).toEqual([
"Network connection lost.",
]);
expect(harness.eventsOfType("auto_retry_end").map((event) => event.success)).toEqual([true]);
expect(getAssistantTexts(harness)).toContain("recovered after reconnect");
});
});
@@ -0,0 +1,119 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getModel } from "@earendil-works/pi-ai/compat";
import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createAgentSessionFromServices,
createAgentSessionServices,
} from "../../../src/core/agent-session-services.ts";
import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts";
import { createAgentSession } from "../../../src/core/sdk.ts";
import { SessionManager } from "../../../src/core/session-manager.ts";
import { SettingsManager } from "../../../src/core/settings-manager.ts";
describe("regression #3592: no-builtin-tools keeps extension tools enabled", () => {
let tempDir: string;
let agentDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-no-builtin-tools-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
mkdirSync(agentDir, { recursive: true });
});
afterEach(() => {
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
async function createSession(options?: { noTools?: "all" | "builtin"; tools?: string[] }) {
const settingsManager = SettingsManager.create(tempDir, agentDir);
const sessionManager = SessionManager.inMemory(tempDir);
const resourceLoader = new DefaultResourceLoader({
cwd: tempDir,
agentDir,
settingsManager,
extensionFactories: [
(pi) => {
pi.on("session_start", () => {
pi.registerTool({
name: "dynamic_tool",
label: "Dynamic Tool",
description: "Tool registered from session_start",
promptSnippet: "Run dynamic test behavior",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "ok" }],
details: {},
}),
});
});
},
],
});
await resourceLoader.reload();
const { session } = await createAgentSession({
cwd: tempDir,
agentDir,
model: getModel("anthropic", "claude-sonnet-4-5")!,
settingsManager,
sessionManager,
resourceLoader,
noTools: options?.noTools,
tools: options?.tools,
});
await session.bindExtensions({});
return session;
}
it("keeps extension tools active when built-in defaults are disabled", async () => {
const session = await createSession({ noTools: "builtin" });
expect(
session
.getAllTools()
.map((tool) => tool.name)
.sort(),
).toEqual(["bash", "dynamic_tool", "edit", "find", "grep", "ls", "read", "write"]);
expect(session.getActiveToolNames()).toEqual(["dynamic_tool"]);
expect(session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior");
expect(session.systemPrompt).not.toContain("- read:");
expect(session.systemPrompt).not.toContain("- bash:");
session.dispose();
});
it("still disables all tools when noTools is all", async () => {
const session = await createSession({ noTools: "all" });
expect(session.getAllTools()).toEqual([]);
expect(session.getActiveToolNames()).toEqual([]);
expect(session.systemPrompt).toContain("Available tools:\n(none)");
session.dispose();
});
it("propagates noTools through service-based session creation", async () => {
const settingsManager = SettingsManager.create(tempDir, agentDir);
const sessionManager = SessionManager.inMemory(tempDir);
const services = await createAgentSessionServices({
cwd: tempDir,
agentDir,
settingsManager,
});
const { session } = await createAgentSessionFromServices({
services,
sessionManager,
model: getModel("anthropic", "claude-sonnet-4-5")!,
noTools: "builtin",
});
expect(session.getActiveToolNames()).toEqual([]);
expect(session.systemPrompt).toContain("Available tools:\n(none)");
expect(session.systemPrompt).not.toContain("- read:");
session.dispose();
});
});
@@ -0,0 +1,86 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts";
import { SettingsManager } from "../../../src/core/settings-manager.ts";
describe("regression #3616: in-memory settings survive reload", () => {
let tempDir: string;
let agentDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-settings-inmemory-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
mkdirSync(agentDir, { recursive: true });
});
afterEach(() => {
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
it("preserves initial settings after direct reload", async () => {
const settingsManager = SettingsManager.inMemory({
defaultThinkingLevel: "high",
images: { autoResize: false },
compaction: { enabled: false },
});
await settingsManager.reload();
expect(settingsManager.getDefaultThinkingLevel()).toBe("high");
expect(settingsManager.getImageAutoResize()).toBe(false);
expect(settingsManager.getCompactionEnabled()).toBe(false);
expect(settingsManager.getGlobalSettings()).toEqual({
defaultThinkingLevel: "high",
images: { autoResize: false },
compaction: { enabled: false },
});
});
it("preserves initial settings when DefaultResourceLoader reloads", async () => {
const settingsManager = SettingsManager.inMemory({
defaultThinkingLevel: "high",
images: { autoResize: false },
compaction: { enabled: false },
});
const resourceLoader = new DefaultResourceLoader({
cwd: tempDir,
agentDir,
settingsManager,
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
});
await resourceLoader.reload();
expect(settingsManager.getDefaultThinkingLevel()).toBe("high");
expect(settingsManager.getImageAutoResize()).toBe(false);
expect(settingsManager.getCompactionEnabled()).toBe(false);
});
it("preserves initial settings after an unrelated setter, flush, and reload", async () => {
const settingsManager = SettingsManager.inMemory({
images: { autoResize: false },
compaction: { enabled: false },
});
settingsManager.setTheme("dark");
await settingsManager.flush();
await settingsManager.reload();
expect(settingsManager.getTheme()).toBe("dark");
expect(settingsManager.getImageAutoResize()).toBe(false);
expect(settingsManager.getCompactionEnabled()).toBe(false);
expect(settingsManager.getGlobalSettings()).toEqual({
images: { autoResize: false },
compaction: { enabled: false },
theme: "dark",
});
});
});
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from "vitest";
import type { ExtensionAPI } from "../../../src/index.ts";
import { createHarness, type Harness } from "../harness.ts";
describe("regression #3686: session name changes emit an event", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("emits session_info_changed when AgentSession.setSessionName is called", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.session.setSessionName("hello world");
expect(harness.sessionManager.getSessionName()).toBe("hello world");
expect(harness.eventsOfType("session_info_changed").map((event) => event.name)).toEqual(["hello world"]);
});
it("emits session_info_changed when an extension calls pi.setSessionName", async () => {
let api: ExtensionAPI | undefined;
const harness = await createHarness({
extensionFactories: [
(pi) => {
api = pi;
},
],
});
harnesses.push(harness);
api?.setSessionName("from extension");
expect(harness.sessionManager.getSessionName()).toBe("from extension");
expect(harness.eventsOfType("session_info_changed").map((event) => event.name)).toEqual(["from extension"]);
});
it("emits session_info_changed to extensions", async () => {
let api: ExtensionAPI | undefined;
const events: Array<{ name: string | undefined }> = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
api = pi;
pi.on("session_info_changed", (event) => {
events.push({ name: event.name });
});
},
],
});
harnesses.push(harness);
api?.setSessionName("first");
harness.session.setSessionName("second");
expect(events).toEqual([{ name: "first" }, { name: "second" }]);
});
});
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it } from "vitest";
import { assistantMsg, userMsg } from "../../utilities.ts";
import { createHarness, type Harness } from "../harness.ts";
describe("issue #3688 tree cancellation compaction state", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("clears branch summary state when session_before_tree cancels navigation", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_before_tree", () => ({ cancel: true }));
},
],
});
harnesses.push(harness);
const targetId = harness.sessionManager.appendMessage(userMsg("first"));
harness.sessionManager.appendMessage(assistantMsg("reply"));
const currentLeafId = harness.sessionManager.appendMessage(userMsg("second"));
expect(harness.sessionManager.getLeafId()).toBe(currentLeafId);
const result = await harness.session.navigateTree(targetId, { summarize: false });
expect(result).toEqual({ cancelled: true });
expect(harness.session.isCompacting).toBe(false);
expect(harness.sessionManager.getLeafId()).toBe(currentLeafId);
});
});
@@ -0,0 +1,56 @@
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, type Harness } from "../harness.ts";
describe("regression #3982: message_end cost override", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("allows extensions to replace finalized assistant usage cost", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("message_end", (event) => {
if (event.message.role !== "assistant") return;
return {
message: {
...event.message,
usage: {
...event.message.usage,
cost: {
...event.message.usage.cost,
total: 0.123,
},
},
},
};
});
},
],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("hello")]);
await harness.session.prompt("hi");
const assistantMessage = harness.session.messages.find((message) => message.role === "assistant");
expect(assistantMessage?.role).toBe("assistant");
if (assistantMessage?.role !== "assistant") {
throw new Error("missing assistant message");
}
expect(assistantMessage.usage.cost.total).toBe(0.123);
const messageEnd = harness.eventsOfType("message_end").find((event) => event.message.role === "assistant");
expect(messageEnd?.message.role).toBe("assistant");
if (messageEnd?.message.role !== "assistant") {
throw new Error("missing assistant message_end event");
}
expect(messageEnd.message.usage.cost.total).toBe(0.123);
});
});
@@ -0,0 +1,182 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { Container, Text, type TUI } from "@earendil-works/pi-tui";
import { beforeAll, describe, expect, test, vi } from "vitest";
import type { AgentSessionEvent } from "../../../src/core/agent-session.ts";
import type { SessionEntry } from "../../../src/core/session-manager.ts";
import type { ToolExecutionComponent } from "../../../src/modes/interactive/components/tool-execution.ts";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
const TOOL_CALL_ID = "tool-4167";
const TOOL_NAME = "slow_tool";
const EMPTY_USAGE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
type RenderSessionItems = (
this: RenderSessionContextThis,
items: AgentMessage[],
options?: { updateFooter?: boolean; populateHistory?: boolean },
) => void;
type RenderSessionContextThis = {
pendingTools: Map<string, ToolExecutionComponent>;
chatContainer: Container;
footer: { invalidate(): void };
ui: TUI;
settingsManager: {
getShowImages(): boolean;
getImageWidthCells(): number;
getShowCacheMissNotices(): boolean;
};
sessionManager: { getCwd(): string; getEntries(): SessionEntry[] };
session: { retryAttempt: number; modelRegistry: { find(provider: string, modelId: string): undefined } };
toolOutputExpanded: boolean;
isInitialized: boolean;
updateEditorBorderColor(): void;
getRegisteredToolDefinition(toolName: string): undefined;
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void;
renderSessionItems: RenderSessionItems;
};
type RenderSessionEntries = (
this: RenderSessionContextThis,
entries: SessionEntry[],
options?: { updateFooter?: boolean; populateHistory?: boolean },
) => void;
type HandleEvent = (this: RenderSessionContextThis, event: AgentSessionEvent) => Promise<void>;
function createFakeInteractiveModeThis(): RenderSessionContextThis {
const chatContainer = new Container();
return {
pendingTools: new Map<string, ToolExecutionComponent>(),
chatContainer,
footer: { invalidate: vi.fn() },
ui: { requestRender: vi.fn() } as unknown as TUI,
settingsManager: {
getShowImages: () => false,
getImageWidthCells: () => 60,
getShowCacheMissNotices: () => false,
},
sessionManager: { getCwd: () => process.cwd(), getEntries: () => [] },
session: { retryAttempt: 0, modelRegistry: { find: () => undefined } },
toolOutputExpanded: false,
isInitialized: true,
updateEditorBorderColor: vi.fn(),
getRegisteredToolDefinition: (_toolName: string) => undefined,
renderSessionItems: (InteractiveMode.prototype as unknown as { renderSessionItems: RenderSessionItems })
.renderSessionItems,
addMessageToChat(message: AgentMessage) {
chatContainer.addChild(new Text(message.role, 0, 0));
},
};
}
function createAssistantToolCallMessage(): AssistantMessage {
return {
role: "assistant",
content: [
{
type: "toolCall",
id: TOOL_CALL_ID,
name: TOOL_NAME,
arguments: { delayMs: 10_000 },
},
],
api: "test-api",
provider: "test-provider",
model: "test-model",
usage: EMPTY_USAGE,
stopReason: "toolUse",
timestamp: Date.now(),
};
}
function createToolResultMessage(text: string): ToolResultMessage {
return {
role: "toolResult",
toolCallId: TOOL_CALL_ID,
toolName: TOOL_NAME,
content: [{ type: "text", text }],
isError: false,
timestamp: Date.now(),
};
}
function createSessionEntries(messages: AgentMessage[]): SessionEntry[] {
let parentId: string | null = null;
return messages.map((message, index) => {
const entry: SessionEntry = {
type: "message",
id: `entry-${index}`,
parentId,
timestamp: new Date().toISOString(),
message,
};
parentId = entry.id;
return entry;
});
}
function renderChat(container: Container): string {
return stripAnsi(container.render(120).join("\n"));
}
describe("InteractiveMode.renderSessionEntries", () => {
beforeAll(() => {
initTheme("dark");
});
test("keeps unresolved rendered tool calls registered for live completion events", async () => {
const fakeThis = createFakeInteractiveModeThis();
const renderSessionEntries = (
InteractiveMode.prototype as unknown as { renderSessionEntries: RenderSessionEntries }
).renderSessionEntries;
const handleEvent = (InteractiveMode.prototype as unknown as { handleEvent: HandleEvent }).handleEvent;
renderSessionEntries.call(fakeThis, createSessionEntries([createAssistantToolCallMessage()]));
expect(fakeThis.pendingTools.has(TOOL_CALL_ID)).toBe(true);
await handleEvent.call(fakeThis, {
type: "tool_execution_end",
toolCallId: TOOL_CALL_ID,
toolName: TOOL_NAME,
result: { content: [{ type: "text", text: "FINAL_RESULT" }], details: undefined },
isError: false,
});
expect(fakeThis.pendingTools.has(TOOL_CALL_ID)).toBe(false);
expect(renderChat(fakeThis.chatContainer)).toContain("FINAL_RESULT");
});
test("does not keep completed historical tool calls registered as pending", () => {
const fakeThis = createFakeInteractiveModeThis();
const renderSessionEntries = (
InteractiveMode.prototype as unknown as { renderSessionEntries: RenderSessionEntries }
).renderSessionEntries;
renderSessionEntries.call(
fakeThis,
createSessionEntries([createAssistantToolCallMessage(), createToolResultMessage("HISTORICAL_RESULT")]),
);
expect(fakeThis.pendingTools.size).toBe(0);
expect(renderChat(fakeThis.chatContainer)).toContain("HISTORICAL_RESULT");
});
});
@@ -0,0 +1,185 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import chalk from "chalk";
import { afterEach, describe, expect, test, vi } from "vitest";
import { APP_NAME } from "../../../src/config.ts";
import type { SessionManager } from "../../../src/core/session-manager.ts";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
// Regression for https://github.com/earendil-works/pi/issues/5080
//
// On SIGTERM/SIGHUP the graceful shutdown must emit `session_shutdown`
// (runtimeHost.dispose) BEFORE touching the terminal. Extension teardown such
// as removing a socket does not write to the tty, so it must not be skipped if
// a later terminal-restore write fails on a dead or stalled terminal. The
// interactive quit path (Ctrl+D, /quit) keeps the opposite order to preserve
// the final TUI frame.
type ShutdownThis = {
isShuttingDown: boolean;
unregisterSignalHandlers: () => void;
runtimeHost: { dispose: () => Promise<void> };
ui: { terminal: { drainInput: (ms: number) => Promise<void> } };
themeController: { disableAutoSync: () => void };
stop: () => void;
sessionManager: SessionManager;
};
type InteractiveModePrototypeWithShutdown = {
shutdown(this: ShutdownThis, options?: { fromSignal?: boolean }): Promise<void>;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown;
const tempDirs: string[] = [];
const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
class ProcessExitError extends Error {}
function createSessionManager(options: { sessionFile?: string } = {}): SessionManager {
return {
isPersisted: () => options.sessionFile !== undefined,
getSessionFile: () => options.sessionFile,
getSessionId: () => "test-session",
getSessionDir: () => "/tmp/pi-sessions",
usesDefaultSessionDir: () => true,
} as unknown as SessionManager;
}
function createTempFile(): string {
const dir = mkdtempSync(join(tmpdir(), "pi-shutdown-resume-hint-"));
tempDirs.push(dir);
const file = join(dir, "session.jsonl");
writeFileSync(file, "\n");
return file;
}
function setStdoutIsTTY(value: boolean): void {
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value });
}
function restoreStdoutIsTTY(): void {
if (originalStdoutIsTTY) {
Object.defineProperty(process.stdout, "isTTY", originalStdoutIsTTY);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
}
function createContext(order: string[], sessionManager = createSessionManager()): ShutdownThis {
return {
isShuttingDown: false,
unregisterSignalHandlers: vi.fn(),
runtimeHost: {
dispose: vi.fn(async () => {
order.push("dispose");
}),
},
ui: {
terminal: {
drainInput: vi.fn(async () => {
order.push("drainInput");
}),
},
},
themeController: { disableAutoSync: vi.fn() },
stop: vi.fn(() => {
order.push("stop");
}),
sessionManager,
};
}
async function callShutdown(context: ShutdownThis, options?: { fromSignal?: boolean }): Promise<void> {
try {
await (interactiveModePrototype as InteractiveModePrototypeWithShutdown).shutdown.call(context, options);
} catch (error) {
if (!(error instanceof ProcessExitError)) throw error;
}
}
describe("InteractiveMode.shutdown ordering (#5080)", () => {
afterEach(() => {
vi.restoreAllMocks();
restoreStdoutIsTTY();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
test("signal-triggered shutdown emits session_shutdown before terminal writes", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const order: string[] = [];
const context = createContext(order);
await callShutdown(context, { fromSignal: true });
expect(order).toEqual(["dispose", "drainInput", "stop"]);
expect(context.isShuttingDown).toBe(true);
});
test("interactive quit stops the TUI before emitting session_shutdown", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const order: string[] = [];
const context = createContext(order);
await callShutdown(context);
expect(order).toEqual(["drainInput", "stop", "dispose"]);
});
test("interactive quit prints a resume hint for persisted sessions", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation((() => true) as typeof process.stdout.write);
setStdoutIsTTY(true);
const order: string[] = [];
const context = createContext(order, createSessionManager({ sessionFile: createTempFile() }));
await callShutdown(context);
expect(order).toEqual(["drainInput", "stop", "dispose"]);
expect(stdoutWrite).toHaveBeenCalledWith(
`${chalk.dim("To resume this session:")} ${APP_NAME} --session test-session\n`,
);
});
test("signal-triggered shutdown does not print a resume hint", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation((() => true) as typeof process.stdout.write);
setStdoutIsTTY(true);
const order: string[] = [];
const context = createContext(order, createSessionManager({ sessionFile: createTempFile() }));
await callShutdown(context, { fromSignal: true });
for (const call of stdoutWrite.mock.calls) {
expect(call[0]).not.toContain("To resume this session:");
}
});
test("re-entrant shutdown is a no-op", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const order: string[] = [];
const context = createContext(order);
context.isShuttingDown = true;
await callShutdown(context, { fromSignal: true });
expect(order).toEqual([]);
expect(context.runtimeHost.dispose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,81 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import type { ExtensionFactory } from "../../../src/index.ts";
import { createHarness } from "../harness.ts";
function toolNames(tools: Array<{ name: string }>): string[] {
return tools.map((tool) => tool.name).sort();
}
describe("regression #5109: exclude tools", () => {
const extensionFactories: ExtensionFactory[] = [
(pi) => {
pi.on("session_start", () => {
pi.registerTool({
name: "ask_question",
label: "Ask Question",
description: "Ask a question",
promptSnippet: "Ask a question",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "ok" }],
details: {},
}),
});
pi.registerTool({
name: "dynamic_tool",
label: "Dynamic Tool",
description: "Dynamic test tool",
promptSnippet: "Run dynamic test behavior",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "ok" }],
details: {},
}),
});
});
},
];
it("filters built-in and extension tools from available and active tools", async () => {
const harness = await createHarness({
excludedToolNames: ["read", "ask_question"],
extensionFactories,
});
try {
await harness.session.bindExtensions({});
const allToolNames = toolNames(harness.session.getAllTools());
expect(allToolNames).not.toContain("read");
expect(allToolNames).not.toContain("ask_question");
expect(allToolNames).toContain("bash");
expect(allToolNames).toContain("dynamic_tool");
expect(harness.session.getActiveToolNames().sort()).toEqual(["bash", "dynamic_tool", "edit", "write"]);
expect(harness.session.systemPrompt).not.toContain("- read:");
expect(harness.session.systemPrompt).not.toContain("ask_question");
expect(harness.session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior");
} finally {
harness.cleanup();
}
});
it("lets excluded tools override the allowlist", async () => {
const harness = await createHarness({
allowedToolNames: ["read", "bash", "ask_question"],
excludedToolNames: ["read", "ask_question"],
initialActiveToolNames: ["read", "bash", "ask_question"],
extensionFactories,
});
try {
await harness.session.bindExtensions({});
expect(toolNames(harness.session.getAllTools())).toEqual(["bash"]);
expect(harness.session.getActiveToolNames()).toEqual(["bash"]);
expect(harness.session.systemPrompt).toContain("- bash:");
expect(harness.session.systemPrompt).not.toContain("- read:");
expect(harness.session.systemPrompt).not.toContain("ask_question");
} finally {
harness.cleanup();
}
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { type BashOperations, createBashTool } from "../../../src/core/tools/bash.ts";
function getTextOutput(result: { content?: Array<{ type: string; text?: string }> }): string {
return (
result.content
?.filter((block) => block.type === "text")
.map((block) => block.text ?? "")
.join("\n") ?? ""
);
}
describe("regression #5208: late bash output callbacks", () => {
it("ignores output callbacks after bash operations resolve", async () => {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
onData(Buffer.from("before\n", "utf-8"));
setTimeout(() => onData(Buffer.from("late\n", "utf-8")), 0);
return { exitCode: 0 };
},
};
const bash = createBashTool(process.cwd(), { operations });
const result = await bash.execute("test-call-late-output", { command: "late-output" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(getTextOutput(result).trim()).toBe("before");
});
});
@@ -0,0 +1,95 @@
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import type { ExtensionFactory } from "../../../src/index.ts";
import { createHarness, type Harness } from "../harness.ts";
type SessionWithCompactionInternals = {
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<boolean>;
};
interface RecordedCompactionEvent {
type: "session_before_compact" | "session_compact";
reason: "manual" | "threshold" | "overflow";
willRetry: boolean;
}
function recordingExtension(recorded: RecordedCompactionEvent[]): ExtensionFactory {
return (pi) => {
pi.on("session_before_compact", async (event) => {
recorded.push({ type: event.type, reason: event.reason, willRetry: event.willRetry });
return {
compaction: {
summary: "summary from extension",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
};
});
pi.on("session_compact", async (event) => {
recorded.push({ type: event.type, reason: event.reason, willRetry: event.willRetry });
});
};
}
async function createCompactionHarness(recorded: RecordedCompactionEvent[]): Promise<Harness> {
const harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [recordingExtension(recorded)],
});
harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]);
await harness.session.prompt("first");
await harness.session.prompt("second");
return harness;
}
describe("issue #5217 compaction reason on extension events", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("reports manual reason for compact()", async () => {
const recorded: RecordedCompactionEvent[] = [];
const harness = await createCompactionHarness(recorded);
harnesses.push(harness);
await harness.session.compact();
expect(recorded).toEqual([
{ type: "session_before_compact", reason: "manual", willRetry: false },
{ type: "session_compact", reason: "manual", willRetry: false },
]);
});
it("reports threshold reason for auto-compaction", async () => {
const recorded: RecordedCompactionEvent[] = [];
const harness = await createCompactionHarness(recorded);
harnesses.push(harness);
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
await sessionInternals._runAutoCompaction("threshold", false);
expect(recorded).toEqual([
{ type: "session_before_compact", reason: "threshold", willRetry: false },
{ type: "session_compact", reason: "threshold", willRetry: false },
]);
});
it("reports overflow reason and willRetry for overflow recovery", async () => {
const recorded: RecordedCompactionEvent[] = [];
const harness = await createCompactionHarness(recorded);
harnesses.push(harness);
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
await sessionInternals._runAutoCompaction("overflow", true);
expect(recorded).toEqual([
{ type: "session_before_compact", reason: "overflow", willRetry: true },
{ type: "session_compact", reason: "overflow", willRetry: true },
]);
});
});
@@ -0,0 +1,79 @@
import type { ChildProcessByStdio } from "node:child_process";
import type { Readable } from "node:stream";
import { afterEach, describe, expect, it } from "vitest";
import { spawnProcess, waitForChildProcess } from "../../../src/utils/child-process.ts";
/**
* Regression test for https://github.com/earendil-works/pi/issues/5303
*
* waitForChildProcess armed a fixed 100ms timer on `exit` and destroyed the
* stdio streams when it fired. When a short-lived detached descendant kept the
* stdout pipe open, `close` never fired, so that timer was the only thing that
* resolved the wait, and any output written more than 100ms after exit was
* binned. In practice every git commit whose pre-commit hook runs lint-staged
* came back truncated mid-listr2 output, read by the model as a hang.
*
* The fix re-arms the grace on each chunk, so an actively writing pipe keeps us
* reading while a genuinely idle held-open handle still releases after the
* grace elapses. Both behaviours are covered below.
*/
describe.skipIf(process.platform === "win32")("issue #5303 bash output truncation past exit", () => {
let child: ChildProcessByStdio<null, Readable, Readable> | undefined;
afterEach(() => {
if (child?.pid) {
try {
process.kill(-child.pid, "SIGKILL");
} catch {
// Already gone.
}
}
child = undefined;
});
it("captures output emitted after exit while a detached child holds stdout open", async () => {
// The shell exits immediately, but a backgrounded subshell keeps the stdout
// pipe open and emits ticks every 50ms, the last well past the 100ms grace.
const command = 'printf "HEAD\\n"; ( for i in 1 2 3 4 5 6; do sleep 0.05; printf "TICK$i\\n"; done ) &';
child = spawnProcess("/bin/sh", ["-c", command], {
stdio: ["ignore", "pipe", "pipe"],
detached: true,
}) as ChildProcessByStdio<null, Readable, Readable>;
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
const exitCode = await waitForChildProcess(child);
expect(exitCode).toBe(0);
expect(output).toContain("HEAD");
expect(output).toContain("TICK6");
});
it("resolves promptly when a detached child holds stdout open but stays quiet", async () => {
// The shell exits, but a backgrounded sleeper inherits the stdout pipe and
// keeps it open for a long time without writing. `close` never fires, so we
// must still release via the idle grace rather than hang on the open handle.
const command = 'printf "DONE\\n"; ( sleep 30 ) &';
child = spawnProcess("/bin/sh", ["-c", command], {
stdio: ["ignore", "pipe", "pipe"],
detached: true,
}) as ChildProcessByStdio<null, Readable, Readable>;
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
const start = Date.now();
const exitCode = await waitForChildProcess(child);
const elapsed = Date.now() - start;
expect(exitCode).toBe(0);
expect(output).toContain("DONE");
// Must not wait for the 30s sleeper; the idle grace releases us in well under a second.
expect(elapsed).toBeLessThan(2000);
});
});
@@ -0,0 +1,119 @@
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { LoginDialogComponent } from "../../../src/modes/interactive/components/login-dialog.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
vi.mock("../../../src/utils/open-browser.ts", () => ({
openBrowser: vi.fn(),
}));
function createDialog(): LoginDialogComponent {
return new LoginDialogComponent(
{ requestRender: vi.fn() } as unknown as TUI,
"prompt-repro",
() => {},
"Prompt Repro",
);
}
function renderDialog(dialog: LoginDialogComponent): string[] {
return stripAnsi(dialog.render(120).join("\n"))
.split("\n")
.map((line) => line.trimEnd());
}
function countRenderedValue(lines: string[], value: string): number {
return lines.filter((line) => line.trim() === `> ${value}`).length;
}
describe("LoginDialogComponent OAuth prompts", () => {
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
test("keeps previous prompt input stable when a later prompt is active", async () => {
const dialog = createDialog();
const firstPrompt = dialog.showPrompt("First prompt:", "first-value");
dialog.handleInput("first-value");
dialog.handleInput("\n");
await expect(firstPrompt).resolves.toBe("first-value");
const secondPrompt = dialog.showPrompt("Second prompt:");
dialog.handleInput("second-secret-demo");
const lines = renderDialog(dialog);
expect(lines.join("\n")).toContain("First prompt:");
expect(lines.join("\n")).toContain("Second prompt:");
expect(countRenderedValue(lines, "first-value")).toBe(1);
expect(countRenderedValue(lines, "second-secret-demo")).toBe(1);
dialog.handleInput("\n");
await expect(secondPrompt).resolves.toBe("second-secret-demo");
});
test("preserves auth instructions when showing a prompt", () => {
const dialog = createDialog();
dialog.showAuth("https://example.invalid/login", "Authorize the extension");
dialog.showPrompt("First prompt:");
const output = renderDialog(dialog).join("\n");
expect(output).toContain("https://example.invalid/login");
expect(output).toContain("Authorize the extension");
expect(output).toContain("First prompt:");
});
test("preserves neutral information and links when showing a prompt", () => {
const dialog = createDialog();
dialog.showInfo("Configure credentials outside pi.", [
{ label: "Provider documentation", url: "https://example.invalid/docs" },
]);
dialog.showPrompt("Press Enter to continue:");
const output = renderDialog(dialog).join("\n");
expect(output).toContain("Configure credentials outside pi.");
expect(output).toContain("Provider documentation: https://example.invalid/docs");
expect(output).toContain("Press Enter to continue:");
});
test("preserves setup details when showing a prompt", () => {
const dialog = createDialog();
dialog.showDetails(["AWS credential setup:", "providers.md"]);
dialog.showPrompt("Enter API key:");
const output = renderDialog(dialog).join("\n");
expect(output).toContain("AWS credential setup:");
expect(output).toContain("providers.md");
expect(output).toContain("Enter API key:");
});
test("keeps previous manual input stable when a later prompt is active", async () => {
const dialog = createDialog();
const manualInput = dialog.showManualInput("Paste callback URL:");
dialog.handleInput("callback-value");
dialog.handleInput("\n");
await expect(manualInput).resolves.toBe("callback-value");
const prompt = dialog.showPrompt("Second prompt:");
dialog.handleInput("second-secret-demo");
const lines = renderDialog(dialog);
expect(lines.join("\n")).toContain("Paste callback URL:");
expect(lines.join("\n")).toContain("Second prompt:");
expect(countRenderedValue(lines, "callback-value")).toBe(1);
expect(countRenderedValue(lines, "second-secret-demo")).toBe(1);
dialog.handleInput("\n");
await expect(prompt).resolves.toBe("second-secret-demo");
});
});
@@ -0,0 +1,90 @@
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Agent } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, registerFauxProvider, streamSimple } from "@earendil-works/pi-ai/compat";
import { afterEach, describe, expect, it } from "vitest";
import { AgentSession } from "../../../src/core/agent-session.ts";
import { AuthStorage } from "../../../src/core/auth-storage.ts";
import { convertToLlm } from "../../../src/core/messages.ts";
import { SessionManager } from "../../../src/core/session-manager.ts";
import { SettingsManager } from "../../../src/core/settings-manager.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { createInMemoryModelRegistry, getModelRuntime } from "../../model-runtime-test-utils.ts";
import { createTestResourceLoader } from "../../utilities.ts";
describe("regression #5596: missing configured theme export", () => {
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length > 0) {
cleanups.pop()?.();
}
initTheme("dark");
});
it("exports with the active fallback theme when the configured theme is missing", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-5596-"));
const faux = registerFauxProvider({
models: [{ id: "faux-1", reasoning: false }],
});
faux.setResponses([fauxAssistantMessage("hello")]);
const model = faux.getModel();
const authStorage = AuthStorage.inMemory();
await authStorage.modify(model.provider, async () => ({ type: "api_key", key: "faux-key" }));
const modelRegistry = await createInMemoryModelRegistry(authStorage);
modelRegistry.registerProvider(model.provider, {
baseUrl: model.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,
baseUrl: registeredModel.baseUrl,
})),
});
const settingsManager = SettingsManager.inMemory({ theme: "missing-theme" });
const sessionManager = SessionManager.create(tempDir, join(tempDir, "sessions"));
const agent = new Agent({
getApiKey: () => "faux-key",
initialState: {
model,
systemPrompt: "You are a test assistant.",
tools: [],
},
convertToLlm,
streamFn: streamSimple,
});
const session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRuntime: getModelRuntime(modelRegistry),
resourceLoader: createTestResourceLoader(),
});
cleanups.push(() => {
session.dispose();
faux.unregister();
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
await session.prompt("hi");
initTheme(settingsManager.getTheme());
const outputPath = join(tempDir, "export.html");
await expect(session.exportToHtml(outputPath)).resolves.toBe(outputPath);
expect(existsSync(outputPath)).toBe(true);
expect(settingsManager.getTheme()).toBe("missing-theme");
});
});
@@ -0,0 +1,91 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ENV_AGENT_DIR } from "../../../src/config.ts";
import { AuthStorage } from "../../../src/core/auth-storage.ts";
import { runMigrations } from "../../../src/migrations.ts";
import { createModelRegistry } from "../../model-runtime-test-utils.ts";
import { createHarness } from "../harness.ts";
describe("regression #5661: uppercase models.json header values", () => {
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length > 0) {
cleanups.pop()?.();
}
});
function withAgentDir(agentDir: string, fn: () => void): void {
const previousAgentDir = process.env[ENV_AGENT_DIR];
process.env[ENV_AGENT_DIR] = agentDir;
try {
fn();
} finally {
if (previousAgentDir === undefined) {
delete process.env[ENV_AGENT_DIR];
} else {
process.env[ENV_AGENT_DIR] = previousAgentDir;
}
}
}
it("keeps uppercase header strings as literals during startup migrations", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
cleanups.push(harness.cleanup);
const envKeys = ["CUSTOM_API_KEY", "BEARER"];
const savedEnv: Record<string, string | undefined> = {};
for (const key of envKeys) {
savedEnv[key] = process.env[key];
process.env[key] = `env-${key}`;
}
cleanups.push(() => {
for (const key of envKeys) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
});
const modelsPath = join(harness.tempDir, "models.json");
writeFileSync(
modelsPath,
`${JSON.stringify(
{
providers: {
"my-provider": {
baseUrl: "https://example.com/v1",
apiKey: "CUSTOM_API_KEY",
api: "openai-completions",
headers: { Authorization: "BEARER" },
models: [{ id: "my-model" }],
},
},
},
null,
2,
)}\n`,
"utf-8",
);
withAgentDir(harness.tempDir, () => runMigrations(harness.tempDir));
const migrated = JSON.parse(readFileSync(modelsPath, "utf-8")) as {
providers: Record<string, { apiKey?: string; headers?: Record<string, string> }>;
};
expect(migrated.providers["my-provider"]?.apiKey).toBe("CUSTOM_API_KEY");
expect(migrated.providers["my-provider"]?.headers?.Authorization).toBe("BEARER");
const registry = await createModelRegistry(AuthStorage.create(join(harness.tempDir, "auth.json")), modelsPath);
const model = registry.find("my-provider", "my-model");
expect(model).toBeDefined();
expect(await registry.getApiKeyAndHeaders(model!)).toMatchObject({
ok: true,
apiKey: "CUSTOM_API_KEY",
headers: { Authorization: "BEARER" },
});
});
});
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
// Regression for https://github.com/earendil-works/pi/issues/5724
//
// `proper-lockfile` installs `signal-exit`, whose signal listener re-sends
// SIGTERM/SIGHUP when it observes no other process listeners during the same
// signal dispatch. InteractiveMode must therefore keep its signal handlers
// registered until async terminal cleanup has completed.
type ShutdownThis = {
isShuttingDown: boolean;
unregisterSignalHandlers: () => void;
runtimeHost: { dispose: () => Promise<void> };
ui: { terminal: { drainInput: (ms: number) => Promise<void> } };
themeController: { disableAutoSync: () => void };
stop: () => void;
};
type InteractiveModePrototypeWithShutdown = {
shutdown(this: ShutdownThis, options?: { fromSignal?: boolean }): Promise<void>;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown;
class ProcessExitError extends Error {}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve: (() => void) | undefined;
const promise = new Promise<void>((res) => {
resolve = res;
});
return {
promise,
resolve: () => resolve?.(),
};
}
async function callShutdown(context: ShutdownThis, options?: { fromSignal?: boolean }): Promise<void> {
try {
await (interactiveModePrototype as InteractiveModePrototypeWithShutdown).shutdown.call(context, options);
} catch (error) {
if (!(error instanceof ProcessExitError)) throw error;
}
}
describe("InteractiveMode SIGTERM shutdown with signal-exit (#5724)", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("keeps signal handlers registered while signal-triggered cleanup is pending", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const order: string[] = [];
const dispose = deferred();
const context: ShutdownThis = {
isShuttingDown: false,
unregisterSignalHandlers: vi.fn(() => {
order.push("unregister");
}),
runtimeHost: {
dispose: vi.fn(() => {
order.push("dispose");
return dispose.promise;
}),
},
ui: {
terminal: {
drainInput: vi.fn(async () => {
order.push("drainInput");
}),
},
},
themeController: { disableAutoSync: vi.fn() },
stop: vi.fn(() => {
order.push("stop");
}),
};
const shutdownPromise = callShutdown(context, { fromSignal: true });
await Promise.resolve();
expect(order).toEqual(["dispose"]);
expect(context.unregisterSignalHandlers).not.toHaveBeenCalled();
dispose.resolve();
await shutdownPromise;
expect(order).toEqual(["dispose", "drainInput", "stop"]);
});
});
@@ -0,0 +1,113 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentSessionRuntime } from "../../../src/core/agent-session-runtime.ts";
import { runRpcMode } from "../../../src/modes/rpc/rpc-mode.ts";
import { createHarness, type Harness } from "../harness.ts";
// Regression for https://github.com/earendil-works/pi/issues/5868
const rpcIo = vi.hoisted(() => ({
outputLines: [] as string[],
lineHandler: undefined as ((line: string) => void) | undefined,
}));
vi.mock("../../../src/core/output-guard.js", () => ({
flushRawStdout: vi.fn(async () => {}),
takeOverStdout: vi.fn(),
waitForRawStdoutBackpressure: vi.fn(async () => {}),
writeRawStdout: (line: string) => {
rpcIo.outputLines.push(line);
},
}));
vi.mock("../../../src/modes/interactive/theme/theme.js", () => ({ theme: {} }));
vi.mock("../../../src/modes/rpc/jsonl.js", () => ({
attachJsonlLineReader: vi.fn((_stream: NodeJS.ReadableStream, onLine: (line: string) => void) => {
rpcIo.lineHandler = onLine;
return () => {
rpcIo.lineHandler = undefined;
};
}),
serializeJsonLine: (value: unknown) => `${JSON.stringify(value)}\n`,
}));
type NodeListener = Parameters<typeof process.on>[1];
type ListenerSnapshot = {
stdinEnd: NodeListener[];
signals: Map<NodeJS.Signals, NodeListener[]>;
};
function takeListenerSnapshot(): ListenerSnapshot {
const signals: NodeJS.Signals[] = process.platform === "win32" ? ["SIGTERM"] : ["SIGTERM", "SIGHUP"];
return {
stdinEnd: process.stdin.listeners("end") as NodeListener[],
signals: new Map(signals.map((signal) => [signal, process.listeners(signal) as NodeListener[]])),
};
}
function restoreListeners(snapshot: ListenerSnapshot): void {
for (const listener of process.stdin.listeners("end") as NodeListener[]) {
if (!snapshot.stdinEnd.includes(listener)) {
process.stdin.off("end", listener);
}
}
for (const [signal, previousListeners] of snapshot.signals) {
for (const listener of process.listeners(signal) as NodeListener[]) {
if (!previousListeners.includes(listener)) {
process.off(signal, listener);
}
}
}
}
function parseOutputLines(): Array<Record<string, unknown>> {
return rpcIo.outputLines
.flatMap((line) => line.split("\n"))
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
function createRuntimeHost(harness: Harness): AgentSessionRuntime {
return {
session: harness.session,
newSession: vi.fn(async () => ({ cancelled: true })),
switchSession: vi.fn(async () => ({ cancelled: true })),
fork: vi.fn(async () => ({ cancelled: true, selectedText: "" })),
dispose: vi.fn(async () => {}),
setRebindSession: vi.fn(),
} as unknown as AgentSessionRuntime;
}
describe("RPC unknown command responses (#5868)", () => {
afterEach(() => {
rpcIo.outputLines = [];
rpcIo.lineHandler = undefined;
});
test("preserves the request id on unknown command errors", async () => {
const listenerSnapshot = takeListenerSnapshot();
const harness = await createHarness();
try {
void runRpcMode(createRuntimeHost(harness));
await vi.waitFor(() => expect(rpcIo.lineHandler).toBeDefined());
rpcIo.lineHandler?.(JSON.stringify({ id: "test", type: "foobar" }));
await vi.waitFor(() => {
expect(parseOutputLines()).toContainEqual({
id: "test",
type: "response",
command: "foobar",
success: false,
error: "Unknown command: foobar",
});
});
} finally {
harness.cleanup();
restoreListeners(listenerSnapshot);
}
});
});
@@ -0,0 +1,519 @@
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { Container, Text } from "@earendil-works/pi-tui";
import { describe, expect, it, vi } from "vitest";
import type { AgentSessionEvent } from "../../../src/core/agent-session.ts";
import type { ExtensionUIContext } from "../../../src/core/extensions/index.ts";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
import { initTheme, type Theme, theme } from "../../../src/modes/interactive/theme/theme.ts";
import { createHarness } from "../harness.ts";
function createUiContext(
onNotify: (message: string, type: "info" | "warning" | "error" | undefined) => void,
): ExtensionUIContext {
return {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
notify: onNotify,
onTerminalInput: () => () => {},
setStatus: () => {},
setWorkingMessage: () => {},
setWorkingVisible: () => {},
setWorkingIndicator: () => {},
setHiddenThinkingLabel: () => {},
setWidget: () => {},
setFooter: () => {},
setHeader: () => {},
setTitle: () => {},
custom: async <T>() => undefined as T,
pasteToEditor: () => {},
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
addAutocompleteProvider: () => {},
setEditorComponent: () => {},
getEditorComponent: () => undefined,
get theme() {
return theme;
},
getAllThemes: () => [],
getTheme: () => undefined,
setTheme: (_theme: string | Theme) => ({ success: false, error: "Theme switching not available in tests" }),
getToolsExpanded: () => false,
setToolsExpanded: () => {},
};
}
type LoadedResourcesResult<T> = { [K in keyof T]: T[K] } & { diagnostics: [] };
type LoadedResourcesContext = {
loadedResourcesContainer: Container;
chatContainer: Container;
options: { verbose?: boolean };
settingsManager: { getQuietStartup: () => boolean };
sessionManager: { getCwd: () => string };
session: {
promptTemplates: [];
resourceLoader: {
getAgentsFiles: () => LoadedResourcesResult<{ agentsFiles: Array<{ path: string }> }>;
getSystemPromptSource: () => { path: string } | undefined;
getAppendSystemPromptSources: () => Array<{ path: string }>;
getSkills: () => LoadedResourcesResult<{ skills: [] }>;
getPrompts: () => LoadedResourcesResult<{ prompts: [] }>;
getThemes: () => LoadedResourcesResult<{ themes: [] }>;
getExtensions: () => { extensions: []; errors: [] };
};
extensionRunner: {
getCommandDiagnostics: () => [];
getShortcutDiagnostics: () => [];
getRegisteredCommands: () => [];
};
};
getStartupExpansionState: () => boolean;
formatDisplayPath: (resourcePath: string) => string;
formatContextPath: (resourcePath: string) => string;
getBuiltInCommandConflictDiagnostics: (extensionRunner: LoadedResourcesContext["session"]["extensionRunner"]) => [];
};
type RebindContext = {
unsubscribe?: () => void;
applyRuntimeSettings: () => void;
renderCurrentSessionState: () => void;
bindCurrentSessionExtensions: () => Promise<void>;
subscribeToAgent: () => void;
updateAvailableProviderCount: () => Promise<void>;
updateEditorBorderColor: () => void;
updateTerminalTitle: () => void;
};
type ReloadCommandContext = {
hideThinkingBlock: boolean;
session: {
isStreaming: boolean;
isCompacting: boolean;
reload: (options?: { beforeSessionStart?: () => void | Promise<void> }) => Promise<void>;
resourceLoader: { getThemes: () => { themes: [] } };
extensionRunner: unknown;
modelRegistry: { getError: () => string | undefined };
};
settingsManager: {
getHttpIdleTimeoutMs: () => number;
getHideThinkingBlock: () => boolean;
getOutputPad: () => 0 | 1;
getEditorPaddingX: () => number;
getAutocompleteMaxVisible: () => number;
getShowHardwareCursor: () => boolean;
getClearOnShrink: () => boolean;
};
keybindings: { reload: () => void };
customHeader?: unknown;
builtInHeader?: unknown;
editorContainer: { clear: () => void; addChild: (component: unknown) => void };
ui: {
setFocus: (component: unknown) => void;
requestRender: (force?: boolean) => void;
setShowHardwareCursor: (enabled: boolean) => void;
setClearOnShrink: (enabled: boolean) => void;
};
editor: unknown;
defaultEditor: { setPaddingX: (padding: number) => void; setAutocompleteMaxVisible: (maxVisible: number) => void };
themeController: { applyFromSettings: () => Promise<void> };
resetExtensionUI: () => void;
rebuildChatFromMessages: () => void;
setupAutocompleteProvider: () => void;
setupExtensionShortcuts: (runner: unknown) => void;
showLoadedResources: (options: unknown) => void;
maybeSaveImplicitProjectTrustAfterReload: () => boolean;
showStatus: (message: string) => void;
showWarning: (message: string) => void;
showError: (message: string) => void;
};
type InteractiveModePrototype = {
showLoadedResources(
this: LoadedResourcesContext,
options?: { extensions?: Array<{ path: string }>; force?: boolean; showDiagnosticsWhenQuiet?: boolean },
): void;
rebindCurrentSession(this: RebindContext, options?: { renderBeforeBind?: boolean }): Promise<void>;
handleReloadCommand(this: ReloadCommandContext): Promise<void>;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype;
type ReloadCommandContextOverrides = Omit<
Partial<ReloadCommandContext>,
"session" | "settingsManager" | "keybindings" | "editorContainer" | "ui" | "defaultEditor" | "themeController"
> & {
session?: Partial<ReloadCommandContext["session"]>;
settingsManager?: Partial<ReloadCommandContext["settingsManager"]>;
keybindings?: Partial<ReloadCommandContext["keybindings"]>;
editorContainer?: Partial<ReloadCommandContext["editorContainer"]>;
ui?: Partial<ReloadCommandContext["ui"]>;
defaultEditor?: Partial<ReloadCommandContext["defaultEditor"]>;
themeController?: Partial<ReloadCommandContext["themeController"]>;
};
function createReloadCommandContext(overrides: ReloadCommandContextOverrides = {}): ReloadCommandContext {
const editor = overrides.editor ?? {};
return {
hideThinkingBlock: overrides.hideThinkingBlock ?? false,
session: {
isStreaming: false,
isCompacting: false,
reload: async (options) => {
await options?.beforeSessionStart?.();
},
resourceLoader: { getThemes: () => ({ themes: [] }) },
extensionRunner: {},
modelRegistry: { getError: () => undefined },
...overrides.session,
},
settingsManager: {
getHttpIdleTimeoutMs: () => 0,
getHideThinkingBlock: () => false,
getOutputPad: () => 1,
getEditorPaddingX: () => 1,
getAutocompleteMaxVisible: () => 10,
getShowHardwareCursor: () => false,
getClearOnShrink: () => false,
...overrides.settingsManager,
},
keybindings: { reload: () => {}, ...overrides.keybindings },
editorContainer: { clear: () => {}, addChild: () => {}, ...overrides.editorContainer },
ui: {
setFocus: () => {},
requestRender: () => {},
setShowHardwareCursor: () => {},
setClearOnShrink: () => {},
...overrides.ui,
},
editor,
defaultEditor: { setPaddingX: () => {}, setAutocompleteMaxVisible: () => {}, ...overrides.defaultEditor },
themeController: { applyFromSettings: async () => {}, ...overrides.themeController },
customHeader: overrides.customHeader,
builtInHeader: overrides.builtInHeader,
resetExtensionUI: overrides.resetExtensionUI ?? (() => {}),
rebuildChatFromMessages: overrides.rebuildChatFromMessages ?? (() => {}),
setupAutocompleteProvider: overrides.setupAutocompleteProvider ?? (() => {}),
setupExtensionShortcuts: overrides.setupExtensionShortcuts ?? (() => {}),
showLoadedResources: overrides.showLoadedResources ?? (() => {}),
maybeSaveImplicitProjectTrustAfterReload: overrides.maybeSaveImplicitProjectTrustAfterReload ?? (() => false),
showStatus: overrides.showStatus ?? (() => {}),
showWarning: overrides.showWarning ?? (() => {}),
showError: overrides.showError ?? (() => {}),
};
}
type MessageEvent = Extract<AgentSessionEvent, { type: "message_start" | "message_end" }>;
function getMessageText(event: MessageEvent): string {
const message = event.message;
if (!("content" in message)) {
return "";
}
const content = message.content;
if (typeof content === "string") {
return content;
}
return content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("");
}
function createLoadedResourcesContext(): LoadedResourcesContext {
return {
loadedResourcesContainer: new Container(),
chatContainer: new Container(),
options: { verbose: true },
settingsManager: { getQuietStartup: () => false },
sessionManager: { getCwd: () => "/repo" },
session: {
promptTemplates: [],
resourceLoader: {
getAgentsFiles: () => ({ agentsFiles: [{ path: "/repo/AGENTS.md" }], diagnostics: [] }),
getSystemPromptSource: () => undefined,
getAppendSystemPromptSources: () => [],
getSkills: () => ({ skills: [], diagnostics: [] }),
getPrompts: () => ({ prompts: [], diagnostics: [] }),
getThemes: () => ({ themes: [], diagnostics: [] }),
getExtensions: () => ({ extensions: [], errors: [] }),
},
extensionRunner: {
getCommandDiagnostics: () => [],
getShortcutDiagnostics: () => [],
getRegisteredCommands: () => [],
},
},
getStartupExpansionState: () => false,
formatDisplayPath: (resourcePath) => resourcePath,
formatContextPath: (resourcePath) => resourcePath.replace("/repo/", ""),
getBuiltInCommandConflictDiagnostics: () => [],
};
}
describe("regression #5943: session_start transient UI", () => {
it("renders loaded resources before restored messages without stale entries", () => {
initTheme("dark", false);
const context = createLoadedResourcesContext();
const root = new Container();
root.addChild(context.loadedResourcesContainer);
root.addChild(context.chatContainer);
context.loadedResourcesContainer.addChild(new Text("stale resources", 0, 0));
context.chatContainer.addChild(new Text("restored message", 0, 0));
interactiveModePrototype.showLoadedResources.call(context);
const chatRendered = context.chatContainer.render(80).join("\n");
expect(chatRendered).toContain("restored message");
expect(chatRendered).not.toContain("[Context]");
const rendered = root.render(80).join("\n");
expect(rendered).not.toContain("stale resources");
expect(rendered.indexOf("[Context]")).toBeLessThan(rendered.indexOf("restored message"));
});
it("renders replacement session state before session_start handlers can notify", async () => {
const events: string[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_start", (_event, ctx) => {
ctx.ui.notify("Hello Error", "error");
});
},
],
});
try {
const context: RebindContext = {
applyRuntimeSettings: () => events.push("apply"),
renderCurrentSessionState: () => events.push("render"),
bindCurrentSessionExtensions: async () => {
events.push("bind");
await harness.session.bindExtensions({
uiContext: createUiContext((message) => events.push(`notify:${message}`)),
mode: "tui",
});
},
subscribeToAgent: () => events.push("subscribe"),
updateAvailableProviderCount: async () => {},
updateEditorBorderColor: () => {},
updateTerminalTitle: () => {},
};
await interactiveModePrototype.rebindCurrentSession.call(context, { renderBeforeBind: true });
expect(events).toEqual(["apply", "render", "subscribe", "bind", "notify:Hello Error"]);
} finally {
harness.cleanup();
}
});
it("subscribes before replacement session_start handlers send messages", async () => {
const events: string[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_start", () => {
pi.sendMessage({
customType: "session-start",
content: "custom from start",
display: true,
});
});
},
],
});
try {
const context: RebindContext = {
applyRuntimeSettings: () => {},
renderCurrentSessionState: () => events.push("render"),
bindCurrentSessionExtensions: async () => {
events.push("bind");
await harness.session.bindExtensions({
uiContext: createUiContext(() => {}),
mode: "tui",
});
},
subscribeToAgent: () => {
events.push("subscribe");
harness.session.subscribe((event) => {
if (event.type !== "message_start" && event.type !== "message_end") {
return;
}
events.push(`${event.type}:${event.message.role}:${getMessageText(event)}`);
});
},
updateAvailableProviderCount: async () => {},
updateEditorBorderColor: () => {},
updateTerminalTitle: () => {},
};
await interactiveModePrototype.rebindCurrentSession.call(context, { renderBeforeBind: true });
expect(events).toEqual([
"render",
"subscribe",
"bind",
"message_start:custom:custom from start",
"message_end:custom:custom from start",
]);
} finally {
harness.cleanup();
}
});
it("subscribes before replacement session_start handlers send user messages", async () => {
const events: string[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_start", () => {
pi.sendUserMessage("user from start");
});
},
],
});
harness.setResponses([fauxAssistantMessage("assistant from start")]);
try {
const context: RebindContext = {
applyRuntimeSettings: () => {},
renderCurrentSessionState: () => events.push("render"),
bindCurrentSessionExtensions: async () => {
events.push("bind");
await harness.session.bindExtensions({
uiContext: createUiContext(() => {}),
mode: "tui",
});
},
subscribeToAgent: () => {
events.push("subscribe");
harness.session.subscribe((event) => {
if (event.type !== "message_start" && event.type !== "message_end") {
return;
}
events.push(`${event.type}:${event.message.role}:${getMessageText(event)}`);
});
},
updateAvailableProviderCount: async () => {},
updateEditorBorderColor: () => {},
updateTerminalTitle: () => {},
};
await interactiveModePrototype.rebindCurrentSession.call(context, { renderBeforeBind: true });
await harness.session.agent.waitForIdle();
expect(events.slice(0, 3)).toEqual(["render", "subscribe", "bind"]);
expect(events).toContain("message_start:user:user from start");
expect(events).toContain("message_end:user:user from start");
expect(events).toContain("message_end:assistant:assistant from start");
} finally {
harness.cleanup();
}
});
it("runs the reload render hook before reload session_start handlers can notify", async () => {
const events: string[] = [];
const beforeSessionStart = vi.fn(() => {
events.push("render");
});
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_start", (event, ctx) => {
events.push(`start:${event.reason}`);
ctx.ui.notify(`notify:${event.reason}`, "error");
});
},
],
});
try {
await harness.session.bindExtensions({
uiContext: createUiContext((message) => events.push(message)),
mode: "tui",
});
expect(events).toEqual(["start:startup", "notify:startup"]);
events.length = 0;
await harness.session.reload({ beforeSessionStart });
expect(beforeSessionStart).toHaveBeenCalledTimes(1);
expect(events).toEqual(["render", "start:reload", "notify:reload"]);
} finally {
harness.cleanup();
}
});
it("refreshes hideThinkingBlock before rebuilding chat during reload", async () => {
initTheme("dark", false);
const events: string[] = [];
let context: ReloadCommandContext;
context = createReloadCommandContext({
settingsManager: { getHideThinkingBlock: () => true },
session: {
reload: async (options) => {
events.push("reload");
await options?.beforeSessionStart?.();
events.push(`start:${context.hideThinkingBlock}`);
},
},
rebuildChatFromMessages: () => {
events.push(`rebuild:${context.hideThinkingBlock}`);
},
});
await interactiveModePrototype.handleReloadCommand.call(context);
expect(context.hideThinkingBlock).toBe(true);
expect(events).toEqual(["reload", "rebuild:true", "start:true"]);
});
it("keeps the reload blocker focused until async reload completes", async () => {
initTheme("dark", false);
const editor = {};
let focused: unknown;
let chatRestored = false;
let markReloadWaiting!: () => void;
let finishReload!: () => void;
const reloadWaiting = new Promise<void>((resolve) => {
markReloadWaiting = resolve;
});
const reloadFinished = new Promise<void>((resolve) => {
finishReload = resolve;
});
const context = createReloadCommandContext({
editor,
session: {
reload: async (options) => {
await options?.beforeSessionStart?.();
markReloadWaiting();
await reloadFinished;
},
},
ui: {
setFocus: (component) => {
focused = component;
},
},
rebuildChatFromMessages: () => {
chatRestored = true;
},
});
const reloadPromise = interactiveModePrototype.handleReloadCommand.call(context);
await reloadWaiting;
expect(chatRestored).toBe(true);
expect(focused).not.toBe(editor);
finishReload();
await reloadPromise;
expect(focused).toBe(editor);
});
});
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it } from "vitest";
import type { ExtensionAPI } from "../../../src/index.ts";
import { createHarness, type Harness } from "../harness.ts";
describe("regression #5996: session names do not contain newlines", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("filters newlines when AgentSession.setSessionName is called", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.session.setSessionName("hello\nworld\r\nagain");
expect(harness.sessionManager.getSessionName()).toBe("hello world again");
expect(harness.eventsOfType("session_info_changed").map((event) => event.name)).toEqual(["hello world again"]);
});
it("filters newlines when an extension calls pi.setSessionName", async () => {
let api: ExtensionAPI | undefined;
const harness = await createHarness({
extensionFactories: [
(pi) => {
api = pi;
},
],
});
harnesses.push(harness);
api?.setSessionName("from\nextension");
expect(harness.sessionManager.getSessionName()).toBe("from extension");
expect(harness.eventsOfType("session_info_changed").map((event) => event.name)).toEqual(["from extension"]);
});
});
@@ -0,0 +1,31 @@
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { createHarness } from "../harness.ts";
const openAIExplicitRetryMessage =
"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_******** in your message.";
const bedrockExplicitRetryMessage =
'{"message":"The system encountered an unexpected error during processing. Try your request again."}';
describe("regression: issue 6019 explicit provider retry messages", () => {
it.each([
["openai", openAIExplicitRetryMessage],
["bedrock", bedrockExplicitRetryMessage],
])("retries %s explicit retry guidance", async (_provider, errorMessage) => {
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
try {
harness.setResponses([
fauxAssistantMessage("", { stopReason: "error", errorMessage }),
fauxAssistantMessage("recovered"),
]);
await harness.session.prompt("test");
expect(harness.faux.state.callCount).toBe(2);
expect(harness.eventsOfType("auto_retry_start").map((event) => event.errorMessage)).toEqual([errorMessage]);
expect(harness.eventsOfType("auto_retry_end").map((event) => event.success)).toEqual([true]);
} finally {
harness.cleanup();
}
});
});
@@ -0,0 +1,192 @@
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import type { ExtensionFactory } from "../../../src/index.ts";
import { createHarness } from "../harness.ts";
describe("extension active tools next-turn refresh", () => {
it("applies pi.setActiveTools before the next provider request in the same run", async () => {
const extensionFactories: ExtensionFactory[] = [
(pi) => {
pi.registerTool({
name: "switch_tools",
label: "Switch Tools",
description: "Switch the active extension tool set",
promptSnippet: "Switch to the next extension tool",
parameters: Type.Object({}),
execute: async () => {
pi.setActiveTools(["after_switch"]);
return {
content: [{ type: "text", text: "switched" }],
details: {},
};
},
});
pi.registerTool({
name: "after_switch",
label: "After Switch",
description: "Tool that should be available after switching",
promptSnippet: "Run after the active tool set changes",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "after" }],
details: {},
}),
});
},
];
const harness = await createHarness({
extensionFactories,
});
try {
harness.session.setActiveToolsByName(["switch_tools"]);
const providerToolNames: string[][] = [];
harness.setResponses([
(context) => {
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
return fauxAssistantMessage(fauxToolCall("switch_tools", {}), { stopReason: "toolUse" });
},
(context) => {
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
return fauxAssistantMessage("done");
},
]);
expect(harness.session.getActiveToolNames()).toEqual(["switch_tools"]);
await harness.session.prompt("start");
expect(harness.session.getActiveToolNames()).toEqual(["after_switch"]);
expect(providerToolNames).toEqual([["switch_tools"], ["after_switch"]]);
} finally {
harness.cleanup();
}
});
it("records additive active tool changes on the current tool result", async () => {
const extensionFactories: ExtensionFactory[] = [
(pi) => {
pi.registerTool({
name: "load_more_tools",
label: "Load More Tools",
description: "Load more tools",
parameters: Type.Object({}),
execute: async () => {
pi.setActiveTools([...pi.getActiveTools(), "after_load"]);
return {
content: [{ type: "text", text: "loaded" }],
details: {},
};
},
});
pi.registerTool({
name: "after_load",
label: "After Load",
description: "Tool available after loading",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "after" }],
details: {},
}),
});
},
];
const harness = await createHarness({ extensionFactories });
try {
harness.session.setActiveToolsByName(["load_more_tools"]);
const addedToolNames: string[][] = [];
harness.setResponses([
() => fauxAssistantMessage(fauxToolCall("load_more_tools", {}), { stopReason: "toolUse" }),
(context) => {
addedToolNames.push(
context.messages
.filter((message) => message.role === "toolResult")
.flatMap((message) => message.addedToolNames ?? []),
);
return fauxAssistantMessage("done");
},
]);
await harness.session.prompt("start");
expect(harness.session.getActiveToolNames()).toEqual(["load_more_tools", "after_load"]);
expect(addedToolNames).toEqual([["after_load"]]);
} finally {
harness.cleanup();
}
});
it("preserves before_agent_start system prompt overrides when tools change mid-run", async () => {
const extensionFactories: ExtensionFactory[] = [
(pi) => {
pi.on("before_agent_start", async (event) => ({
systemPrompt: `${event.systemPrompt}\n\nkeep this run override`,
}));
pi.registerTool({
name: "switch_tools",
label: "Switch Tools",
description: "Switch the active extension tool set",
promptSnippet: "Switch to the next extension tool",
parameters: Type.Object({}),
execute: async () => {
pi.setActiveTools(["after_switch"]);
return {
content: [{ type: "text", text: "switched" }],
details: {},
};
},
});
pi.registerTool({
name: "after_switch",
label: "After Switch",
description: "Tool that should be available after switching",
promptSnippet: "Run after the active tool set changes",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "after" }],
details: {},
}),
});
},
];
const harness = await createHarness({
extensionFactories,
});
try {
harness.session.setActiveToolsByName(["switch_tools"]);
const providerSystemPrompts: string[] = [];
const providerToolNames: string[][] = [];
harness.setResponses([
(context) => {
providerSystemPrompts.push(context.systemPrompt ?? "");
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
return fauxAssistantMessage(fauxToolCall("switch_tools", {}), { stopReason: "toolUse" });
},
(context) => {
providerSystemPrompts.push(context.systemPrompt ?? "");
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
return fauxAssistantMessage("done");
},
]);
await harness.session.prompt("start");
expect(providerToolNames).toEqual([["switch_tools"], ["after_switch"]]);
expect(providerSystemPrompts).toHaveLength(2);
expect(providerSystemPrompts[0]).toContain("keep this run override");
expect(providerSystemPrompts[1]).toContain("keep this run override");
} finally {
harness.cleanup();
}
});
});
@@ -0,0 +1,119 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts";
import type { ExtensionAPI } from "../../../src/index.ts";
const noop: (pi: ExtensionAPI) => void = () => {};
describe("inline extension naming", () => {
const roots: string[] = [];
function fixture(name: string) {
const root = join(tmpdir(), `pi-inline-naming-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
const cwd = join(root, "project");
const agentDir = join(root, "agent");
mkdirSync(cwd, { recursive: true });
mkdirSync(agentDir, { recursive: true });
roots.push(root);
return { root, cwd, agentDir };
}
beforeEach(() => {
roots.length = 0;
});
afterEach(() => {
while (roots.length > 0) {
const root = roots.pop();
if (root && existsSync(root)) {
rmSync(root, { recursive: true, force: true });
}
}
});
it("displays bare factories as <inline:N>", async () => {
const { cwd, agentDir } = fixture("bare");
const loader = new DefaultResourceLoader({
cwd,
agentDir,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
extensionFactories: [noop, noop],
});
await loader.reload();
const result = loader.getExtensions();
expect(result.extensions).toHaveLength(2);
expect(result.extensions[0].path).toBe("<inline:1>");
expect(result.extensions[1].path).toBe("<inline:2>");
});
it("displays named wrappers as <inline:name>", async () => {
const { cwd, agentDir } = fixture("named");
const loader = new DefaultResourceLoader({
cwd,
agentDir,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
extensionFactories: [
{ name: "my-provider", factory: noop },
{ name: "my-commands", factory: noop },
],
});
await loader.reload();
const result = loader.getExtensions();
expect(result.extensions).toHaveLength(2);
expect(result.extensions[0].path).toBe("<inline:my-provider>");
expect(result.extensions[1].path).toBe("<inline:my-commands>");
});
it("preserves hidden state for named factories", async () => {
const { cwd, agentDir } = fixture("hidden");
const loader = new DefaultResourceLoader({
cwd,
agentDir,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
extensionFactories: [{ name: "built-in", factory: noop, hidden: true }],
});
await loader.reload();
const result = loader.getExtensions();
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].path).toBe("<inline:built-in>");
expect(result.extensions[0].hidden).toBe(true);
});
it("supports mixed bare and named factories", async () => {
const { cwd, agentDir } = fixture("mixed");
const loader = new DefaultResourceLoader({
cwd,
agentDir,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
extensionFactories: [noop, { name: "named-ext", factory: noop }, noop],
});
await loader.reload();
const result = loader.getExtensions();
expect(result.extensions).toHaveLength(3);
expect(result.extensions[0].path).toBe("<inline:1>");
expect(result.extensions[1].path).toBe("<inline:named-ext>");
expect(result.extensions[2].path).toBe("<inline:3>");
});
});
@@ -0,0 +1,62 @@
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { assistantMsg, userMsg } from "../../utilities.ts";
import { createHarness, type Harness } from "../harness.ts";
describe("issue #6324 branch summary ambient auth", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("summarizes tree branches when request auth has no API key", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
let streamCallCount = 0;
harness.session.agent.streamFunction = (model, _context, options) => {
streamCallCount++;
expect(options?.apiKey).toBeUndefined();
const stream = createAssistantMessageEventStream();
stream.push({
type: "done",
reason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "branch summary text" }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.25 },
},
stopReason: "stop",
timestamp: Date.now(),
},
});
return stream;
};
const targetId = harness.sessionManager.appendMessage(userMsg("first branch"));
harness.sessionManager.appendMessage(assistantMsg("first reply"));
harness.sessionManager.appendMessage(userMsg("abandoned branch work"));
harness.sessionManager.appendMessage(assistantMsg("abandoned reply"));
const result = await harness.session.navigateTree(targetId, { summarize: true });
expect(result.cancelled).toBe(false);
expect(streamCallCount).toBe(1);
expect(result.summaryEntry?.type).toBe("branch_summary");
expect(result.summaryEntry?.summary).toContain("branch summary text");
expect(result.summaryEntry?.usage?.cost.total).toBe(0.25);
});
});
@@ -0,0 +1,158 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, getUserTexts, type Harness } from "../harness.ts";
function createWaitTool(released: Promise<void>): AgentTool {
return {
name: "wait",
label: "Wait",
description: "Wait until released",
parameters: Type.Object({}),
execute: async () => {
await released;
return { content: [{ type: "text", text: "released" }], details: {} };
},
};
}
describe("regression #6363: agent settled event and idle waiting", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("emits one agent_settled event after automatic retry finishes", async () => {
const extensionEvents: string[] = [];
const publicEvents: string[] = [];
const harness = await createHarness({
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
extensionFactories: [
(pi) => {
pi.on("agent_end", () => {
extensionEvents.push("agent_end");
});
pi.on("agent_settled", (_event, ctx) => {
extensionEvents.push(`agent_settled:${ctx.isIdle()}`);
});
},
],
});
harnesses.push(harness);
harness.session.subscribe((event) => {
if (event.type === "agent_settled") {
publicEvents.push("agent_settled");
}
});
harness.setResponses([
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
fauxAssistantMessage("recovered"),
]);
await harness.session.prompt("test");
expect(harness.eventsOfType("agent_end").map((event) => event.willRetry)).toEqual([true, false]);
expect(harness.eventsOfType("agent_settled")).toHaveLength(1);
expect(extensionEvents).toEqual(["agent_end", "agent_end", "agent_settled:true"]);
expect(publicEvents).toEqual(["agent_settled"]);
});
it("settles only after follow-ups queued by agent_end handlers run", async () => {
let queuedFollowUp = false;
const settledIdleStates: boolean[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("agent_end", () => {
if (queuedFollowUp) return;
queuedFollowUp = true;
pi.sendUserMessage("status follow-up", { deliverAs: "followUp" });
});
pi.on("agent_settled", (_event, ctx) => {
settledIdleStates.push(ctx.isIdle());
});
},
],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("first"), fauxAssistantMessage("second")]);
await harness.session.prompt("hello");
expect(getUserTexts(harness)).toEqual(["hello", "status follow-up"]);
expect(harness.eventsOfType("agent_end")).toHaveLength(2);
expect(harness.eventsOfType("agent_settled")).toHaveLength(1);
expect(settledIdleStates).toEqual([true]);
});
it("extension command waitForIdle waits for session-level settlement", async () => {
let releaseTool = () => {};
const released = new Promise<void>((resolve) => {
releaseTool = resolve;
});
let markCommandStarted = () => {};
const commandStarted = new Promise<void>((resolve) => {
markCommandStarted = resolve;
});
const commandResults: boolean[] = [];
const harness = await createHarness({
tools: [createWaitTool(released)],
extensionFactories: [
(pi) => {
pi.registerCommand("after-idle", {
description: "Wait for idle",
handler: async (_args, ctx) => {
markCommandStarted();
await ctx.waitForIdle();
commandResults.push(ctx.isIdle());
},
});
},
],
});
harnesses.push(harness);
await harness.session.bindExtensions({
commandContextActions: {
waitForIdle: () => harness.session.waitForIdle(),
newSession: async () => ({ cancelled: false }),
fork: async () => ({ cancelled: false }),
navigateTree: async () => ({ cancelled: false }),
switchSession: async () => ({ cancelled: false }),
reload: async () => {},
},
});
const toolStarted = new Promise<void>((resolve) => {
const unsubscribe = harness.session.subscribe((event) => {
if (event.type === "tool_execution_start" && event.toolName === "wait") {
unsubscribe();
resolve();
}
});
});
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
]);
const promptPromise = harness.session.prompt("start");
await toolStarted;
const commandPromise = harness.session.prompt("/after-idle");
await commandStarted;
let commandFinished = false;
void commandPromise.then(() => {
commandFinished = true;
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(commandFinished).toBe(false);
releaseTool();
await Promise.all([promptPromise, commandPromise]);
expect(commandResults).toEqual([true]);
expect(harness.eventsOfType("agent_settled")).toHaveLength(1);
});
});
@@ -0,0 +1,192 @@
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { type AssistantMessage, createAssistantMessageEventStream, fauxAssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, type Harness } from "../harness.ts";
/**
* Regression for #6647: compaction runs a single non-retried summarization call, so a
* transient mid-stream socket death (`terminated`) failed the whole compaction.
* Verifies that summarization now reuses `settings.retry` (bounded retries with
* exponential backoff gated on isRetryableAssistantError), emits
* `summarization_retry_*` events, and that aborts / non-retryable errors are not retried.
*/
describe("#6647 compaction retries transient summarization failures", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
function createUsage(totalTokens: number) {
return {
input: totalTokens,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function seedCompactableSession(harness: Harness): void {
harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const now = Date.now();
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "message to compact" }],
timestamp: now - 1000,
});
const model = harness.getModel();
const assistant: AssistantMessage = {
...fauxAssistantMessage("", { stopReason: "stop", timestamp: now - 500 }),
api: model.api,
provider: model.provider,
model: model.id,
usage: createUsage(100),
};
assistant.content = [{ type: "text", text: "assistant response to compact" }];
harness.sessionManager.appendMessage(assistant);
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
}
/** streamFn that responds with the given sequence of assistant messages across calls. */
function useScriptedStreamFn(harness: Harness, script: AssistantMessage[]): () => number {
let callCount = 0;
const streamFunction: StreamFn = (model) => {
const message = script[callCount] ?? script[script.length - 1]!;
callCount++;
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
const response = { ...message, api: model.api, provider: model.provider, model: model.id };
if (response.stopReason === "pending") {
const error: AssistantMessage = {
...response,
stopReason: "error",
errorMessage: "Scripted response ended without a stop reason",
};
stream.push({ type: "error", reason: "error", error });
} else if (response.stopReason === "error" || response.stopReason === "aborted") {
stream.push({ type: "error", reason: response.stopReason, error: response });
} else {
stream.push({ type: "done", reason: response.stopReason, message: response });
}
});
return stream;
};
harness.session.agent.streamFunction = streamFunction;
return () => callCount;
}
it("retries a transient `terminated` summarization error and compacts successfully", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 } });
const model = harness.getModel();
const error = (errorMessage: string): AssistantMessage => ({
...fauxAssistantMessage("", { stopReason: "error", errorMessage }),
usage: createUsage(10),
});
const success: AssistantMessage = {
...fauxAssistantMessage("recovered summary"),
usage: createUsage(10),
};
const getCallCount = useScriptedStreamFn(harness, [error("terminated"), error("terminated"), success]);
const result = await harness.session.compact();
expect(result.summary).toContain("recovered summary");
expect(getCallCount()).toBe(3); // 1 initial + 2 retries
const starts = harness.eventsOfType("summarization_retry_scheduled");
const ends = harness.eventsOfType("summarization_retry_finished");
expect(starts).toHaveLength(2);
expect(ends).toHaveLength(1);
expect(starts[0]).toMatchObject({ attempt: 1, maxAttempts: 3, errorMessage: "terminated" });
expect(starts[1]).toMatchObject({ attempt: 2, maxAttempts: 3 });
expect(ends[0]).toMatchObject({ type: "summarization_retry_finished" });
// model.* referenced to keep imports honest
expect(model.id).toBeTruthy();
});
it("does not retry a non-retryable error (insufficient_quota)", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 } });
const error: AssistantMessage = {
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" }),
usage: createUsage(10),
};
const getCallCount = useScriptedStreamFn(harness, [error]);
await expect(harness.session.compact()).rejects.toThrow("insufficient_quota");
expect(getCallCount()).toBe(1);
expect(harness.eventsOfType("summarization_retry_scheduled")).toHaveLength(0);
});
it("does not retry when retry is disabled", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
harness.settingsManager.applyOverrides({ retry: { enabled: false, maxRetries: 3, baseDelayMs: 0 } });
const error: AssistantMessage = {
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }),
usage: createUsage(10),
};
const getCallCount = useScriptedStreamFn(harness, [error]);
await expect(harness.session.compact()).rejects.toThrow("terminated");
expect(getCallCount()).toBe(1);
expect(harness.eventsOfType("summarization_retry_scheduled")).toHaveLength(0);
});
it("stops retrying after maxRetries and reports failure", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 2, baseDelayMs: 0 } });
const error: AssistantMessage = {
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }),
usage: createUsage(10),
};
const getCallCount = useScriptedStreamFn(harness, [error, error, error]);
await expect(harness.session.compact()).rejects.toThrow("terminated");
expect(getCallCount()).toBe(3); // 1 initial + 2 retries
const starts = harness.eventsOfType("summarization_retry_scheduled");
const ends = harness.eventsOfType("summarization_retry_finished");
expect(starts).toHaveLength(2);
expect(ends).toHaveLength(1);
expect(ends[0]).toMatchObject({ type: "summarization_retry_finished" });
});
it("aborts an in-flight retry backoff via abortCompaction", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 5, baseDelayMs: 30_000 } });
const error: AssistantMessage = {
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }),
usage: createUsage(10),
};
useScriptedStreamFn(harness, [error, error, error]);
const compactPromise = harness.session.compact();
// Let the first error resolve and the retry backoff sleep start.
await new Promise((resolve) => setTimeout(resolve, 0));
harness.session.abortCompaction();
// The aborted retry backoff is normalized to an aborted assistant message,
// which compaction classifies as aborted.
await expect(compactPromise).rejects.toThrow();
const compactionEnd = harness.eventsOfType("compaction_end").at(-1);
expect(compactionEnd).toMatchObject({ aborted: true });
});
});
@@ -0,0 +1,28 @@
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { createHarness } from "../harness.ts";
const wrappedDnsLookupError =
"The pending stream has been canceled (caused by: getaddrinfo ENOTFOUND bedrock-runtime.us-east-1.amazonaws.com)";
describe("issue #6904 DNS transport failure retry", () => {
it("retries a transient DNS lookup failure", async () => {
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
try {
harness.setResponses([
fauxAssistantMessage("", { stopReason: "error", errorMessage: wrappedDnsLookupError }),
fauxAssistantMessage("recovered after DNS retry"),
]);
await harness.session.prompt("test");
expect(harness.faux.state.callCount).toBe(2);
expect(harness.eventsOfType("auto_retry_start").map((event) => event.errorMessage)).toEqual([
wrappedDnsLookupError,
]);
expect(harness.eventsOfType("auto_retry_end").map((event) => event.success)).toEqual([true]);
} finally {
harness.cleanup();
}
});
});
@@ -0,0 +1,160 @@
import type { Api, Model } from "@earendil-works/pi-ai";
import { setKeybindings } from "@earendil-works/pi-tui";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { ScopedModelsSelectorComponent } from "../../../src/modes/interactive/components/scoped-models-selector.ts";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
import { createHarness, type Harness } from "../harness.ts";
function createInteractiveContext(options: {
allModels: Model<Api>[];
enabledModelIds: string[];
scopedModels?: Array<{ model: Model<Api> }>;
}) {
let selector: ScopedModelsSelectorComponent | undefined;
const setScopedModels = vi.fn();
const getAvailable = vi.fn().mockResolvedValue(options.allModels);
const context = {
session: {
modelRuntime: {
refresh: vi.fn(),
getAvailable,
},
scopedModels: options.scopedModels ?? [],
setScopedModels,
},
settingsManager: {
getEnabledModels: () => options.enabledModelIds,
setEnabledModels: vi.fn(),
},
showStatus: vi.fn(),
showSelector: (factory: (done: () => void) => { component: ScopedModelsSelectorComponent }) => {
selector = factory(() => {}).component;
},
updateAvailableProviderCount: vi.fn(),
ui: { requestRender: vi.fn() },
};
return { context, getAvailable, getSelector: () => selector, setScopedModels };
}
async function showModelsSelector(context: object): Promise<void> {
const show = Reflect.get(InteractiveMode.prototype, "showModelsSelector") as (this: object) => Promise<void>;
await show.call(context);
}
describe("issue #6949 unavailable scoped models", () => {
const harnesses: Harness[] = [];
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
afterEach(() => {
while (harnesses.length > 0) harnesses.pop()?.cleanup();
});
it("shows and removes an enabled model without a catalog entry", async () => {
const harness = await createHarness({ models: [{ id: "available", name: "Available" }] });
harnesses.push(harness);
const availableId = `${harness.models[0].provider}/${harness.models[0].id}`;
const unavailableId = `${harness.models[0].provider}/unavailable`;
const changes: Array<string[] | null> = [];
const persisted: Array<string[] | null> = [];
const selector = new ScopedModelsSelectorComponent(
{
allModels: [...harness.models],
enabledModelIds: [unavailableId, availableId],
},
{
onChange: (enabledIds) => {
changes.push(enabledIds);
},
onPersist: (enabledIds) => {
persisted.push(enabledIds);
},
onCancel: () => {},
},
);
expect(stripAnsi(selector.render(100).join("\n"))).toContain(`${unavailableId} [unavailable] ✗`);
selector.handleInput("\r");
expect(changes).toEqual([[availableId]]);
selector.handleInput("\x13");
expect(persisted).toEqual([[availableId]]);
});
it("passes unmatched settings patterns to the selector with one combined resolution", async () => {
const harness = await createHarness({ models: [{ id: "available", name: "Available" }] });
harnesses.push(harness);
const unavailableIds = ["unavailable-one", "unavailable-two"].map((id) => `${harness.models[0].provider}/${id}`);
const { context, getAvailable, getSelector } = createInteractiveContext({
allModels: [],
enabledModelIds: unavailableIds,
});
await showModelsSelector(context);
const selector = getSelector();
if (!selector) throw new Error("Expected scoped-model selector to open");
const rendered = stripAnsi(selector.render(100).join("\n"));
for (const unavailableId of unavailableIds) {
expect(rendered).toContain(`${unavailableId} [unavailable] ✗`);
}
expect(getAvailable).toHaveBeenCalledTimes(2);
});
it("opens when only a session-scoped model is unavailable", async () => {
const harness = await createHarness({ models: [{ id: "unavailable", name: "Unavailable" }] });
harnesses.push(harness);
const model = harness.models[0];
const fullId = `${model.provider}/${model.id}`;
const { context, getSelector } = createInteractiveContext({
allModels: [],
enabledModelIds: [],
scopedModels: [{ model }],
});
await showModelsSelector(context);
const selector = getSelector();
if (!selector) throw new Error("Expected scoped-model selector to open");
expect(stripAnsi(selector.render(100).join("\n"))).toContain(`${fullId} [unavailable] ✗`);
});
it("does not clear a partial scope when an enabled model is unavailable", async () => {
const harness = await createHarness({
models: [
{ id: "one", name: "One" },
{ id: "two", name: "Two" },
{ id: "three", name: "Three" },
],
});
harnesses.push(harness);
const [one, two] = harness.models;
const enabledIds = [one, two].map((model) => `${model.provider}/${model.id}`);
const unavailableId = `${one.provider}/unavailable`;
const { context, getSelector, setScopedModels } = createInteractiveContext({
allModels: [...harness.models],
enabledModelIds: [...enabledIds, unavailableId],
scopedModels: [{ model: one }, { model: two }],
});
await showModelsSelector(context);
const selector = getSelector();
if (!selector) throw new Error("Expected scoped-model selector to open");
selector.handleInput("\x1b[1;3B");
await vi.waitFor(() => {
expect(setScopedModels).toHaveBeenLastCalledWith([
{ model: two, thinkingLevel: undefined },
{ model: one, thinkingLevel: undefined },
]);
});
});
});
@@ -0,0 +1,68 @@
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
import { createHarness, type Harness } from "../harness.ts";
function createFakeTui(): TUI {
return {
requestRender: () => {},
} as unknown as TUI;
}
function modelsJson(provider: string, model: string): Record<string, unknown> {
return {
providers: {
[provider]: {
baseUrl: "https://example.test/v1",
api: "openai-completions",
apiKey: "test-key",
models: [{ id: model }],
},
},
};
}
describe("issue #6999 models.json hot reload", () => {
let harness: Harness | undefined;
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
afterEach(() => {
harness?.cleanup();
harness = undefined;
});
it("reloads models.json when opening /model", async () => {
harness = await createHarness({ modelsJson: modelsJson("old-provider", "old-model") });
expect(harness.session.modelRuntime.getModel("old-provider", "old-model")).toBeDefined();
writeFileSync(join(harness.tempDir, "models.json"), JSON.stringify(modelsJson("new-provider", "new-model")));
const selector = new ModelSelectorComponent(
createFakeTui(),
harness.getModel(),
harness.settingsManager,
harness.session.modelRuntime,
[],
() => {},
() => {},
);
await vi.waitFor(() => {
const rendered = stripAnsi(selector.render(120).join("\n"));
expect(rendered).toContain("new-model [new-provider]");
expect(rendered).toContain("Model catalogs refreshed.");
});
expect(harness.session.modelRuntime.getModel("old-provider", "old-model")).toBeUndefined();
});
});
@@ -0,0 +1,128 @@
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
import { createHarness, type Harness } from "../harness.ts";
function createFakeTui(): TUI {
return { requestRender: () => {} } as unknown as TUI;
}
/** Return the model id of the highlighted (→) row in the rendered selector. */
function selectedModelId(rendered: string): string | undefined {
const line = rendered.split("\n").find((l) => l.startsWith("→ "));
if (!line) return undefined;
const rest = line.replace(/^→\s*/, "");
const id = rest.split(" [")[0];
return id?.trim() || undefined;
}
describe("model selector filter resets selection to top", () => {
const harnesses: Harness[] = [];
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
afterAll(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("moves selection to the first row in the All tab when typing a query", async () => {
const harness = await createHarness({
models: [
{ id: "alpha-1", name: "Alpha One", reasoning: true },
{ id: "alpha-2", name: "Alpha Two", reasoning: true },
{ id: "alpha-3", name: "Alpha Three", reasoning: true },
{ id: "beta-1", name: "Beta One", reasoning: true },
],
});
harnesses.push(harness);
const current = harness.getModel("alpha-1")!;
const selector = new ModelSelectorComponent(
createFakeTui(),
current,
harness.settingsManager,
harness.session.modelRuntime,
[],
() => {},
() => {},
);
await vi.waitFor(() => {
const rendered = stripAnsi(selector.render(120).join("\n"));
expect(rendered).toContain("Model catalogs refreshed.");
});
// Current model (alpha-1) is sorted first, so selection starts on row 0.
expect(selectedModelId(stripAnsi(selector.render(120).join("\n")))).toBe("alpha-1");
// Move selection down two rows to alpha-3.
selector.handleInput("\x1b[B");
selector.handleInput("\x1b[B");
expect(selectedModelId(stripAnsi(selector.render(120).join("\n")))).toBe("alpha-3");
// Type a query that matches the three alpha models. The selection must
// move back to the top row (alpha-1), not stay clamped at index 2.
for (const char of "alpha") {
selector.handleInput(char);
}
const rendered = stripAnsi(selector.render(120).join("\n"));
expect(selectedModelId(rendered)).toBe("alpha-1");
// Sanity: the filter actually narrowed the list.
expect(rendered).not.toContain("beta-1");
});
it("moves selection to the first row in the Scoped tab when typing a query", async () => {
const harness = await createHarness({
models: [
{ id: "alpha-1", name: "Alpha One", reasoning: true },
{ id: "alpha-2", name: "Alpha Two", reasoning: true },
{ id: "alpha-3", name: "Alpha Three", reasoning: true },
],
});
harnesses.push(harness);
const alpha1 = harness.getModel("alpha-1")!;
const alpha2 = harness.getModel("alpha-2")!;
const alpha3 = harness.getModel("alpha-3")!;
// Scoped list is intentionally not in current-model-first order; the
// current model (alpha-1) sits at index 2.
const selector = new ModelSelectorComponent(
createFakeTui(),
alpha1,
harness.settingsManager,
harness.session.modelRuntime,
[{ model: alpha2 }, { model: alpha3 }, { model: alpha1 }],
() => {},
() => {},
);
await vi.waitFor(() => {
const rendered = stripAnsi(selector.render(120).join("\n"));
expect(rendered).toContain("Model catalogs refreshed.");
});
// Selection starts on the current model (alpha-1), which is row 2 here.
expect(selectedModelId(stripAnsi(selector.render(120).join("\n")))).toBe("alpha-1");
// Type a query matching all three scoped models. Selection must move to
// the top row (alpha-2), not stay clamped at index 2 (alpha-1).
for (const char of "alpha") {
selector.handleInput(char);
}
expect(selectedModelId(stripAnsi(selector.render(120).join("\n")))).toBe("alpha-2");
});
});
@@ -0,0 +1,131 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { clearExtensionCache, loadExtensions, loadExtensionsCached } from "../../../src/core/extensions/loader.ts";
import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts";
interface TestState {
moduleLoads?: number;
factoryRuns?: number;
}
function state(): TestState {
const global = globalThis as typeof globalThis & { __extensionFactoryCacheTest?: TestState };
if (!global.__extensionFactoryCacheTest) {
global.__extensionFactoryCacheTest = {};
}
return global.__extensionFactoryCacheTest;
}
function resetState(): void {
delete (globalThis as typeof globalThis & { __extensionFactoryCacheTest?: TestState }).__extensionFactoryCacheTest;
}
function writeCountingExtension(filePath: string): void {
writeFileSync(
filePath,
`
const state = (globalThis.__extensionFactoryCacheTest ??= {});
state.moduleLoads = (state.moduleLoads ?? 0) + 1;
export default function () {
state.factoryRuns = (state.factoryRuns ?? 0) + 1;
}
`,
"utf-8",
);
}
describe("extension factory cache", () => {
const roots: string[] = [];
function fixture(name: string) {
const root = join(tmpdir(), `pi-extension-cache-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
const cwd = join(root, "project");
const agentDir = join(root, "agent");
mkdirSync(cwd, { recursive: true });
mkdirSync(agentDir, { recursive: true });
roots.push(root);
return { root, cwd, agentDir };
}
beforeEach(() => {
resetState();
clearExtensionCache();
});
afterEach(() => {
while (roots.length > 0) {
const root = roots.pop();
if (root && existsSync(root)) {
rmSync(root, { recursive: true, force: true });
}
}
resetState();
clearExtensionCache();
});
it("caches extension modules for cached same-cwd loads but reruns factories", async () => {
const { root, cwd } = fixture("same-cwd");
const extensionPath = join(root, "counting.ts");
writeCountingExtension(extensionPath);
const first = await loadExtensionsCached([extensionPath], cwd);
const second = await loadExtensionsCached([extensionPath], cwd);
expect(state().moduleLoads).toBe(1);
expect(state().factoryRuns).toBe(2);
expect(first.extensions[0]).not.toBe(second.extensions[0]);
expect(first.runtime).not.toBe(second.runtime);
});
it("does not cache direct loadExtensions calls", async () => {
const { root, cwd } = fixture("direct");
const extensionPath = join(root, "counting.ts");
writeCountingExtension(extensionPath);
await loadExtensions([extensionPath], cwd);
await loadExtensions([extensionPath], cwd);
expect(state().moduleLoads).toBe(2);
expect(state().factoryRuns).toBe(2);
});
it("clears the cache on resource loader reload", async () => {
const { cwd, agentDir } = fixture("reload");
const extensionDir = join(agentDir, "extensions");
mkdirSync(extensionDir, { recursive: true });
writeCountingExtension(join(extensionDir, "counting.ts"));
const loader = new DefaultResourceLoader({
cwd,
agentDir,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
});
await loader.reload();
await loader.reload();
expect(state().moduleLoads).toBe(2);
expect(state().factoryRuns).toBe(2);
});
it("keeps the cache scoped to one cwd", async () => {
const { root } = fixture("cross-cwd");
const firstCwd = join(root, "first");
const secondCwd = join(root, "second");
mkdirSync(firstCwd, { recursive: true });
mkdirSync(secondCwd, { recursive: true });
const extensionPath = join(root, "counting.ts");
writeCountingExtension(extensionPath);
await loadExtensionsCached([extensionPath], firstCwd);
await loadExtensionsCached([extensionPath], secondCwd);
await loadExtensionsCached([extensionPath], secondCwd);
expect(state().moduleLoads).toBe(2);
expect(state().factoryRuns).toBe(3);
});
});
@@ -0,0 +1,75 @@
import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createHarness, getUserTexts, type Harness } from "../harness.ts";
function createUsage(totalTokens: number) {
return {
input: totalTokens,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
describe("pre-prompt compaction regression", () => {
const harnesses: Harness[] = [];
afterEach(() => {
vi.restoreAllMocks();
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("compacts length-stop overflow before a new prompt without continuing from an assistant message", async () => {
const harness = await createHarness({
models: [{ id: "faux-1", contextWindow: 100, maxTokens: 100 }],
settings: { compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 0 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "pre-prompt summary",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
}));
},
],
});
harnesses.push(harness);
const now = Date.now();
const model = harness.getModel();
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "previous prompt" }],
timestamp: now - 1000,
});
const lengthStopAssistant: AssistantMessage = {
...fauxAssistantMessage("length-stop assistant response", { stopReason: "length", timestamp: now - 500 }),
api: model.api,
provider: model.provider,
model: model.id,
usage: createUsage(100),
};
harness.sessionManager.appendMessage(lengthStopAssistant);
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
harness.setResponses([fauxAssistantMessage("answered next prompt")]);
const continueSpy = vi.spyOn(harness.session.agent, "continue");
await expect(harness.session.prompt("next prompt")).resolves.toBeUndefined();
expect(continueSpy).not.toHaveBeenCalled();
expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({
reason: "overflow",
aborted: false,
willRetry: true,
});
expect(getUserTexts(harness)).toContain("next prompt");
expect(harness.faux.state.callCount).toBe(1);
});
});
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
type RebindContext = {
session: object;
unsubscribe?: () => void;
applyRuntimeSettings: () => void;
renderCurrentSessionState: () => void;
bindCurrentSessionExtensions: () => Promise<void>;
subscribeToAgent: () => void;
updateAvailableProviderCount: () => Promise<void>;
updateEditorBorderColor: () => void;
updateTerminalTitle: () => void;
};
type InteractiveModePrototype = {
rebindCurrentSession(this: RebindContext, options?: { renderBeforeBind?: boolean }): Promise<void>;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype;
describe("overlapping startup and replacement session rebinds", () => {
it("does not subscribe from the stale startup rebind", async () => {
const startupSession = {};
const replacementSession = {};
let resolveStartupBind!: () => void;
let resolveReplacementBind!: () => void;
const startupBind = new Promise<void>((resolve) => {
resolveStartupBind = resolve;
});
const replacementBind = new Promise<void>((resolve) => {
resolveReplacementBind = resolve;
});
const subscribeToAgent = vi.fn();
const updateTerminalTitle = vi.fn();
let bindCount = 0;
const context: RebindContext = {
session: startupSession,
applyRuntimeSettings: () => {},
renderCurrentSessionState: () => {},
bindCurrentSessionExtensions: () => {
bindCount += 1;
return bindCount === 1 ? startupBind : replacementBind;
},
subscribeToAgent,
updateAvailableProviderCount: async () => {},
updateEditorBorderColor: () => {},
updateTerminalTitle,
};
const startupRebind = interactiveModePrototype.rebindCurrentSession.call(context);
expect(bindCount).toBe(1);
context.session = replacementSession;
const replacementRebind = interactiveModePrototype.rebindCurrentSession.call(context, {
renderBeforeBind: true,
});
expect(bindCount).toBe(2);
expect(subscribeToAgent).toHaveBeenCalledTimes(1);
resolveStartupBind();
await startupRebind;
expect(subscribeToAgent).toHaveBeenCalledTimes(1);
expect(updateTerminalTitle).not.toHaveBeenCalled();
resolveReplacementBind();
await replacementRebind;
expect(subscribeToAgent).toHaveBeenCalledTimes(1);
expect(updateTerminalTitle).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,35 @@
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { userMsg } from "../../utilities.ts";
import { createHarness } from "../harness.ts";
describe("tree navigation during an active response", () => {
it("rejects navigation without changing the active leaf", async () => {
const harness = await createHarness();
const targetId = harness.sessionManager.appendMessage(userMsg("first"));
let navigationResult: unknown;
let leafUnchanged = false;
try {
// Navigate from inside the response factory, while the run is active.
harness.setResponses([
async () => {
const activeLeafId = harness.sessionManager.getLeafId();
navigationResult = await harness.session
.navigateTree(targetId, { summarize: false })
.catch((error) => error);
leafUnchanged = activeLeafId !== targetId && harness.sessionManager.getLeafId() === activeLeafId;
return fauxAssistantMessage("response");
},
]);
await harness.session.prompt("second");
expect(navigationResult).toEqual(
new Error("Wait for the current response to finish before navigating the session tree."),
);
expect(leafUnchanged).toBe(true);
} finally {
harness.cleanup();
}
});
});