Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
@@ -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,103 @@
|
||||
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.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?.replace(/^✓\s*/, "").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", "powershell", "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,184 @@
|
||||
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;
|
||||
maybeShowAssistantDiagnostics(message: AssistantMessage): void;
|
||||
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,
|
||||
maybeShowAssistantDiagnostics: vi.fn(),
|
||||
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,82 @@
|
||||
import type { ChildProcessByStdio } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { 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 with virtual time so host
|
||||
* load cannot reorder a real subprocess's writes and the grace timer.
|
||||
*/
|
||||
describe("issue #5303 bash output truncation past exit", () => {
|
||||
function createChild(): ChildProcessByStdio<null, PassThrough, PassThrough> {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
stdin: null,
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
}) as unknown as ChildProcessByStdio<null, PassThrough, PassThrough>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("captures output emitted after exit while a descendant holds stdout open", async () => {
|
||||
const child = createChild();
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
let resolved = false;
|
||||
const waiting = waitForChildProcess(child).then((exitCode) => {
|
||||
resolved = true;
|
||||
return exitCode;
|
||||
});
|
||||
|
||||
child.stdout.write("HEAD\n");
|
||||
child.emit("exit", 0, null);
|
||||
for (let index = 1; index <= 6; index++) {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
child.stdout.write(`TICK${index}\n`);
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(resolved).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(await waiting).toBe(0);
|
||||
expect(output).toContain("HEAD");
|
||||
expect(output).toContain("TICK6");
|
||||
});
|
||||
|
||||
it("resolves after the grace when a descendant holds stdout open but stays quiet", async () => {
|
||||
const child = createChild();
|
||||
let resolved = false;
|
||||
const waiting = waitForChildProcess(child).then((exitCode) => {
|
||||
resolved = true;
|
||||
return exitCode;
|
||||
});
|
||||
|
||||
child.stdout.write("DONE\n");
|
||||
child.emit("exit", 0, null);
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(resolved).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(await waiting).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -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,53 @@
|
||||
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, getAssistantTexts, type Harness } from "../harness.ts";
|
||||
|
||||
describe("#5998 blocked tool termination", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a tool_call handler terminate the run after blocking execution", async () => {
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async () => {
|
||||
throw new Error("tool should have been blocked");
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [echoTool],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("tool_call", async () => ({
|
||||
block: true,
|
||||
reason: "Blocked by terminating policy",
|
||||
terminate: true,
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("should not run"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(harness.getPendingResponseCount()).toBe(1);
|
||||
expect(getAssistantTexts(harness)).not.toContain("should not run");
|
||||
expect(harness.eventsOfType("tool_execution_end")[0]?.result).toHaveProperty("terminate", true);
|
||||
expect(
|
||||
harness.session.messages.find((message) => message.role === "toolResult" && message.isError),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -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,91 @@
|
||||
import { posix, win32 } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createFindToolDefinition, relativizeFindResultPath } from "../../../src/core/tools/find.ts";
|
||||
|
||||
/**
|
||||
* Regression test for https://github.com/earendil-works/pi/issues/6104
|
||||
*
|
||||
* path.resolve() preserves a trailing separator when resolving to a root path,
|
||||
* such as POSIX / or Windows I:\\. The find tool used to slice absolute
|
||||
* results at searchPath.length + 1, which dropped the first character of the
|
||||
* first path segment for root searches. Windows directory results that ended in
|
||||
* backslash also gained an extra slash.
|
||||
*/
|
||||
describe("issue #6104 find relativizes root search paths", () => {
|
||||
describe("Windows drive root", () => {
|
||||
const searchRoot = "I:\\";
|
||||
|
||||
it("preserves the first segment and emits one trailing slash for fd directory output", () => {
|
||||
expect(relativizeFindResultPath("I:\\AI\\Models\\TextGen\\gemma4\\", searchRoot, win32)).toBe(
|
||||
"AI/Models/TextGen/gemma4/",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles fd output that uses forward slashes under a drive root", () => {
|
||||
expect(relativizeFindResultPath("I:/AI/Models/TextGen/gemma4/", searchRoot, win32)).toBe(
|
||||
"AI/Models/TextGen/gemma4/",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps deeper search paths unchanged", () => {
|
||||
expect(relativizeFindResultPath("I:\\AI\\Models\\", "I:\\AI", win32)).toBe("Models/");
|
||||
});
|
||||
|
||||
it("does not relativize a sibling directory that shares a name prefix", () => {
|
||||
expect(relativizeFindResultPath("I:\\AI\\Models2\\file.txt", "I:\\AI\\Models", win32)).toBe(
|
||||
"../Models2/file.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes relative custom-glob results without corrupting them", () => {
|
||||
expect(relativizeFindResultPath("AI\\Models\\TextGen\\gemma4\\", searchRoot, win32)).toBe(
|
||||
"AI/Models/TextGen/gemma4/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POSIX root", () => {
|
||||
it("preserves the first segment for files under /", () => {
|
||||
expect(relativizeFindResultPath("/home/user/file.txt", "/", posix)).toBe("home/user/file.txt");
|
||||
});
|
||||
|
||||
it("preserves the first segment and one trailing slash for directories under /", () => {
|
||||
expect(relativizeFindResultPath("/home/user/project/", "/", posix)).toBe("home/user/project/");
|
||||
});
|
||||
|
||||
it("preserves backslashes in POSIX filenames", () => {
|
||||
expect(relativizeFindResultPath("/home/user/file\\", "/home/user", posix)).toBe("file\\");
|
||||
});
|
||||
});
|
||||
|
||||
describe("absolute results outside the search path", () => {
|
||||
it("falls back to path.relative when the absolute paths do not share a prefix", () => {
|
||||
expect(relativizeFindResultPath("/tmp/results/file.txt", "/workspace/project", posix)).toBe(
|
||||
"../../tmp/results/file.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a trailing slash on directories resolved through path.relative", () => {
|
||||
expect(relativizeFindResultPath("/tmp/results/dir/", "/workspace/project", posix)).toBe(
|
||||
"../../tmp/results/dir/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("through the find tool", () => {
|
||||
it("relativizes custom glob results against a root search path", async () => {
|
||||
const def = createFindToolDefinition("/", {
|
||||
operations: {
|
||||
exists: () => true,
|
||||
glob: () => ["/home/user/project/", "/home/user/project/file.txt"],
|
||||
},
|
||||
});
|
||||
// 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 }>;
|
||||
};
|
||||
expect(result.content[0]?.text).toBe("home/user/project/\nhome/user/project/file.txt");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,52 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
|
||||
|
||||
vi.mock("child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("child_process")>();
|
||||
return { ...actual, spawn: spawnMock };
|
||||
});
|
||||
|
||||
import { killProcessTree } from "../../../src/utils/shell.ts";
|
||||
|
||||
function withWindowsPlatform(test: () => void): void {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
try {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
|
||||
test();
|
||||
} finally {
|
||||
if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
spawnMock.mockReset();
|
||||
});
|
||||
|
||||
describe("issue #6596 taskkill spawn failures", () => {
|
||||
it("uses System32 taskkill and consumes its asynchronous spawn error", () => {
|
||||
const child = new EventEmitter() as ChildProcess;
|
||||
const previousSystemRoot = process.env.SystemRoot;
|
||||
process.env.SystemRoot = "C:\\CustomWindows";
|
||||
spawnMock.mockReturnValue(child);
|
||||
|
||||
try {
|
||||
withWindowsPlatform(() => {
|
||||
killProcessTree(1234);
|
||||
});
|
||||
} finally {
|
||||
if (previousSystemRoot === undefined) delete process.env.SystemRoot;
|
||||
else process.env.SystemRoot = previousSystemRoot;
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
join("C:\\CustomWindows", "System32", "taskkill.exe"),
|
||||
["/F", "/T", "/PID", "1234"],
|
||||
{ detached: true, stdio: "ignore", windowsHide: true },
|
||||
);
|
||||
expect(() => child.emit("error", new Error("spawn taskkill ENOENT"))).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -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,114 @@
|
||||
import {
|
||||
type AssistantMessage,
|
||||
createAssistantMessageEventStream,
|
||||
fauxAssistantMessage,
|
||||
type Model,
|
||||
type Provider,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
const INDIVIDUAL_BASE_URL = "https://api.individual.githubcopilot.com";
|
||||
const ENTERPRISE_BASE_URL = "https://api.enterprise.githubcopilot.com";
|
||||
|
||||
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("assistant response to compact", { timestamp: now - 500 }),
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 100,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
};
|
||||
harness.sessionManager.appendMessage(assistant);
|
||||
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
|
||||
}
|
||||
|
||||
describe("issue #6768 Copilot compaction base URL", () => {
|
||||
let harness: Harness | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
harness?.cleanup();
|
||||
harness = undefined;
|
||||
});
|
||||
|
||||
it("uses the auth-resolved base URL through the SDK-style stream wrapper", async () => {
|
||||
harness = await createHarness();
|
||||
seedCompactableSession(harness);
|
||||
const catalogModel = { ...harness.getModel(), baseUrl: INDIVIDUAL_BASE_URL };
|
||||
harness.session.agent.state.model = catalogModel;
|
||||
|
||||
let requestBaseUrl: string | undefined;
|
||||
const respond = (requestModel: Model<string>) => {
|
||||
requestBaseUrl = requestModel.baseUrl;
|
||||
const stream = createAssistantMessageEventStream();
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
...fauxAssistantMessage("summary"),
|
||||
api: requestModel.api,
|
||||
provider: requestModel.provider,
|
||||
model: requestModel.id,
|
||||
},
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
const provider: Provider<string> = {
|
||||
id: catalogModel.provider,
|
||||
name: "Copilot regression provider",
|
||||
baseUrl: INDIVIDUAL_BASE_URL,
|
||||
auth: {
|
||||
apiKey: {
|
||||
name: "Copilot token",
|
||||
resolve: async ({ credential }) =>
|
||||
credential?.key ? { auth: { apiKey: credential.key }, source: "explicit token" } : undefined,
|
||||
},
|
||||
oauth: {
|
||||
name: "Copilot OAuth",
|
||||
login: async () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
refresh: async (credential) => credential,
|
||||
toAuth: async (credential) => ({
|
||||
apiKey: credential.access,
|
||||
baseUrl: ENTERPRISE_BASE_URL,
|
||||
}),
|
||||
},
|
||||
},
|
||||
getModels: () => [catalogModel],
|
||||
stream: (requestModel) => respond(requestModel),
|
||||
streamSimple: (requestModel) => respond(requestModel),
|
||||
};
|
||||
|
||||
await harness.authStorage.modify(catalogModel.provider, async () => ({
|
||||
type: "oauth",
|
||||
access: "enterprise-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60 * 60_000,
|
||||
}));
|
||||
const modelRuntime = harness.session.modelRuntime;
|
||||
modelRuntime.registerNativeProvider(provider);
|
||||
await modelRuntime.refresh({ allowNetwork: false, providers: [catalogModel.provider] });
|
||||
harness.session.agent.streamFunction = (model, context, options) =>
|
||||
modelRuntime.streamSimple(model, context, options);
|
||||
|
||||
await harness.session.compact();
|
||||
|
||||
expect(requestBaseUrl).toBe(ENTERPRISE_BASE_URL);
|
||||
});
|
||||
});
|
||||
@@ -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,162 @@
|
||||
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, theme } 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 getAvailableSnapshot = vi.fn(() => options.allModels);
|
||||
const context = {
|
||||
session: {
|
||||
modelRuntime: {
|
||||
refresh: vi.fn().mockResolvedValue({ aborted: false, errors: new Map() }),
|
||||
getAvailableSnapshot,
|
||||
},
|
||||
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, getAvailableSnapshot, 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: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const rendered = selector.render(100).join("\n");
|
||||
expect(stripAnsi(rendered)).toContain(`${unavailableId} [unavailable]`);
|
||||
expect(rendered).toContain(theme.strikethrough(unavailableId));
|
||||
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, getAvailableSnapshot, 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(getAvailableSnapshot).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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,82 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AuthStorage } from "../../../src/core/auth-storage.ts";
|
||||
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 { createModelRegistry, getModelRuntime } from "../../model-runtime-test-utils.ts";
|
||||
|
||||
function observeRefreshRender(): { tui: TUI; renderedAfterRefresh: Promise<void> } {
|
||||
let renderCount = 0;
|
||||
let resolveRefreshRender = () => {};
|
||||
const renderedAfterRefresh = new Promise<void>((resolve) => {
|
||||
resolveRefreshRender = resolve;
|
||||
});
|
||||
return {
|
||||
tui: {
|
||||
requestRender: () => {
|
||||
renderCount++;
|
||||
if (renderCount === 2) resolveRefreshRender();
|
||||
},
|
||||
} as unknown as TUI,
|
||||
renderedAfterRefresh,
|
||||
};
|
||||
}
|
||||
|
||||
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 tempDir: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setKeybindings(new KeybindingsManager());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = undefined;
|
||||
});
|
||||
|
||||
it("reloads models.json when opening /model", async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-models-json-hot-reload-"));
|
||||
const modelsPath = join(tempDir, "models.json");
|
||||
writeFileSync(modelsPath, JSON.stringify(modelsJson("old-provider", "old-model")));
|
||||
const modelRuntime = getModelRuntime(await createModelRegistry(AuthStorage.inMemory(), modelsPath));
|
||||
expect(modelRuntime.getModel("old-provider", "old-model")).toBeDefined();
|
||||
|
||||
writeFileSync(modelsPath, JSON.stringify(modelsJson("new-provider", "new-model")));
|
||||
const { tui, renderedAfterRefresh } = observeRefreshRender();
|
||||
const selector = new ModelSelectorComponent(
|
||||
tui,
|
||||
undefined,
|
||||
modelRuntime,
|
||||
[],
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
await renderedAfterRefresh;
|
||||
const rendered = stripAnsi(selector.render(120).join("\n"));
|
||||
expect(rendered).toContain("new-model [new-provider]");
|
||||
expect(rendered).not.toContain("old-model [old-provider]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Api, Model, Provider } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AuthStorage } from "../../../src/core/auth-storage.ts";
|
||||
import { ModelRuntime } from "../../../src/core/model-runtime.ts";
|
||||
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
const dynamicModel: Model<"openai-completions"> = {
|
||||
id: "dynamic",
|
||||
name: "Dynamic",
|
||||
api: "openai-completions",
|
||||
provider: "stalled-login",
|
||||
baseUrl: "https://example.test/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
};
|
||||
|
||||
describe("issues #7027 and #7113 credential refresh hang", () => {
|
||||
let harness: Harness | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
harness?.cleanup();
|
||||
harness = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not hold login behind an older stalled network catalog refresh", async () => {
|
||||
let markNetworkStarted: (() => void) | undefined;
|
||||
const networkStarted = new Promise<void>((resolve) => {
|
||||
markNetworkStarted = resolve;
|
||||
});
|
||||
const provider: Provider<"openai-completions"> = {
|
||||
id: "stalled-login",
|
||||
name: "Stalled Login",
|
||||
auth: {
|
||||
apiKey: {
|
||||
name: "API key",
|
||||
login: async () => ({ type: "api_key", key: "secret" }),
|
||||
check: async ({ credential }) =>
|
||||
credential?.key ? { type: "api_key", source: "stored key" } : undefined,
|
||||
resolve: async ({ credential }) => ({
|
||||
auth: { apiKey: credential?.key ?? "ambient-key" },
|
||||
source: credential?.key ? "stored key" : "ambient key",
|
||||
}),
|
||||
},
|
||||
},
|
||||
getModels: () => [dynamicModel],
|
||||
refreshModels: async ({ allowNetwork }) => {
|
||||
if (!allowNetwork) return;
|
||||
markNetworkStarted?.();
|
||||
await new Promise<void>(() => {});
|
||||
},
|
||||
stream: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
streamSimple: () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
};
|
||||
const credentials = AuthStorage.inMemory();
|
||||
const runtime = await ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false });
|
||||
runtime.registerNativeProvider(provider);
|
||||
await runtime.refresh({ allowNetwork: false, providers: [provider.id] });
|
||||
|
||||
const stalledRefresh = runtime.refresh({ allowNetwork: true, providers: [provider.id] });
|
||||
await networkStarted;
|
||||
await expect(
|
||||
runtime.login(provider.id, "api_key", { prompt: async () => "unused", notify: () => {} }),
|
||||
).resolves.toEqual({ type: "api_key", key: "secret" });
|
||||
|
||||
expect(runtime.getAvailableSnapshot().map((model) => model.id)).toContain(dynamicModel.id);
|
||||
expect(await credentials.read(provider.id)).toEqual({ type: "api_key", key: "secret" });
|
||||
await expect(stalledRefresh).resolves.toMatchObject({ aborted: false });
|
||||
});
|
||||
|
||||
it("completes interactive login before its bounded background refresh", async () => {
|
||||
harness = await createHarness();
|
||||
vi.useFakeTimers();
|
||||
const runtime = harness.session.modelRuntime;
|
||||
vi.spyOn(runtime, "refresh").mockImplementation(
|
||||
(options) =>
|
||||
new Promise((resolve) => {
|
||||
options?.signal?.addEventListener("abort", () => resolve({ aborted: true, errors: new Map() }), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const showWarning = vi.fn();
|
||||
const context = {
|
||||
session: harness.session,
|
||||
updateAvailableProviderCount: vi.fn(),
|
||||
footer: { invalidate: vi.fn() },
|
||||
updateEditorBorderColor: vi.fn(),
|
||||
showStatus: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showWarning,
|
||||
maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(),
|
||||
checkDaxnutsEasterEgg: vi.fn(),
|
||||
ui: { requestRender: vi.fn() },
|
||||
};
|
||||
const complete = Reflect.get(InteractiveMode.prototype, "completeProviderAuthentication") as (
|
||||
this: object,
|
||||
providerId: string,
|
||||
providerName: string,
|
||||
authType: "oauth" | "api_key",
|
||||
previousModel: Model<Api>,
|
||||
) => Promise<void>;
|
||||
|
||||
await complete.call(context, dynamicModel.provider, "Stalled Login", "api_key", harness.getModel());
|
||||
expect(runtime.refresh).toHaveBeenCalledWith({
|
||||
providers: [dynamicModel.provider],
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(showWarning).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(showWarning).toHaveBeenCalledWith(
|
||||
"Saved API key for Stalled Login, but its model catalog refresh timed out; using cached models.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
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("assistant response to compact", { timestamp: now - 500 }),
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 100,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
};
|
||||
harness.sessionManager.appendMessage(assistant);
|
||||
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
|
||||
}
|
||||
|
||||
describe("#7048 truncated compaction summaries", () => {
|
||||
let harness: Harness | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
harness?.cleanup();
|
||||
harness = undefined;
|
||||
});
|
||||
|
||||
it("does not persist a length-limited summary", async () => {
|
||||
harness = await createHarness();
|
||||
seedCompactableSession(harness);
|
||||
harness.setResponses([fauxAssistantMessage("partial summar", { stopReason: "length" })]);
|
||||
|
||||
await expect(harness.session.compact()).rejects.toThrow("generation hit the token cap");
|
||||
expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, getMessageText, getUserTexts, type Harness } from "../harness.ts";
|
||||
|
||||
describe("issue #7150: RPC prompt during manual compaction", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an RPC prompt while manual compaction is in progress", async () => {
|
||||
let markCompactionStarted = () => {};
|
||||
const compactionStarted = new Promise<void>((resolve) => {
|
||||
markCompactionStarted = resolve;
|
||||
});
|
||||
let releaseCompaction = () => {};
|
||||
const compactionReleased = new Promise<void>((resolve) => {
|
||||
releaseCompaction = resolve;
|
||||
});
|
||||
|
||||
const harness = await createHarness({
|
||||
settings: { compaction: { keepRecentTokens: 1 } },
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_before_compact", async (event) => {
|
||||
markCompactionStarted();
|
||||
await compactionReleased;
|
||||
return {
|
||||
compaction: {
|
||||
summary: "manual compacted",
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
details: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
const timestamp = Date.now();
|
||||
harness.sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "old user message" }],
|
||||
timestamp: timestamp - 1000,
|
||||
});
|
||||
harness.sessionManager.appendMessage(
|
||||
fauxAssistantMessage("old assistant response", { timestamp: timestamp - 500 }),
|
||||
);
|
||||
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
|
||||
harness.setResponses([fauxAssistantMessage("probe response")]);
|
||||
|
||||
const compactPromise = harness.session.compact();
|
||||
await compactionStarted;
|
||||
|
||||
let preflightResult: boolean | undefined;
|
||||
let promptError: unknown;
|
||||
try {
|
||||
await harness.session.prompt("PROBE-7150", {
|
||||
source: "rpc",
|
||||
preflightResult: (success) => {
|
||||
preflightResult = success;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
promptError = error;
|
||||
} finally {
|
||||
releaseCompaction();
|
||||
await compactPromise;
|
||||
}
|
||||
|
||||
const persistedUserTexts = harness.sessionManager
|
||||
.getEntries()
|
||||
.flatMap((entry) =>
|
||||
entry.type === "message" && entry.message.role === "user" ? [getMessageText(entry.message)] : [],
|
||||
);
|
||||
|
||||
expect(preflightResult).toBe(false);
|
||||
expect(promptError).toEqual(
|
||||
expect.objectContaining({ message: expect.stringContaining("compaction is in progress") }),
|
||||
);
|
||||
expect(getUserTexts(harness)).not.toContain("PROBE-7150");
|
||||
expect(persistedUserTexts).not.toContain("PROBE-7150");
|
||||
expect(harness.eventsOfType("agent_start")).toHaveLength(0);
|
||||
expect(harness.eventsOfType("agent_settled")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Api, Model, ModelsRefreshResult } from "@earendil-works/pi-ai";
|
||||
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 type { 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";
|
||||
|
||||
const showModelsSelector = Reflect.get(InteractiveMode.prototype, "showModelsSelector") as (this: object) => void;
|
||||
|
||||
function openSelector(harness: Harness, initialModels: readonly Model<Api>[]) {
|
||||
let snapshot = initialModels;
|
||||
let finishRefresh: ((result: ModelsRefreshResult) => void) | undefined;
|
||||
let refreshSignal: AbortSignal | undefined;
|
||||
let selector: ScopedModelsSelectorComponent | undefined;
|
||||
let dispose: (() => void) | undefined;
|
||||
const done = vi.fn();
|
||||
vi.spyOn(harness.session.modelRuntime, "getAvailableSnapshot").mockImplementation(() => snapshot);
|
||||
vi.spyOn(harness.session.modelRuntime, "refresh").mockImplementation(
|
||||
(options) =>
|
||||
new Promise((resolve) => {
|
||||
refreshSignal = options?.signal;
|
||||
finishRefresh = resolve;
|
||||
}),
|
||||
);
|
||||
const context = {
|
||||
session: harness.session,
|
||||
settingsManager: harness.settingsManager,
|
||||
showSelector: (
|
||||
factory: (close: () => void) => {
|
||||
component: ScopedModelsSelectorComponent;
|
||||
dispose?: () => void;
|
||||
},
|
||||
) => {
|
||||
const close = () => {
|
||||
dispose?.();
|
||||
done();
|
||||
};
|
||||
const created = factory(close);
|
||||
selector = created.component;
|
||||
dispose = created.dispose;
|
||||
},
|
||||
updateAvailableProviderCount: vi.fn(),
|
||||
ui: { requestRender: vi.fn() } as unknown as TUI,
|
||||
};
|
||||
|
||||
showModelsSelector.call(context);
|
||||
if (!selector) throw new Error("Expected scoped-model selector to open");
|
||||
return {
|
||||
done,
|
||||
get refreshSignal() {
|
||||
return refreshSignal;
|
||||
},
|
||||
selector,
|
||||
complete(models: readonly Model<Api>[], result: ModelsRefreshResult) {
|
||||
snapshot = models;
|
||||
if (!finishRefresh) throw new Error("Expected model refresh to start");
|
||||
finishRefresh(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue #7153 scoped models refresh", () => {
|
||||
let harness: Harness | undefined;
|
||||
|
||||
beforeAll(() => initTheme("dark"));
|
||||
beforeEach(() => setKeybindings(new KeybindingsManager()));
|
||||
afterEach(() => {
|
||||
harness?.cleanup();
|
||||
harness = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders cached models immediately and updates after background refresh", async () => {
|
||||
harness = await createHarness({
|
||||
models: [
|
||||
{ id: "cached", name: "Cached" },
|
||||
{ id: "refreshed", name: "Refreshed" },
|
||||
],
|
||||
});
|
||||
const refresh = openSelector(harness, [harness.models[0]]);
|
||||
|
||||
const initial = stripAnsi(refresh.selector.render(100).join("\n"));
|
||||
expect(initial).toContain("cached");
|
||||
expect(initial).toContain("Refreshing model catalogs…");
|
||||
expect(initial).not.toContain("refreshed");
|
||||
|
||||
refresh.complete(harness.models, { aborted: false, errors: new Map() });
|
||||
await vi.waitFor(() => {
|
||||
const rendered = stripAnsi(refresh.selector.render(100).join("\n"));
|
||||
expect(rendered).toContain("refreshed");
|
||||
expect(rendered).toContain("Model catalogs refreshed.");
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels the background refresh when the selector closes", async () => {
|
||||
harness = await createHarness({ models: [{ id: "cached", name: "Cached" }] });
|
||||
const refresh = openSelector(harness, harness.models);
|
||||
|
||||
expect(refresh.refreshSignal).toBeDefined();
|
||||
refresh.selector.handleInput("\x1b");
|
||||
await vi.waitFor(() => expect(refresh.refreshSignal?.aborted).toBe(true));
|
||||
expect(refresh.done).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DefaultPackageManager } from "../../../src/core/package-manager.ts";
|
||||
import { SettingsManager } from "../../../src/core/settings-manager.ts";
|
||||
import { createHarness } from "../harness.ts";
|
||||
|
||||
describe("issue #7187 malformed package manifest", () => {
|
||||
it("ignores invalid resource fields without dropping valid fields", async () => {
|
||||
const harness = await createHarness();
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "pi-7187-"));
|
||||
const agentDir = join(tempDir, "agent");
|
||||
try {
|
||||
const packageDir = join(agentDir, "npm", "node_modules", "bad-package");
|
||||
const skillPath = join(packageDir, "skills", "bad", "SKILL.md");
|
||||
const promptPath = join(packageDir, "prompts", "valid.md");
|
||||
mkdirSync(join(packageDir, "skills", "bad"), { recursive: true });
|
||||
mkdirSync(join(packageDir, "prompts"), { recursive: true });
|
||||
writeFileSync(skillPath, "---\nname: bad\ndescription: Must not load\n---\n");
|
||||
writeFileSync(promptPath, "Valid prompt\n");
|
||||
writeFileSync(
|
||||
join(packageDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "bad-package",
|
||||
version: "1.0.0",
|
||||
pi: {
|
||||
skills: "./skills",
|
||||
prompts: ["./prompts"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const packageManager = new DefaultPackageManager({
|
||||
cwd: tempDir,
|
||||
agentDir,
|
||||
settingsManager: SettingsManager.inMemory({ packages: ["npm:bad-package"] }),
|
||||
});
|
||||
|
||||
const resources = await packageManager.resolve();
|
||||
expect(resources.skills.map((skill) => skill.path)).not.toContain(skillPath);
|
||||
expect(resources.prompts.map((prompt) => prompt.path)).toContain(promptPath);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createEventBus } from "../../../src/core/event-bus.ts";
|
||||
import { createExtensionRuntime, loadExtensionFromFactory } from "../../../src/core/extensions/loader.ts";
|
||||
import type { ExtensionAPI, ExtensionFactory, ResourceLoader } from "../../../src/index.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
describe("issue #7193 extension event-bus lifecycle", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("removes extension-owned event-bus listeners on reload and dispose", async () => {
|
||||
const eventBus = createEventBus();
|
||||
let extensionCalls = 0;
|
||||
let hostCalls = 0;
|
||||
let firstApi: ExtensionAPI | undefined;
|
||||
const factory: ExtensionFactory = (pi) => {
|
||||
firstApi ??= pi;
|
||||
pi.events.on("reload:test", () => extensionCalls++);
|
||||
};
|
||||
eventBus.on("reload:test", () => hostCalls++);
|
||||
|
||||
const loadExtensions = async () => {
|
||||
const runtime = createExtensionRuntime();
|
||||
const extension = await loadExtensionFromFactory(factory, process.cwd(), eventBus, runtime);
|
||||
return { extensions: [extension], errors: [], runtime };
|
||||
};
|
||||
let extensionsResult = await loadExtensions();
|
||||
const resourceLoader: ResourceLoader = {
|
||||
getExtensions: () => extensionsResult,
|
||||
getSkills: () => ({ skills: [], diagnostics: [] }),
|
||||
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||
getAgentsFiles: () => ({ agentsFiles: [] }),
|
||||
getSystemPrompt: () => undefined,
|
||||
getSystemPromptSource: () => undefined,
|
||||
getAppendSystemPrompt: () => [],
|
||||
getAppendSystemPromptSources: () => [],
|
||||
extendResources: () => {},
|
||||
reload: async () => {
|
||||
extensionsResult = await loadExtensions();
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({ resourceLoader });
|
||||
harnesses.push(harness);
|
||||
await harness.session.bindExtensions({ shutdownHandler: () => {} });
|
||||
|
||||
const emit = async () => {
|
||||
const extensionBefore = extensionCalls;
|
||||
const hostBefore = hostCalls;
|
||||
eventBus.emit("reload:test", undefined);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
return { extension: extensionCalls - extensionBefore, host: hostCalls - hostBefore };
|
||||
};
|
||||
|
||||
expect(await emit()).toEqual({ extension: 1, host: 1 });
|
||||
await harness.session.reload();
|
||||
expect(() => firstApi?.getCommands()).toThrow("stale after session replacement or reload");
|
||||
expect(await emit()).toEqual({ extension: 1, host: 1 });
|
||||
await harness.session.reload();
|
||||
expect(await emit()).toEqual({ extension: 1, host: 1 });
|
||||
|
||||
harness.session.dispose();
|
||||
expect(await emit()).toEqual({ extension: 0, host: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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]?.replace(/^✓\s*/, "");
|
||||
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.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.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,83 @@
|
||||
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 createNoopTool(): AgentTool {
|
||||
return {
|
||||
name: "noop",
|
||||
label: "No-op",
|
||||
description: "Return immediately",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "done" }], details: {} }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue #7253: manual compaction during an active response", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists the aborted response before running the requested manual compaction", async () => {
|
||||
let markSecondResponseStarted = () => {};
|
||||
const secondResponseStarted = new Promise<void>((resolve) => {
|
||||
markSecondResponseStarted = resolve;
|
||||
});
|
||||
let releaseSecondResponse = () => {};
|
||||
const secondResponseReleased = new Promise<void>((resolve) => {
|
||||
releaseSecondResponse = resolve;
|
||||
});
|
||||
|
||||
const harness = await createHarness({
|
||||
models: [{ id: "faux-1", contextWindow: 1000, maxTokens: 1000 }],
|
||||
settings: { compaction: { enabled: true, reserveTokens: 200, keepRecentTokens: 2 } },
|
||||
tools: [createNoopTool()],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_before_compact", async (event) => ({
|
||||
compaction: {
|
||||
summary: `${event.reason} summary`,
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
details: {},
|
||||
},
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("noop", {}), { stopReason: "toolUse" }),
|
||||
async () => {
|
||||
markSecondResponseStarted();
|
||||
await secondResponseReleased;
|
||||
return fauxAssistantMessage(`second response:${"x".repeat(4000)}`);
|
||||
},
|
||||
]);
|
||||
|
||||
const promptPromise = harness.session.prompt("Run the tool, then continue responding.");
|
||||
await secondResponseStarted;
|
||||
|
||||
const compactPromise = harness.session.compact();
|
||||
const compactExpectation = expect(compactPromise).resolves.toMatchObject({ summary: "manual summary" });
|
||||
releaseSecondResponse();
|
||||
await Promise.all([promptPromise, compactExpectation]);
|
||||
|
||||
expect(harness.eventsOfType("compaction_start").map((event) => event.reason)).toEqual(["manual"]);
|
||||
expect(harness.eventsOfType("compaction_end").map((event) => event.reason)).toEqual(["manual"]);
|
||||
const entries = harness.sessionManager.getEntries();
|
||||
const abortedResponseIndex = entries.findIndex(
|
||||
(entry) =>
|
||||
entry.type === "message" && entry.message.role === "assistant" && entry.message.stopReason === "aborted",
|
||||
);
|
||||
const compactionIndex = entries.findIndex((entry) => entry.type === "compaction");
|
||||
expect(abortedResponseIndex).toBeGreaterThan(-1);
|
||||
expect(compactionIndex).toBeGreaterThan(abortedResponseIndex);
|
||||
expect(entries.filter((entry) => entry.type === "compaction")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { parseArgs } from "../../../src/cli/args.ts";
|
||||
import { createHarness, getUserTexts, type Harness } from "../harness.ts";
|
||||
|
||||
describe("issue #7269 CLI end-of-options delimiter", () => {
|
||||
let harness: Harness | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
harness?.cleanup();
|
||||
harness = undefined;
|
||||
});
|
||||
|
||||
it.each(["- summarize the following points for me", "--answer my question briefly"])(
|
||||
"passes %j as a prompt after --",
|
||||
async (prompt) => {
|
||||
const parsed = parseArgs(["-ne", "--no-session", "-p", "--", prompt]);
|
||||
expect(parsed.messages).toEqual([prompt]);
|
||||
expect(parsed.unknownFlags.size).toBe(0);
|
||||
expect(parsed.diagnostics).toEqual([]);
|
||||
|
||||
harness = await createHarness();
|
||||
harness.setResponses([fauxAssistantMessage("ok")]);
|
||||
await harness.session.prompt(parsed.messages[0]);
|
||||
expect(getUserTexts(harness)).toEqual([prompt]);
|
||||
},
|
||||
);
|
||||
|
||||
it("stops parsing options while retaining @file handling", () => {
|
||||
const parsed = parseArgs(["--unknown-flag", "value", "--", "--provider", "openai", "-c", "@prompt.md"]);
|
||||
|
||||
expect(parsed.unknownFlags.get("unknown-flag")).toBe("value");
|
||||
expect(parsed.provider).toBeUndefined();
|
||||
expect(parsed.continue).toBeUndefined();
|
||||
expect(parsed.messages).toEqual(["--provider", "openai", "-c"]);
|
||||
expect(parsed.fileArgs).toEqual(["prompt.md"]);
|
||||
expect(parsed.diagnostics).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { toJsonEvent } from "../../../src/modes/json-event.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
describe("regression #7290: JSON event streams stay linear", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function measureUpdateBytes(text: string): Promise<number> {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage(text)]);
|
||||
|
||||
await harness.session.prompt("respond");
|
||||
|
||||
const sessionUpdates = harness.eventsOfType("message_update");
|
||||
for (const update of sessionUpdates) {
|
||||
expect(update).toHaveProperty("message");
|
||||
expect(update.assistantMessageEvent).toHaveProperty("partial");
|
||||
}
|
||||
|
||||
const updates = sessionUpdates.map((event) => toJsonEvent(event));
|
||||
expect(updates.length).toBeGreaterThan(0);
|
||||
for (const update of updates) {
|
||||
expect(update).not.toHaveProperty("message");
|
||||
expect(update.assistantMessageEvent).not.toHaveProperty("partial");
|
||||
}
|
||||
return updates.reduce((bytes, event) => bytes + Buffer.byteLength(JSON.stringify(event)), 0);
|
||||
}
|
||||
|
||||
it("emits delta-only message updates whose size scales linearly", async () => {
|
||||
const smallBytes = await measureUpdateBytes("x".repeat(2_000));
|
||||
const largeBytes = await measureUpdateBytes("x".repeat(4_000));
|
||||
|
||||
expect(largeBytes).toBeGreaterThan(smallBytes);
|
||||
expect(largeBytes / smallBytes).toBeLessThan(2.2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Models } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
interface Deferred {
|
||||
promise: Promise<void>;
|
||||
resolve(): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
interface StalledCredentialList {
|
||||
started: Promise<void>;
|
||||
release(): void;
|
||||
fail(error: Error): void;
|
||||
getCallCount(): number;
|
||||
}
|
||||
|
||||
function createDeferred(): Deferred {
|
||||
let resolvePromise = () => {};
|
||||
let rejectPromise = (_error: Error) => {};
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
resolvePromise = resolve;
|
||||
rejectPromise = reject;
|
||||
});
|
||||
return { promise, resolve: resolvePromise, reject: rejectPromise };
|
||||
}
|
||||
|
||||
function stallNextCredentialList(harness: Harness): StalledCredentialList {
|
||||
const originalList = harness.authStorage.list.bind(harness.authStorage);
|
||||
const started = createDeferred();
|
||||
const gate = createDeferred();
|
||||
let shouldStall = true;
|
||||
let callCount = 0;
|
||||
|
||||
harness.authStorage.list = async () => {
|
||||
const entries = await originalList();
|
||||
callCount++;
|
||||
if (!shouldStall) return entries;
|
||||
shouldStall = false;
|
||||
started.resolve();
|
||||
await gate.promise;
|
||||
return entries;
|
||||
};
|
||||
|
||||
return {
|
||||
started: started.promise,
|
||||
release: gate.resolve,
|
||||
fail: gate.reject,
|
||||
getCallCount: () => callCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForRecoveryRefresh(stalledList: StalledCredentialList, harness: Harness): Promise<void> {
|
||||
const refresh = harness.session.modelRuntime.refresh({ allowNetwork: false });
|
||||
await vi.waitFor(() => expect(stalledList.getCallCount()).toBe(2));
|
||||
await refresh;
|
||||
}
|
||||
|
||||
describe("issue #7301 stalled availability refresh", () => {
|
||||
let harness: Harness | undefined;
|
||||
let stalledList: StalledCredentialList | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
stalledList?.release();
|
||||
stalledList = undefined;
|
||||
harness?.cleanup();
|
||||
harness = undefined;
|
||||
});
|
||||
|
||||
it("recovers without letting the stalled refresh overwrite the newer snapshot", async () => {
|
||||
harness = await createHarness({ withConfiguredAuth: false });
|
||||
const runtime = harness.session.modelRuntime;
|
||||
await harness.authStorage.modify("stale-provider", async () => ({ type: "api_key", key: "stale-key" }));
|
||||
await runtime.refresh({ allowNetwork: false });
|
||||
expect(runtime.getProviderAuthStatus("stale-provider")).toEqual({ configured: true, source: "stored" });
|
||||
|
||||
stalledList = stallNextCredentialList(harness);
|
||||
const staleRefresh = runtime.getAvailable();
|
||||
await stalledList.started;
|
||||
|
||||
await harness.authStorage.delete("stale-provider");
|
||||
await harness.authStorage.modify("current-provider", async () => ({ type: "api_key", key: "current-key" }));
|
||||
await waitForRecoveryRefresh(stalledList, harness);
|
||||
expect(runtime.getProviderAuthStatus("stale-provider")).toEqual({ configured: false });
|
||||
expect(runtime.getProviderAuthStatus("current-provider")).toEqual({ configured: true, source: "stored" });
|
||||
|
||||
stalledList.release();
|
||||
await staleRefresh;
|
||||
expect(runtime.getProviderAuthStatus("stale-provider")).toEqual({ configured: false });
|
||||
expect(runtime.getProviderAuthStatus("current-provider")).toEqual({ configured: true, source: "stored" });
|
||||
});
|
||||
|
||||
it("does not let a stale failure overwrite newer availability error state", async () => {
|
||||
harness = await createHarness({ withConfiguredAuth: false });
|
||||
const runtime = harness.session.modelRuntime;
|
||||
stalledList = stallNextCredentialList(harness);
|
||||
const staleRefresh = runtime.getAvailable();
|
||||
await stalledList.started;
|
||||
|
||||
await waitForRecoveryRefresh(stalledList, harness);
|
||||
expect(runtime.getError()).toBeUndefined();
|
||||
|
||||
stalledList.fail(new Error("stale credential list failure"));
|
||||
await expect(staleRefresh).rejects.toThrow("stale credential list failure");
|
||||
expect(runtime.getError()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not let a stale provider-scoped failure overwrite a newer availability pass", async () => {
|
||||
harness = await createHarness();
|
||||
const runtime = harness.session.modelRuntime;
|
||||
const models = Reflect.get(runtime, "models") as Models;
|
||||
const originalGetAvailable = models.getAvailable.bind(models);
|
||||
const started = createDeferred();
|
||||
const gate = createDeferred();
|
||||
let stall = true;
|
||||
models.getAvailable = async (providerId, options) => {
|
||||
if (!providerId || !stall) return originalGetAvailable(providerId, options);
|
||||
stall = false;
|
||||
started.resolve();
|
||||
await gate.promise;
|
||||
throw new Error("stale provider availability failure");
|
||||
};
|
||||
|
||||
const staleRefresh = runtime.getAvailable(harness.getModel().provider);
|
||||
await started.promise;
|
||||
await runtime.getAvailable();
|
||||
expect(runtime.getError()).toBeUndefined();
|
||||
|
||||
gate.resolve();
|
||||
await expect(staleRefresh).rejects.toThrow("stale provider availability failure");
|
||||
expect(runtime.getError()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Api, Model } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
const findExactModelMatch = Reflect.get(InteractiveMode.prototype, "findExactModelMatch") as (
|
||||
this: object,
|
||||
searchTerm: string,
|
||||
) => Promise<Model<Api> | undefined>;
|
||||
|
||||
describe("issue #7443 /model cached match", () => {
|
||||
let harness: Harness | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
harness?.cleanup();
|
||||
harness = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("matches the availability snapshot without starting a catalog refresh", async () => {
|
||||
harness = await createHarness({ models: [{ id: "cached", name: "Cached" }] });
|
||||
const refresh = vi.spyOn(harness.session.modelRuntime, "refresh").mockImplementation(() => new Promise(() => {}));
|
||||
const context = { session: harness.session, showStatus: vi.fn(), showWarning: vi.fn() };
|
||||
|
||||
const model = await findExactModelMatch.call(context, harness.models[0].id);
|
||||
|
||||
expect(model?.id).toBe("cached");
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(context.showStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses a caller-owned deadline only after a cache miss", async () => {
|
||||
harness = await createHarness({ models: [{ id: "cached", name: "Cached" }] });
|
||||
const refresh = vi.spyOn(harness.session.modelRuntime, "refresh").mockResolvedValue({
|
||||
aborted: true,
|
||||
errors: new Map(),
|
||||
});
|
||||
const context = { session: harness.session, showStatus: vi.fn(), showWarning: vi.fn() };
|
||||
|
||||
await expect(findExactModelMatch.call(context, "not-cached")).resolves.toBeUndefined();
|
||||
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(refresh.mock.calls[0]?.[0]?.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(context.showStatus).toHaveBeenCalledWith("Refreshing model catalogs…");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ENV_AGENT_DIR } from "../../../src/config.ts";
|
||||
import { SessionManager } from "../../../src/core/session-manager.ts";
|
||||
|
||||
const DIRECTORY_LINK_TYPE = process.platform === "win32" ? "junction" : "dir";
|
||||
|
||||
describe("regression #7497: discover sessions through symlinked directories", () => {
|
||||
let tempDir: string;
|
||||
let sessionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-session-discovery-"));
|
||||
const agentDir = join(tempDir, "agent");
|
||||
sessionsDir = join(agentDir, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
vi.stubEnv(ENV_AGENT_DIR, agentDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeSession(dir: string, id: string): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, `${id}.jsonl`),
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id,
|
||||
timestamp: "2026-08-03T00:00:00.000Z",
|
||||
cwd: join(tempDir, "project"),
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
it("discovers a session through a directory link and preserves the alias path", async () => {
|
||||
const targetDir = join(tempDir, "linked-sessions");
|
||||
writeSession(targetDir, "linked");
|
||||
const aliasDir = join(sessionsDir, "--linked--");
|
||||
symlinkSync(targetDir, aliasDir, DIRECTORY_LINK_TYPE);
|
||||
|
||||
const sessions = await SessionManager.listAll();
|
||||
|
||||
expect(sessions.map((session) => session.id)).toEqual(["linked"]);
|
||||
expect(sessions[0]?.path).toBe(join(aliasDir, "linked.jsonl"));
|
||||
});
|
||||
|
||||
it("ignores a broken directory link without hiding valid sessions", async () => {
|
||||
writeSession(join(sessionsDir, "--regular--"), "regular");
|
||||
const targetDir = join(tempDir, "removed-sessions");
|
||||
mkdirSync(targetDir);
|
||||
symlinkSync(targetDir, join(sessionsDir, "--broken--"), DIRECTORY_LINK_TYPE);
|
||||
rmSync(targetDir, { recursive: true });
|
||||
|
||||
const sessions = await SessionManager.listAll();
|
||||
|
||||
expect(sessions.map((session) => session.id)).toEqual(["regular"]);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("ignores links to files", async () => {
|
||||
writeSession(join(sessionsDir, "--regular--"), "regular");
|
||||
const targetFile = join(tempDir, "not-a-directory");
|
||||
writeFileSync(targetFile, "");
|
||||
symlinkSync(targetFile, join(sessionsDir, "--file--"), "file");
|
||||
|
||||
const sessions = await SessionManager.listAll();
|
||||
|
||||
expect(sessions.map((session) => session.id)).toEqual(["regular"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { InMemorySettingsStorage, SettingsManager } from "../../../src/core/settings-manager.ts";
|
||||
|
||||
describe("regression #7572: nested provider retry settings merge", () => {
|
||||
it("preserves global provider settings not overridden by the project", () => {
|
||||
const storage = new InMemorySettingsStorage();
|
||||
storage.withLock("global", () =>
|
||||
JSON.stringify({
|
||||
retry: {
|
||||
provider: {
|
||||
timeoutMs: 30000,
|
||||
maxRetryDelayMs: 45000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
storage.withLock("project", () =>
|
||||
JSON.stringify({
|
||||
retry: {
|
||||
provider: {
|
||||
maxRetries: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const settingsManager = SettingsManager.fromStorage(storage);
|
||||
|
||||
expect(settingsManager.getProviderRetrySettings()).toEqual({
|
||||
timeoutMs: 30000,
|
||||
maxRetries: 2,
|
||||
maxRetryDelayMs: 45000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { TUI } from "@earendil-works/pi-tui";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createInteractiveTuiReference } from "../../../src/modes/interactive/interactive-mode.ts";
|
||||
|
||||
describe("TUI method wrapping", () => {
|
||||
it("calls the method captured before a replacement", () => {
|
||||
const renderer = {
|
||||
render: (width: number) => [`width: ${width}`],
|
||||
} as unknown as TUI;
|
||||
const tui = createInteractiveTuiReference(() => renderer);
|
||||
const originalRender = tui.render;
|
||||
tui.render = (width: number) => originalRender(width);
|
||||
|
||||
expect(tui.render(80)).toEqual(["width: 80"]);
|
||||
});
|
||||
|
||||
it("routes a captured method to a replacement renderer", () => {
|
||||
const regularRequestRender = vi.fn();
|
||||
const fullscreenRequestRender = vi.fn();
|
||||
let renderer = { requestRender: regularRequestRender } as unknown as TUI;
|
||||
const tui = createInteractiveTuiReference(() => renderer);
|
||||
const requestRender = tui.requestRender;
|
||||
|
||||
requestRender();
|
||||
renderer = { requestRender: fullscreenRequestRender } as unknown as TUI;
|
||||
requestRender();
|
||||
|
||||
expect(regularRequestRender).toHaveBeenCalledOnce();
|
||||
expect(fullscreenRequestRender).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Container } from "@earendil-works/pi-tui";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentSessionRuntimeDiagnostic } from "../../../src/core/agent-session-services.ts";
|
||||
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
|
||||
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
|
||||
import { createHarness } from "../harness.ts";
|
||||
|
||||
function render(container: Container): string {
|
||||
return container.children.flatMap((child) => child.render(120)).join("\n");
|
||||
}
|
||||
|
||||
describe("issue #7829 invalid settings warning", () => {
|
||||
beforeAll(() => initTheme("dark"));
|
||||
|
||||
it("renders startup diagnostics inside the transcript", async () => {
|
||||
const harness = await createHarness();
|
||||
const previousOffline = process.env.PI_OFFLINE;
|
||||
process.env.PI_OFFLINE = "1";
|
||||
try {
|
||||
const chatContainer = new Container();
|
||||
const startupDiagnostics: AgentSessionRuntimeDiagnostic[] = [
|
||||
{
|
||||
type: "warning",
|
||||
message: "Invalid settings file /tmp/settings.json: malformed JSON",
|
||||
},
|
||||
];
|
||||
const context = {
|
||||
init: vi.fn(async () => {}),
|
||||
options: { startupDiagnostics },
|
||||
chatContainer,
|
||||
outputPad: 1,
|
||||
ui: { requestRender: vi.fn() },
|
||||
version: "test",
|
||||
showWarning: (InteractiveMode.prototype as unknown as { showWarning(message: string): void }).showWarning,
|
||||
session: harness.session,
|
||||
checkForPackageUpdates: vi.fn().mockResolvedValue([]),
|
||||
checkTmuxKeyboardSetup: vi.fn().mockResolvedValue(undefined),
|
||||
maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(),
|
||||
getUserInput: vi.fn(() => new Promise<string>(() => {})),
|
||||
};
|
||||
const run = (InteractiveMode.prototype as unknown as { run(this: typeof context): Promise<void> }).run;
|
||||
|
||||
void run.call(context);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(render(chatContainer)).toContain(
|
||||
"Warning: Invalid settings file /tmp/settings.json: malformed JSON",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
if (previousOffline === undefined) delete process.env.PI_OFFLINE;
|
||||
else process.env.PI_OFFLINE = previousOffline;
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { toJsonEvent } from "../../../src/modes/json-event.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
describe("regression #7911: JSON message updates retain usage", () => {
|
||||
let harness: Harness | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
harness?.cleanup();
|
||||
});
|
||||
|
||||
it("includes cumulative usage without cumulative message snapshots", async () => {
|
||||
harness = await createHarness();
|
||||
harness.setResponses([fauxAssistantMessage("hello")]);
|
||||
|
||||
await harness.session.prompt("respond");
|
||||
|
||||
// #7290's delta-only wire projection dropped this fixed-size metadata with the snapshots.
|
||||
const update = harness
|
||||
.eventsOfType("message_update")
|
||||
.find((event) => event.message.role === "assistant" && event.message.usage.totalTokens > 0);
|
||||
if (!update || update.message.role !== "assistant") {
|
||||
throw new Error("Expected an assistant update with populated usage");
|
||||
}
|
||||
|
||||
const wireUpdate = toJsonEvent(update);
|
||||
expect(wireUpdate.usage).toEqual(update.message.usage);
|
||||
expect(wireUpdate).not.toHaveProperty("message");
|
||||
expect(wireUpdate.assistantMessageEvent).not.toHaveProperty("partial");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { toJsonEvent } from "../../../src/modes/json-event.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
describe("regression #7925: tool-call metadata is available when streaming starts", () => {
|
||||
let harness: Harness | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
harness?.cleanup();
|
||||
});
|
||||
|
||||
it("includes the tool call id and name without cumulative snapshots", async () => {
|
||||
harness = await createHarness();
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(
|
||||
fauxToolCall("write", { path: "output.txt", content: "x".repeat(100) }, { id: "call_7925" }),
|
||||
{ stopReason: "toolUse" },
|
||||
),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("write a file");
|
||||
|
||||
const update = harness
|
||||
.eventsOfType("message_update")
|
||||
.find((event) => event.assistantMessageEvent.type === "toolcall_start");
|
||||
if (!update || update.message.role !== "assistant") {
|
||||
throw new Error("Expected toolcall_start assistant update");
|
||||
}
|
||||
|
||||
expect(toJsonEvent(update)).toEqual({
|
||||
type: "message_update",
|
||||
usage: update.message.usage,
|
||||
assistantMessageEvent: {
|
||||
type: "toolcall_start",
|
||||
contentIndex: 0,
|
||||
id: "call_7925",
|
||||
toolName: "write",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => {
|
||||
const originalGetBuiltinModule = Object.getOwnPropertyDescriptor(process, "getBuiltinModule");
|
||||
const getBuiltinModule = process.getBuiltinModule.bind(process);
|
||||
Object.defineProperty(process, "getBuiltinModule", {
|
||||
configurable: true,
|
||||
value: (id: string) => (id === "node:sea" ? { isSea: () => true } : getBuiltinModule(id)),
|
||||
});
|
||||
return {
|
||||
originalGetBuiltinModule,
|
||||
createJiti: vi.fn((_id: unknown, _options: unknown) => ({
|
||||
import: vi.fn(async () => () => {}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("jiti/static", () => ({ createJiti: state.createJiti }));
|
||||
|
||||
import { loadExtensions } from "../../../src/core/extensions/loader.ts";
|
||||
|
||||
interface JitiOptionsProbe {
|
||||
alias?: unknown;
|
||||
tryNative?: boolean;
|
||||
virtualModules?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
if (state.originalGetBuiltinModule) {
|
||||
Object.defineProperty(process, "getBuiltinModule", state.originalGetBuiltinModule);
|
||||
}
|
||||
});
|
||||
|
||||
describe("Node SEA extension loading", () => {
|
||||
it("uses bundled virtual modules instead of filesystem aliases", async () => {
|
||||
const result = await loadExtensions(["/extension.ts"], "/");
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(state.createJiti).toHaveBeenCalledOnce();
|
||||
|
||||
const options = state.createJiti.mock.calls[0][1] as JitiOptionsProbe;
|
||||
// Source TypeScript also uses virtual modules, so tryNative: false is what
|
||||
// proves the compiled-binary branch took precedence over the source branch.
|
||||
expect(options.tryNative).toBe(false);
|
||||
expect(options.alias).toBeUndefined();
|
||||
expect(options.virtualModules?.typebox).toBeDefined();
|
||||
expect(options.virtualModules?.["@earendil-works/pi-coding-agent"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import subagentExtension from "../../../examples/extensions/subagent/index.ts";
|
||||
import type { ExtensionUIContext } from "../../../src/core/extensions/index.ts";
|
||||
import { createHarness, getMessageText } from "../harness.ts";
|
||||
|
||||
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
||||
CONFIG_DIR_NAME: ".pi",
|
||||
getAgentDir: () => "/missing-user-agent-dir",
|
||||
getMarkdownTheme: () => ({}),
|
||||
parseFrontmatter: (content: string) => ({
|
||||
frontmatter: { name: "project-agent", description: "Project test agent" },
|
||||
body: content,
|
||||
}),
|
||||
withFileMutationQueue: async (_path: string, fn: () => Promise<unknown>) => fn(),
|
||||
}));
|
||||
|
||||
interface RunOptions {
|
||||
trusted: boolean;
|
||||
confirmResult?: boolean;
|
||||
}
|
||||
|
||||
async function runProjectAgent(options: RunOptions): Promise<{ confirmCalls: number; toolResult: string }> {
|
||||
const harness = await createHarness({ extensionFactories: [subagentExtension] });
|
||||
const confirm = vi.fn(async () => options.confirmResult ?? false);
|
||||
|
||||
try {
|
||||
const agentsDir = join(harness.tempDir, ".pi", "agents");
|
||||
mkdirSync(agentsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(agentsDir, "project-agent.md"),
|
||||
"---\nname: project-agent\ndescription: Project test agent\n---\n\nHandle the delegated task.\n",
|
||||
);
|
||||
harness.settingsManager.setProjectTrusted(options.trusted);
|
||||
|
||||
await harness.session.bindExtensions({
|
||||
uiContext: { confirm } as unknown as ExtensionUIContext,
|
||||
mode: "tui",
|
||||
});
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(
|
||||
fauxToolCall("subagent", {
|
||||
agent: "project-agent",
|
||||
task: "Test project trust",
|
||||
agentScope: "project",
|
||||
cwd: join(harness.tempDir, "missing-cwd"),
|
||||
}),
|
||||
{ stopReason: "toolUse" },
|
||||
),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("Delegate this task");
|
||||
|
||||
const toolResult = harness.session.messages.find((message) => message.role === "toolResult");
|
||||
return {
|
||||
confirmCalls: confirm.mock.calls.length,
|
||||
toolResult: getMessageText(toolResult),
|
||||
};
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
describe("regression #8261: subagent project trust", () => {
|
||||
it("skips per-call confirmation for trusted projects", async () => {
|
||||
const result = await runProjectAgent({ trusted: true });
|
||||
|
||||
expect(result.confirmCalls).toBe(0);
|
||||
expect(result.toolResult).not.toContain("Canceled:");
|
||||
});
|
||||
|
||||
it("keeps confirmation for untrusted interactive projects", async () => {
|
||||
const result = await runProjectAgent({ trusted: false, confirmResult: false });
|
||||
|
||||
expect(result.confirmCalls).toBe(1);
|
||||
expect(result.toolResult).toContain("Canceled: project-local agents not approved.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
type SessionWithCompactionInternals = {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage) => Promise<boolean>;
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<boolean>;
|
||||
};
|
||||
|
||||
function createZeroUsageAssistant(harness: Harness): AssistantMessage {
|
||||
const model = harness.getModel();
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "response" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue #8328 zero-usage auto-compaction", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function createCompactionHarness(): Promise<Harness> {
|
||||
const harness = await createHarness({
|
||||
models: [{ id: "faux-1", contextWindow: 100, maxTokens: 20 }],
|
||||
settings: { compaction: { enabled: true, reserveTokens: 10 } },
|
||||
});
|
||||
harnesses.push(harness);
|
||||
return harness;
|
||||
}
|
||||
|
||||
it("uses the message estimate when no assistant has reported usage", async () => {
|
||||
const harness = await createCompactionHarness();
|
||||
const assistant = createZeroUsageAssistant(harness);
|
||||
harness.session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() - 1 },
|
||||
assistant,
|
||||
];
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false);
|
||||
|
||||
await sessionInternals._checkCompaction(assistant);
|
||||
|
||||
expect(runAutoCompactionSpy).toHaveBeenCalledOnce();
|
||||
expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false);
|
||||
});
|
||||
|
||||
it("does not compact when the zero-usage message estimate is below the threshold", async () => {
|
||||
const harness = await createCompactionHarness();
|
||||
const assistant = createZeroUsageAssistant(harness);
|
||||
harness.session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "short" }], timestamp: Date.now() - 1 },
|
||||
assistant,
|
||||
];
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false);
|
||||
|
||||
await sessionInternals._checkCompaction(assistant);
|
||||
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { existsSync, 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";
|
||||
import { SettingsManager } from "../../../src/core/settings-manager.ts";
|
||||
import { parseFrontmatter } from "../../../src/utils/frontmatter.ts";
|
||||
import { splitBom } from "../../../src/utils/text.ts";
|
||||
|
||||
describe("issue #8337 UTF-8 BOM parsing", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = mkdtempSync(join(tmpdir(), "pi-8337-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("loads frontmatter and settings with a leading BOM", async () => {
|
||||
expect(splitBom("\uFEFFcontent")).toEqual({ bom: "\uFEFF", text: "content" });
|
||||
const document = "---\nname: demo\ndescription: Test\n---\nBody";
|
||||
expect(parseFrontmatter(`\uFEFF${document}`)).toEqual({
|
||||
frontmatter: { name: "demo", description: "Test" },
|
||||
body: "Body",
|
||||
});
|
||||
|
||||
const agentDir = join(testDir, "agent");
|
||||
const projectDir = join(testDir, "project");
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
const globalSettingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(globalSettingsPath, `\uFEFF${JSON.stringify({ defaultModel: "global-model" })}`);
|
||||
writeFileSync(
|
||||
join(projectDir, ".pi", "settings.json"),
|
||||
`\uFEFF${JSON.stringify({ defaultProvider: "project-provider" })}`,
|
||||
);
|
||||
|
||||
const settings = SettingsManager.create(projectDir, agentDir);
|
||||
expect(settings.getDefaultModel()).toBe("global-model");
|
||||
expect(settings.getDefaultProvider()).toBe("project-provider");
|
||||
|
||||
settings.setTheme("dark");
|
||||
await settings.flush();
|
||||
expect(readFileSync(globalSettingsPath, "utf-8")).not.toMatch(/^\uFEFF/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createEventBus } from "../../../src/core/event-bus.ts";
|
||||
import { createExtensionRuntime, loadExtensionFromFactory } from "../../../src/core/extensions/loader.ts";
|
||||
import type { ExtensionAPI, ProviderConfig } from "../../../src/core/extensions/types.ts";
|
||||
|
||||
const providerConfig = {
|
||||
baseUrl: "https://provider.test/v1",
|
||||
apiKey: "provider-test-key",
|
||||
} satisfies ProviderConfig;
|
||||
|
||||
describe("issue #8423 extension factory failure", () => {
|
||||
it("discards runtime changes and disables the failed API", async () => {
|
||||
const runtime = createExtensionRuntime();
|
||||
const eventBus = createEventBus();
|
||||
let capturedApi: ExtensionAPI | undefined;
|
||||
let eventCalls = 0;
|
||||
let flagDuringLoad: boolean | string | undefined;
|
||||
|
||||
await loadExtensionFromFactory(
|
||||
(pi) => pi.registerProvider("working-provider", providerConfig),
|
||||
process.cwd(),
|
||||
eventBus,
|
||||
runtime,
|
||||
"<working>",
|
||||
);
|
||||
await expect(
|
||||
loadExtensionFromFactory(
|
||||
(pi) => {
|
||||
capturedApi = pi;
|
||||
pi.events.on("factory-failure", () => {
|
||||
eventCalls++;
|
||||
});
|
||||
pi.registerFlag("failed-flag", { type: "boolean", default: true });
|
||||
flagDuringLoad = pi.getFlag("failed-flag");
|
||||
pi.unregisterProvider("working-provider");
|
||||
pi.registerProvider("failed-provider", providerConfig);
|
||||
throw new Error("factory failed");
|
||||
},
|
||||
process.cwd(),
|
||||
eventBus,
|
||||
runtime,
|
||||
"<failing>",
|
||||
),
|
||||
).rejects.toThrow("factory failed");
|
||||
|
||||
eventBus.emit("factory-failure", undefined);
|
||||
expect(flagDuringLoad).toBe(true);
|
||||
expect(runtime.flagValues.has("failed-flag")).toBe(false);
|
||||
expect(runtime.pendingProviderRegistrations.map(({ name }) => name)).toEqual(["working-provider"]);
|
||||
expect(eventCalls).toBe(0);
|
||||
expect(capturedApi).toBeDefined();
|
||||
expect(() => capturedApi?.registerFlag("late-flag", { type: "boolean", default: true })).toThrow(
|
||||
'Extension "<failing>" failed to load and its API is no longer active.',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not discard a concurrently loaded factory's provider", async () => {
|
||||
const runtime = createExtensionRuntime();
|
||||
const eventBus = createEventBus();
|
||||
let releaseFailure!: () => void;
|
||||
const waitBeforeFailure = new Promise<void>((resolve) => {
|
||||
releaseFailure = resolve;
|
||||
});
|
||||
const failingLoad = loadExtensionFromFactory(
|
||||
async (pi) => {
|
||||
pi.registerProvider("failed-provider", providerConfig);
|
||||
await waitBeforeFailure;
|
||||
throw new Error("factory failed");
|
||||
},
|
||||
process.cwd(),
|
||||
eventBus,
|
||||
runtime,
|
||||
"<failing>",
|
||||
);
|
||||
|
||||
await loadExtensionFromFactory(
|
||||
(pi) => pi.registerProvider("working-provider", providerConfig),
|
||||
process.cwd(),
|
||||
eventBus,
|
||||
runtime,
|
||||
"<working>",
|
||||
);
|
||||
releaseFailure();
|
||||
|
||||
await expect(failingLoad).rejects.toThrow("factory failed");
|
||||
expect(runtime.pendingProviderRegistrations.map(({ name }) => name)).toEqual(["working-provider"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { AgentMessage, 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 { convertToLlm } from "../../../src/core/messages.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
function roles(messages: AgentMessage[]): string[] {
|
||||
return messages.map((message) => message.role);
|
||||
}
|
||||
|
||||
describe("#8537 custom messages injected during tool execution", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("appends the message after the turn's tool results instead of between call and result", async () => {
|
||||
let notify: (() => Promise<void>) | undefined;
|
||||
const slowTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for a background task",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
// A background task (e.g. a subagent reply) notifies the session while the
|
||||
// tool is still running.
|
||||
await notify?.();
|
||||
return { content: [{ type: "text", text: "tool done" }], details: {} };
|
||||
},
|
||||
};
|
||||
|
||||
const harness = await createHarness({ tools: [slowTool] });
|
||||
harnesses.push(harness);
|
||||
notify = () =>
|
||||
harness.session.sendCustomMessage(
|
||||
{ customType: "subagent-reply", content: "subagent replied", display: true },
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(roles(harness.session.messages)).toEqual(["user", "assistant", "toolResult", "custom", "assistant"]);
|
||||
});
|
||||
|
||||
it("keeps session entries and message events in the same order as agent state", async () => {
|
||||
let notify: (() => Promise<void>) | undefined;
|
||||
const slowTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for a background task",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await notify?.();
|
||||
return { content: [{ type: "text", text: "tool done" }], details: {} };
|
||||
},
|
||||
};
|
||||
|
||||
const harness = await createHarness({ tools: [slowTool] });
|
||||
harnesses.push(harness);
|
||||
notify = () =>
|
||||
harness.session.sendCustomMessage(
|
||||
{ customType: "subagent-reply", content: "subagent replied", display: true },
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
const entryKinds = harness.sessionManager
|
||||
.getBranch()
|
||||
.flatMap((entry) =>
|
||||
entry.type === "message" ? [entry.message.role] : entry.type === "custom_message" ? ["custom"] : [],
|
||||
);
|
||||
expect(entryKinds).toEqual(["user", "assistant", "toolResult", "custom", "assistant"]);
|
||||
|
||||
// message events must never describe a message the session tree does not contain yet
|
||||
const messageStarts = harness.events.flatMap((event) =>
|
||||
event.type === "message_start" ? [event.message.role] : [],
|
||||
);
|
||||
expect(messageStarts).toEqual(["user", "assistant", "toolResult", "custom", "assistant"]);
|
||||
});
|
||||
|
||||
it("produces an llm history where every tool result follows its tool call", async () => {
|
||||
let notify: (() => Promise<void>) | undefined;
|
||||
const slowTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for a background task",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await notify?.();
|
||||
return { content: [{ type: "text", text: "tool done" }], details: {} };
|
||||
},
|
||||
};
|
||||
|
||||
const harness = await createHarness({ tools: [slowTool] });
|
||||
harnesses.push(harness);
|
||||
notify = () =>
|
||||
harness.session.sendCustomMessage(
|
||||
{ customType: "subagent-reply", content: "subagent replied", display: true },
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
fauxAssistantMessage("second turn"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
await harness.session.prompt("and now?");
|
||||
|
||||
const llmMessages = convertToLlm(harness.session.messages);
|
||||
const openToolCallIds = new Set<string>();
|
||||
for (const message of llmMessages) {
|
||||
if (message.role === "assistant") {
|
||||
openToolCallIds.clear();
|
||||
for (const block of message.content) {
|
||||
if (block.type === "toolCall") openToolCallIds.add(block.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
expect(openToolCallIds.has(message.toolCallId)).toBe(true);
|
||||
openToolCallIds.delete(message.toolCallId);
|
||||
continue;
|
||||
}
|
||||
openToolCallIds.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Container, type TUI } from "@earendil-works/pi-tui";
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { 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";
|
||||
|
||||
type UpdateThinkingBlockVisibility = (this: { chatContainer: Container; ui: TUI }) => void;
|
||||
|
||||
type ToggleThinkingBlockVisibility = (this: {
|
||||
hideThinkingBlock: boolean;
|
||||
settingsManager: { setHideThinkingBlock(hidden: boolean): void };
|
||||
updateThinkingBlockVisibility(): void;
|
||||
showStatus(message: string): void;
|
||||
}) => void;
|
||||
|
||||
function renderChat(container: Container): string {
|
||||
return stripAnsi(container.render(120).join("\n"));
|
||||
}
|
||||
|
||||
describe("thinking visibility while a bash tool is running (#8611)", () => {
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
test("preserves partial bash output", () => {
|
||||
const ui = { requestRender: vi.fn() } as unknown as TUI;
|
||||
const chatContainer = new Container();
|
||||
const component = new ToolExecutionComponent(
|
||||
"bash",
|
||||
"tool-8611",
|
||||
{ command: "echo first; sleep 10" },
|
||||
{ showImages: false },
|
||||
undefined,
|
||||
ui,
|
||||
process.cwd(),
|
||||
);
|
||||
component.markExecutionStarted();
|
||||
component.updateResult({ content: [{ type: "text", text: "first" }], isError: false }, true);
|
||||
chatContainer.addChild(component);
|
||||
|
||||
const updateThinkingBlockVisibility = Reflect.get(
|
||||
InteractiveMode.prototype,
|
||||
"updateThinkingBlockVisibility",
|
||||
) as UpdateThinkingBlockVisibility;
|
||||
const toggleThinkingBlockVisibility = Reflect.get(
|
||||
InteractiveMode.prototype,
|
||||
"toggleThinkingBlockVisibility",
|
||||
) as ToggleThinkingBlockVisibility;
|
||||
const fakeThis = {
|
||||
hideThinkingBlock: false,
|
||||
settingsManager: { setHideThinkingBlock: vi.fn() },
|
||||
chatContainer,
|
||||
ui,
|
||||
updateThinkingBlockVisibility() {
|
||||
updateThinkingBlockVisibility.call(this);
|
||||
},
|
||||
showStatus: vi.fn(),
|
||||
};
|
||||
|
||||
expect(renderChat(chatContainer)).toContain("first");
|
||||
toggleThinkingBlockVisibility.call(fakeThis);
|
||||
|
||||
expect(fakeThis.settingsManager.setHideThinkingBlock).toHaveBeenCalledWith(true);
|
||||
expect(chatContainer.children).toContain(component);
|
||||
expect(renderChat(chatContainer)).toContain("first");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { AgentTool, AgentToolResult } 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 {
|
||||
AgentSessionRuntime,
|
||||
type AgentSessionServices,
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
createAgentSessionFromServices,
|
||||
} from "../../../src/core/agent-session-runtime.ts";
|
||||
import { createHarness } from "../harness.ts";
|
||||
|
||||
describe("regression #8724: in-memory fork during an active tool turn", () => {
|
||||
const cleanups: Array<() => Promise<void> | void> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) {
|
||||
await cleanups.pop()?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not append the aborted turn to the replacement session", async () => {
|
||||
let markToolStarted = () => {};
|
||||
const toolStarted = new Promise<void>((resolve) => {
|
||||
markToolStarted = resolve;
|
||||
});
|
||||
const blockingTool: AgentTool = {
|
||||
name: "block",
|
||||
label: "Block",
|
||||
description: "Wait until aborted",
|
||||
parameters: Type.Object({}),
|
||||
execute: (_toolCallId, _params, signal) =>
|
||||
new Promise<AgentToolResult<unknown>>((resolve) => {
|
||||
markToolStarted();
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => resolve({ content: [{ type: "text", text: "tool aborted" }], details: {} }),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
};
|
||||
const harness = await createHarness({ tools: [blockingTool] });
|
||||
const services: AgentSessionServices = {
|
||||
cwd: harness.tempDir,
|
||||
agentDir: harness.tempDir,
|
||||
modelRuntime: harness.session.modelRuntime,
|
||||
settingsManager: harness.settingsManager,
|
||||
resourceLoader: harness.session.resourceLoader,
|
||||
diagnostics: [],
|
||||
};
|
||||
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ sessionManager, sessionStartEvent }) => ({
|
||||
...(await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
model: harness.getModel(),
|
||||
noTools: "all",
|
||||
})),
|
||||
services,
|
||||
diagnostics: [],
|
||||
});
|
||||
const runtime = new AgentSessionRuntime(harness.session, services, createRuntime);
|
||||
cleanups.push(async () => {
|
||||
if (runtime.session !== harness.session) {
|
||||
await runtime.dispose();
|
||||
}
|
||||
harness.cleanup();
|
||||
});
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("first response"),
|
||||
fauxAssistantMessage(fauxToolCall("block", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("unused after abort"),
|
||||
]);
|
||||
await runtime.session.prompt("first prompt");
|
||||
const firstUserEntryId = runtime.session.getUserMessagesForForking()[0]?.entryId;
|
||||
expect(firstUserEntryId).toBeDefined();
|
||||
|
||||
const outgoingPrompt = runtime.session.prompt("start blocking tool");
|
||||
await toolStarted;
|
||||
const forkResult = await runtime.fork(firstUserEntryId!);
|
||||
await outgoingPrompt;
|
||||
await runtime.session.bindExtensions({});
|
||||
|
||||
expect(forkResult).toEqual({ cancelled: false, selectedText: "first prompt" });
|
||||
expect(runtime.session.messages).toEqual([]);
|
||||
expect(runtime.session.sessionManager.getEntries().filter((entry) => entry.type === "message")).toEqual([]);
|
||||
|
||||
let capturedRoles: string[] = [];
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
capturedRoles = context.messages.map((message) => message.role);
|
||||
return fauxAssistantMessage("next response");
|
||||
},
|
||||
]);
|
||||
await runtime.session.prompt("next prompt");
|
||||
|
||||
expect(capturedRoles).toEqual(["user"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
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, getMessageText, type Harness } from "../harness.ts";
|
||||
|
||||
describe("#8935 parallel preflight abort", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not start prepared tools after a later preflight aborts", async () => {
|
||||
const executions: string[] = [];
|
||||
const preflights: string[] = [];
|
||||
const resultHooks: string[] = [];
|
||||
const externalWrite: AgentTool = {
|
||||
name: "external_write",
|
||||
label: "External write",
|
||||
description: "Perform an external write",
|
||||
parameters: Type.Object({ value: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const value =
|
||||
typeof params === "object" && params !== null && "value" in params ? String(params.value) : "";
|
||||
executions.push(value);
|
||||
return { content: [{ type: "text", text: value }], details: { value } };
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [externalWrite],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
const value = "value" in event.input ? String(event.input.value) : "";
|
||||
preflights.push(value);
|
||||
if (value === "second") ctx.abort();
|
||||
});
|
||||
pi.on("tool_result", async (event) => {
|
||||
resultHooks.push(event.toolCallId);
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(
|
||||
[fauxToolCall("external_write", { value: "first" }), fauxToolCall("external_write", { value: "second" })],
|
||||
{ stopReason: "toolUse" },
|
||||
),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("run both writes");
|
||||
|
||||
expect(preflights).toEqual(["first", "second"]);
|
||||
expect(executions).toEqual([]);
|
||||
expect(resultHooks).toEqual([]);
|
||||
|
||||
const starts = harness.eventsOfType("tool_execution_start");
|
||||
const ends = harness.eventsOfType("tool_execution_end");
|
||||
expect(starts).toHaveLength(2);
|
||||
expect(ends).toHaveLength(2);
|
||||
expect(new Set(ends.map((event) => event.toolCallId))).toEqual(new Set(starts.map((event) => event.toolCallId)));
|
||||
expect(ends.every((event) => event.isError)).toBe(true);
|
||||
|
||||
const toolResults = harness.session.messages.filter((message) => message.role === "toolResult");
|
||||
expect(toolResults.map((message) => message.toolCallId)).toEqual(starts.map((event) => event.toolCallId));
|
||||
expect(toolResults.map(getMessageText)).toEqual(["Operation aborted", "Operation aborted"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type CompactionEntry, SessionManager } from "../../../src/core/session-manager.ts";
|
||||
import { userMsg } from "../../utilities.ts";
|
||||
|
||||
describe("regression #8989", () => {
|
||||
it("preserves compaction context when a fork removes the boundary label", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
const oldId = session.appendMessage(userMsg("old"));
|
||||
// findCutPoint() can move a compaction boundary back to this context-invisible label.
|
||||
const labelId = session.appendLabelChange(oldId, "checkpoint");
|
||||
const keptId = session.appendMessage(userMsg("kept"));
|
||||
const compactionId = session.appendCompaction("summary", labelId, 100);
|
||||
const leafId = session.appendMessage(userMsg("after"));
|
||||
|
||||
session.createBranchedSession(leafId);
|
||||
|
||||
expect((session.getEntry(compactionId) as CompactionEntry).firstKeptEntryId).toBe(keptId);
|
||||
expect(session.buildSessionContext().messages).toMatchObject([
|
||||
{ role: "compactionSummary", summary: "summary" },
|
||||
{ role: "user", content: "kept" },
|
||||
{ role: "user", content: "after" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user