chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
alexion committed 2026-10-15 17:58:48 -04:00
1 parent 719370c8f7
commit 6081e3e325
1743 files changed
+205459 -131704

No files matched your search

@@ -0,0 +1,72 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { ToolRenderContext } from "../../../src/core/extensions/types.ts";
import { SessionManager } from "../../../src/core/session-manager.ts";
import { createMcpExtension } from "../../../src/extensions/mcp/index.ts";
import { initTheme, theme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
import { createHarness } from "../harness.ts";
const mcpExtension = createMcpExtension({ loadConfig: () => ({ servers: [], errors: [] }) });
describe("MCP tool renderers", () => {
// Regression #10285: a resumed session renders MCP tool calls before their server connected, if it
// ever does. They render with the MCP renderers anyway, instead of the expanded fallback.
it("renders calls to MCP tools that are not registered", async () => {
initTheme("dark");
const harness = await createHarness({ extensionFactories: [mcpExtension] });
try {
const resolve = (toolName: string) =>
harness.session.extensionRunner.resolveToolRenderers(toolName, () =>
harness.session.getToolDefinition(toolName),
);
const call = resolve("mcp__my_docs__search")?.renderCall?.({ query: "pi" }, theme, {
expanded: false,
} as unknown as ToolRenderContext);
expect(stripAnsi(call?.render(100).join("\n") ?? "")).toContain('my_docs/search query="pi"');
expect(resolve("not_mcp")).toBeUndefined();
// Registered tools keep their own renderers.
expect(resolve("read")?.renderCall).toBe(harness.session.getToolDefinition("read")?.renderCall);
} finally {
harness.cleanup();
}
});
it("renders them in HTML exports too", async () => {
initTheme("dark");
const dir = mkdtempSync(join(tmpdir(), "pi-10285-"));
const sessionManager = SessionManager.create(dir, join(dir, "sessions"));
const harness = await createHarness({ extensionFactories: [mcpExtension], sessionManager });
try {
sessionManager.appendMessage({ role: "user", content: "search", timestamp: 1 });
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "mcp__my_docs__search", arguments: { query: "pi" } }],
api: "anthropic-messages",
provider: "anthropic",
model: "test",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 2,
});
const html = readFileSync(await harness.session.exportToHtml(join(dir, "export.html")), "utf8");
const data = /<script id="session-data" type="application\/json">([^<]*)<\/script>/.exec(html)?.[1] ?? "";
const session = JSON.parse(Buffer.from(data, "base64").toString("utf8"));
expect(stripAnsi(session.renderedTools?.["call-1"]?.callHtml ?? "")).toContain("my_docs/search");
} finally {
harness.cleanup();
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -53,6 +53,7 @@ describe("regressions #1717/#2113: agent session event settlement", () => {
.filter((entry) => entry.type === "message")
.map((entry) => entry.message);
expect(branchMessages.map((message) => message.role)).toEqual([
"system",
"user",
"assistant",
"toolResult",
@@ -90,6 +91,6 @@ describe("regressions #1717/#2113: agent session event settlement", () => {
await harness.session.prompt("run tool");
expect(branchRolesAtToolCall).toEqual([["user", "assistant"]]);
expect(branchRolesAtToolCall).toEqual([["system", "user", "assistant"]]);
});
});
@@ -5,7 +5,7 @@ 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
* Regression test for https://github.com/earendil-works/pi/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
@@ -87,7 +87,7 @@ describe("regression #2835: tool allowlists filter extension tools", () => {
expect(session.getAllTools()).toEqual([]);
expect(session.getActiveToolNames()).toEqual([]);
expect(session.systemPrompt).toContain("Available tools:\n(none)");
expect(session.systemPrompt).toContain("<tools>\n(none)\n");
expect(session.systemPrompt).not.toContain("dynamic_tool");
session.dispose();
});
@@ -201,10 +201,11 @@ describe("regression #2860: replaced session callbacks", () => {
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",
]);
expect(
runtime.session.messages
.filter((message) => message.role !== "system")
.map((message) => `${message.role}:${getText(message)}`),
).toEqual(["user:Hello from the new session!", "assistant:hello reply"]);
});
it("supports withSession for fork", async () => {
@@ -232,12 +233,11 @@ describe("regression #2860: replaced session callbacks", () => {
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",
]);
expect(
runtime.session.messages
.filter((message) => message.role !== "system")
.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 () => {
@@ -269,11 +269,10 @@ describe("regression #2860: replaced session callbacks", () => {
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",
]);
expect(
runtime.session.messages
.filter((message) => message.role !== "system")
.map((message) => `${message.role}:${getText(message)}`),
).toEqual(["user:target", "assistant:target reply", "user:switch callback message", "assistant:switch reply"]);
});
});
@@ -5,7 +5,7 @@ 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
* Regression test for https://github.com/earendil-works/pi/issues/3302
*
* The `find` tool advertises glob patterns like `src/**\/*.spec.ts`, but the
* default fd-backed implementation used `fd --glob <pattern>` without
@@ -5,7 +5,7 @@ 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
* Regression test for https://github.com/earendil-works/pi/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`
@@ -91,7 +91,7 @@ describe("regression #3592: no-builtin-tools keeps extension tools enabled", ()
expect(session.getAllTools()).toEqual([]);
expect(session.getActiveToolNames()).toEqual([]);
expect(session.systemPrompt).toContain("Available tools:\n(none)");
expect(session.systemPrompt).toContain("<tools>\n(none)\n");
session.dispose();
});
@@ -112,7 +112,7 @@ describe("regression #3592: no-builtin-tools keeps extension tools enabled", ()
});
expect(session.getActiveToolNames()).toEqual([]);
expect(session.systemPrompt).toContain("Available tools:\n(none)");
expect(session.systemPrompt).toContain("<tools>\n(none)\n");
expect(session.systemPrompt).not.toContain("- read:");
session.dispose();
});
@@ -37,6 +37,7 @@ type RenderSessionContextThis = {
pendingTools: Map<string, ToolExecutionComponent>;
chatContainer: Container;
footer: { invalidate(): void };
programStatus: { handleEvent(): void };
ui: TUI;
settingsManager: {
getShowImages(): boolean;
@@ -68,6 +69,7 @@ function createFakeInteractiveModeThis(): RenderSessionContextThis {
pendingTools: new Map<string, ToolExecutionComponent>(),
chatContainer,
footer: { invalidate: vi.fn() },
programStatus: { handleEvent: vi.fn() },
ui: { requestRender: vi.fn() } as unknown as TUI,
settingsManager: {
getShowImages: () => false,
@@ -34,7 +34,13 @@ const interactiveModePrototype = InteractiveMode.prototype as unknown;
const tempDirs: string[] = [];
const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
class ProcessExitError extends Error {}
class ProcessExitError extends Error {
readonly code: number | undefined;
constructor(code?: number) {
super(`process.exit(${code})`);
this.code = code;
}
}
function createSessionManager(options: { sessionFile?: string } = {}): SessionManager {
return {
@@ -183,3 +189,131 @@ describe("InteractiveMode.shutdown ordering (#5080)", () => {
expect(context.runtimeHost.dispose).not.toHaveBeenCalled();
});
});
// Regression for the `read EIO` crash reports (crash_tty_read_eio).
//
// When the terminal goes away, stdin reads and setRawMode fail with EIO (pi is
// left in an orphaned background process group) or ENOTTY (macOS revoked the
// tty). Node emits these as `error` events on process.stdin. Without a stdin
// listener they became uncaught exceptions that were recorded as crashes and
// prompted users to run /bug on the next launch.
type HandlerContext = {
isShuttingDown: boolean;
signalCleanupHandlers: Array<() => void>;
shutdown: () => Promise<void>;
unregisterSignalHandlers: () => void;
emergencyTerminalExit: () => never;
uncaughtCrash: (error: Error) => never;
recordCrash: () => boolean;
getCrashExtensionHint: () => string | undefined;
crashReportInstructions: () => string;
ui: { stop: () => void };
};
type InteractiveModePrivates = {
registerSignalHandlers(this: HandlerContext): void;
unregisterSignalHandlers(this: HandlerContext): void;
emergencyTerminalExit(this: HandlerContext): never;
uncaughtCrash(this: HandlerContext, error: Error): never;
};
const proto = InteractiveMode.prototype as unknown as InteractiveModePrivates;
function createHandlerContext(): HandlerContext {
const context: HandlerContext = {
isShuttingDown: false,
signalCleanupHandlers: [],
shutdown: vi.fn(async () => {}),
unregisterSignalHandlers: () => proto.unregisterSignalHandlers.call(context),
emergencyTerminalExit: () => proto.emergencyTerminalExit.call(context),
uncaughtCrash: (error: Error) => proto.uncaughtCrash.call(context, error),
recordCrash: vi.fn(() => true),
getCrashExtensionHint: () => undefined,
crashReportInstructions: () => "",
ui: { stop: vi.fn() },
};
return context;
}
function errnoError(message: string, code: string): NodeJS.ErrnoException {
return Object.assign(new Error(message), { code });
}
function captureExit(fn: () => void): number | undefined {
try {
fn();
} catch (error) {
if (error instanceof ProcessExitError) return error.code;
throw error;
}
throw new Error("expected process.exit");
}
describe("dead terminal errors on stdin", () => {
let context: HandlerContext | undefined;
afterEach(() => {
context?.unregisterSignalHandlers();
context = undefined;
vi.restoreAllMocks();
});
function setup(): HandlerContext {
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new ProcessExitError(code);
}) as typeof process.exit);
vi.spyOn(console, "error").mockImplementation(() => {});
context = createHandlerContext();
proto.registerSignalHandlers.call(context);
return context;
}
test.each([
["read EIO", "EIO"],
["setRawMode EIO", "EIO"],
["setRawMode ENOTTY", "ENOTTY"],
])("stdin %s exits quietly without recording a crash", (message, code) => {
const ctx = setup();
const exitCode = captureExit(() => process.stdin.emit("error", errnoError(message, code)));
expect(exitCode).toBe(129);
expect(ctx.recordCrash).not.toHaveBeenCalled();
expect(ctx.ui.stop).not.toHaveBeenCalled();
});
test("other stdin errors still crash", () => {
setup();
const error = errnoError("read ECONNREFUSED", "ECONNREFUSED");
expect(() => process.stdin.emit("error", error)).toThrow(error);
});
test("uncaught dead terminal errors are not recorded as crashes", () => {
const ctx = setup();
const exitCode = captureExit(() => ctx.uncaughtCrash(errnoError("read EIO", "EIO")));
expect(exitCode).toBe(129);
expect(ctx.recordCrash).not.toHaveBeenCalled();
});
test("other uncaught errors are still recorded as crashes", () => {
const ctx = setup();
const exitCode = captureExit(() => ctx.uncaughtCrash(new Error("boom")));
expect(exitCode).toBe(1);
expect(ctx.recordCrash).toHaveBeenCalledOnce();
});
test("handlers are removed on unregister", () => {
const ctx = setup();
const before = process.stdin.listenerCount("error");
ctx.unregisterSignalHandlers();
expect(process.stdin.listenerCount("error")).toBe(before - 1);
});
});
@@ -78,4 +78,22 @@ describe("regression #5109: exclude tools", () => {
harness.cleanup();
}
});
it("matches allowlist and denylist patterns", async () => {
const harness = await createHarness({
allowedToolNames: ["*_tool", "ask_*", "re*"],
excludedToolNames: ["ask*"],
initialActiveToolNames: ["*_tool", "ask_*", "re*"],
extensionFactories,
});
try {
await harness.session.bindExtensions({});
// Patterns that match a tool activate it like its name.
expect(toolNames(harness.session.getAllTools())).toEqual(["dynamic_tool", "read"]);
expect(harness.session.getActiveToolNames().sort()).toEqual(["dynamic_tool", "read"]);
} finally {
harness.cleanup();
}
});
});
@@ -19,7 +19,13 @@ function createDialog(): LoginDialogComponent {
}
function renderDialog(dialog: LoginDialogComponent): string[] {
return stripAnsi(dialog.render(120).join("\n"))
// stripAnsi keeps APC sequences, so drop the TUI's zero-width cursor markers (e.g. \x1b_pi:fc\x07) first
return stripAnsi(
dialog
.render(120)
.join("\n")
.replace(/\x1b_[^\x07]*\x07/g, ""),
)
.split("\n")
.map((line) => line.trimEnd());
}
@@ -2,47 +2,9 @@ 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: () => {},
};
}
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { createHarness, createTestUiContext } from "../harness.ts";
type LoadedResourcesResult<T> = { [K in keyof T]: T[K] } & { diagnostics: [] };
@@ -70,6 +32,7 @@ type LoadedResourcesContext = {
};
};
getStartupExpansionState: () => boolean;
shouldShowStartupDetails: () => boolean;
formatDisplayPath: (resourcePath: string) => string;
formatContextPath: (resourcePath: string) => string;
getBuiltInCommandConflictDiagnostics: (extensionRunner: LoadedResourcesContext["session"]["extensionRunner"]) => [];
@@ -78,6 +41,7 @@ type LoadedResourcesContext = {
type RebindContext = {
unsubscribe?: () => void;
applyRuntimeSettings: () => void;
programStatus: { reset(): void };
renderCurrentSessionState: () => void;
bindCurrentSessionExtensions: () => Promise<void>;
subscribeToAgent: () => void;
@@ -246,6 +210,7 @@ function createLoadedResourcesContext(): LoadedResourcesContext {
},
},
getStartupExpansionState: () => false,
shouldShowStartupDetails: () => true,
formatDisplayPath: (resourcePath) => resourcePath,
formatContextPath: (resourcePath) => resourcePath.replace("/repo/", ""),
getBuiltInCommandConflictDiagnostics: () => [],
@@ -288,11 +253,12 @@ describe("regression #5943: session_start transient UI", () => {
try {
const context: RebindContext = {
applyRuntimeSettings: () => events.push("apply"),
programStatus: { reset: () => {} },
renderCurrentSessionState: () => events.push("render"),
bindCurrentSessionExtensions: async () => {
events.push("bind");
await harness.session.bindExtensions({
uiContext: createUiContext((message) => events.push(`notify:${message}`)),
uiContext: createTestUiContext({ notify: (message) => events.push(`notify:${message}`) }),
mode: "tui",
});
},
@@ -329,11 +295,12 @@ describe("regression #5943: session_start transient UI", () => {
try {
const context: RebindContext = {
applyRuntimeSettings: () => {},
programStatus: { reset: () => {} },
renderCurrentSessionState: () => events.push("render"),
bindCurrentSessionExtensions: async () => {
events.push("bind");
await harness.session.bindExtensions({
uiContext: createUiContext(() => {}),
uiContext: createTestUiContext(),
mode: "tui",
});
},
@@ -381,11 +348,12 @@ describe("regression #5943: session_start transient UI", () => {
try {
const context: RebindContext = {
applyRuntimeSettings: () => {},
programStatus: { reset: () => {} },
renderCurrentSessionState: () => events.push("render"),
bindCurrentSessionExtensions: async () => {
events.push("bind");
await harness.session.bindExtensions({
uiContext: createUiContext(() => {}),
uiContext: createTestUiContext(),
mode: "tui",
});
},
@@ -433,7 +401,7 @@ describe("regression #5943: session_start transient UI", () => {
try {
await harness.session.bindExtensions({
uiContext: createUiContext((message) => events.push(message)),
uiContext: createTestUiContext({ notify: (message) => events.push(message) }),
mode: "tui",
});
expect(events).toEqual(["start:startup", "notify:startup"]);
@@ -1,44 +1,48 @@
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import {
fauxAssistantMessage,
fauxToolCall,
getCurrentSystemPrompt,
getCurrentTools,
type TranscriptContext,
} from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import type { ExtensionFactory } from "../../../src/index.ts";
import type { ExtensionAPI, 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: {},
};
},
});
function getProviderToolNames(context: TranscriptContext): string[] {
return getCurrentTools(context.messages)
.map((tool) => tool.name)
.sort();
}
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,
});
/** Register `switch_tools`, which swaps the active set to `after_switch` when executed. */
function registerSwitchTools(pi: ExtensionAPI): void {
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: {} }),
});
}
describe("extension active tools next-turn refresh", () => {
// Regression #6162
it("applies pi.setActiveTools before the next provider request in the same run", async () => {
const harness = await createHarness({ extensionFactories: [registerSwitchTools] });
try {
harness.session.setActiveToolsByName(["switch_tools"]);
@@ -46,11 +50,11 @@ describe("extension active tools next-turn refresh", () => {
const providerToolNames: string[][] = [];
harness.setResponses([
(context) => {
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
providerToolNames.push(getProviderToolNames(context));
return fauxAssistantMessage(fauxToolCall("switch_tools", {}), { stopReason: "toolUse" });
},
(context) => {
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
providerToolNames.push(getProviderToolNames(context));
return fauxAssistantMessage("done");
},
]);
@@ -66,57 +70,30 @@ describe("extension active tools next-turn refresh", () => {
}
});
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 });
it("reports the refreshed system prompt during the run", async () => {
const harness = await createHarness({ extensionFactories: [registerSwitchTools] });
try {
harness.session.setActiveToolsByName(["load_more_tools"]);
const addedToolNames: string[][] = [];
harness.session.setActiveToolsByName(["switch_tools"]);
const providerPrompts: string[] = [];
const sessionPrompts: string[] = [];
harness.setResponses([
() => fauxAssistantMessage(fauxToolCall("load_more_tools", {}), { stopReason: "toolUse" }),
(context) => {
addedToolNames.push(
context.messages
.filter((message) => message.role === "toolResult")
.flatMap((message) => message.addedToolNames ?? []),
);
providerPrompts.push(getCurrentSystemPrompt(context.messages));
sessionPrompts.push(harness.session.systemPrompt);
return fauxAssistantMessage(fauxToolCall("switch_tools", {}), { stopReason: "toolUse" });
},
(context) => {
providerPrompts.push(getCurrentSystemPrompt(context.messages));
sessionPrompts.push(harness.session.systemPrompt);
return fauxAssistantMessage("done");
},
]);
await harness.session.prompt("start");
expect(harness.session.getActiveToolNames()).toEqual(["load_more_tools", "after_load"]);
expect(addedToolNames).toEqual([["after_load"]]);
expect(providerPrompts).toHaveLength(2);
expect(providerPrompts[0]).not.toBe(providerPrompts[1]);
expect(sessionPrompts).toEqual(providerPrompts);
} finally {
harness.cleanup();
}
@@ -129,32 +106,7 @@ describe("extension active tools next-turn refresh", () => {
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: {},
}),
});
registerSwitchTools(pi);
},
];
const harness = await createHarness({
@@ -166,15 +118,18 @@ describe("extension active tools next-turn refresh", () => {
const providerSystemPrompts: string[] = [];
const providerToolNames: string[][] = [];
const captureSystemPrompt = (context: TranscriptContext): void => {
providerSystemPrompts.push(getCurrentSystemPrompt(context.messages));
};
harness.setResponses([
(context) => {
providerSystemPrompts.push(context.systemPrompt ?? "");
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
captureSystemPrompt(context);
providerToolNames.push(getProviderToolNames(context));
return fauxAssistantMessage(fauxToolCall("switch_tools", {}), { stopReason: "toolUse" });
},
(context) => {
providerSystemPrompts.push(context.systemPrompt ?? "");
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
captureSystemPrompt(context);
providerToolNames.push(getProviderToolNames(context));
return fauxAssistantMessage("done");
},
]);
@@ -56,7 +56,7 @@ describe("regression #6363: agent settled event and idle waiting", () => {
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(harness.eventsOfType("agent_settled")).toEqual([{ type: "agent_settled", aborted: false }]);
expect(extensionEvents).toEqual(["agent_end", "agent_end", "agent_settled:true"]);
expect(publicEvents).toEqual(["agent_settled"]);
});
@@ -99,7 +99,7 @@ describe("#6647 compaction retries transient summarization failures", () => {
const result = await harness.session.compact();
expect(result.summary).toContain("recovered summary");
expect(getCallCount()).toBe(3); // 1 initial + 2 retries
expect(getCallCount()).toBe(3); // 1 prefix-summary attempt + 2 retries
const starts = harness.eventsOfType("summarization_retry_scheduled");
const ends = harness.eventsOfType("summarization_retry_finished");
expect(starts).toHaveLength(2);
@@ -1,10 +1,19 @@
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 { defaultModelPerProvider } from "../../../src/core/model-resolver.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 complete = Reflect.get(InteractiveMode.prototype, "completeProviderAuthentication") as (
this: object,
providerId: string,
providerName: string,
authType: "oauth" | "api_key",
previousModel: Model<Api>,
) => Promise<void>;
const dynamicModel: Model<"openai-completions"> = {
id: "dynamic",
name: "Dynamic",
@@ -99,17 +108,8 @@ describe("issues #7027 and #7113 credential refresh hang", () => {
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],
@@ -123,3 +123,95 @@ describe("issues #7027 and #7113 credential refresh hang", () => {
);
});
});
describe("post-login model discovery", () => {
let harness: Harness | undefined;
afterEach(() => {
vi.useRealTimers();
harness?.cleanup();
vi.restoreAllMocks();
});
async function startLogin() {
harness = await createHarness();
vi.useFakeTimers();
const session = harness.session;
const model = harness.getModel();
const unknownModel = { ...model, id: "unknown", provider: "unknown", api: "unknown" };
const currentModel = vi.spyOn(session, "model", "get").mockReturnValue(unknownModel);
const availableModels = vi.spyOn(session.modelRuntime, "getAvailableSnapshot").mockReturnValue([]);
let finishRefresh = () => {};
vi.spyOn(session.modelRuntime, "refresh").mockImplementation(
(options) =>
new Promise((resolve) => {
finishRefresh = () => resolve({ aborted: false, errors: new Map() });
options?.signal?.addEventListener("abort", () => resolve({ aborted: true, errors: new Map() }), {
once: true,
});
}),
);
const setModel = vi.spyOn(session, "setModel").mockResolvedValue();
const context = {
session,
updateAvailableProviderCount: vi.fn(),
footer: { invalidate: vi.fn() },
updateEditorBorderColor: vi.fn(),
showStatus: vi.fn(),
showError: vi.fn(),
showWarning: vi.fn(),
maybeWarnAboutAnthropicSubscriptionAuth: vi.fn(),
ui: { requestRender: vi.fn() },
};
await complete.call(context, "radius", "Radius", "oauth", unknownModel);
expect(context.showStatus).toHaveBeenCalledWith(expect.stringContaining("Credentials saved"));
expect(context.showError).not.toHaveBeenCalled();
expect(setModel).not.toHaveBeenCalled();
return {
...context,
setModel,
currentModel,
async discover(ids: string[]) {
availableModels.mockReturnValue(ids.map((id) => ({ ...model, provider: "radius", id })));
finishRefresh();
await vi.advanceTimersByTimeAsync(0);
},
};
}
it.each([
{ models: ["fast", "balanced"], selected: "balanced" },
{ models: ["fast", "powerful"], selected: "fast" },
])("selects $selected from the refreshed catalog $models", async ({ models, selected }) => {
expect(defaultModelPerProvider.radius).toBe("balanced");
const login = await startLogin();
await login.discover(models);
expect(login.setModel).toHaveBeenCalledWith(expect.objectContaining({ provider: "radius", id: selected }), {
persist: true,
});
expect(login.showError).not.toHaveBeenCalled();
});
it("reports an empty catalog only after refresh", async () => {
const login = await startLogin();
await login.discover([]);
expect(login.setModel).not.toHaveBeenCalled();
expect(login.showError).toHaveBeenCalledWith(expect.stringContaining("no models are available"));
});
it("preserves a model selected during refresh", async () => {
const login = await startLogin();
login.currentModel.mockReturnValue(harness!.getModel());
await login.discover(["fast", "balanced"]);
expect(login.setModel).not.toHaveBeenCalled();
expect(login.showError).not.toHaveBeenCalled();
});
it("bounds refresh to 15 seconds", async () => {
const login = await startLogin();
await vi.advanceTimersByTimeAsync(15_000);
expect(login.showWarning).toHaveBeenCalledWith(expect.stringContaining("timed out"));
expect(login.showError).toHaveBeenCalledWith(expect.stringContaining("no models are available"));
expect(login.setModel).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,6 @@
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import type { PromptDisposition } from "../../../src/core/agent-session.ts";
import { createHarness, getMessageText, getUserTexts, type Harness } from "../harness.ts";
describe("issue #7150: RPC prompt during manual compaction", () => {
@@ -57,13 +58,13 @@ describe("issue #7150: RPC prompt during manual compaction", () => {
const compactPromise = harness.session.compact();
await compactionStarted;
let preflightResult: boolean | undefined;
let preflightResult: PromptDisposition | undefined;
let promptError: unknown;
try {
await harness.session.prompt("PROBE-7150", {
source: "rpc",
preflightResult: (success) => {
preflightResult = success;
preflightResult: (result) => {
preflightResult = result;
},
});
} catch (error) {
@@ -79,7 +80,7 @@ describe("issue #7150: RPC prompt during manual compaction", () => {
entry.type === "message" && entry.message.role === "user" ? [getMessageText(entry.message)] : [],
);
expect(preflightResult).toBe(false);
expect(preflightResult).toBeUndefined();
expect(promptError).toEqual(
expect.objectContaining({ message: expect.stringContaining("compaction is in progress") }),
);
@@ -9,13 +9,28 @@ const state = vi.hoisted(() => {
});
return {
originalGetBuiltinModule,
jitiModuleLoads: 0,
virtualModulesLoads: 0,
createJiti: vi.fn((_id: unknown, _options: unknown) => ({
import: vi.fn(async () => () => {}),
})),
};
});
vi.mock("jiti/static", () => ({ createJiti: state.createJiti }));
vi.mock("jiti/static", () => {
state.jitiModuleLoads++;
return { createJiti: state.createJiti };
});
vi.mock("../../../src/core/extensions/virtual-modules.ts", () => {
state.virtualModulesLoads++;
return {
VIRTUAL_MODULES: {
typebox: {},
"@earendil-works/pi-coding-agent": {},
},
};
});
import { loadExtensions } from "../../../src/core/extensions/loader.ts";
@@ -32,10 +47,16 @@ afterAll(() => {
});
describe("Node SEA extension loading", () => {
it("uses bundled virtual modules instead of filesystem aliases", async () => {
// Regression test for #8237 and #9540.
it("loads jiti and bundled virtual modules only when importing an extension", async () => {
expect(state.jitiModuleLoads).toBe(0);
expect(state.virtualModulesLoads).toBe(0);
const result = await loadExtensions(["/extension.ts"], "/");
expect(result.errors).toEqual([]);
expect(state.jitiModuleLoads).toBe(1);
expect(state.virtualModulesLoads).toBe(1);
expect(result.extensions).toHaveLength(1);
expect(state.createJiti).toHaveBeenCalledOnce();
@@ -48,7 +48,14 @@ describe("#8537 custom messages injected during tool execution", () => {
await harness.session.prompt("hi");
expect(roles(harness.session.messages)).toEqual(["user", "assistant", "toolResult", "custom", "assistant"]);
expect(roles(harness.session.messages)).toEqual([
"system",
"user",
"assistant",
"toolResult",
"custom",
"assistant",
]);
});
it("keeps session entries and message events in the same order as agent state", async () => {
@@ -84,13 +91,13 @@ describe("#8537 custom messages injected during tool execution", () => {
.flatMap((entry) =>
entry.type === "message" ? [entry.message.role] : entry.type === "custom_message" ? ["custom"] : [],
);
expect(entryKinds).toEqual(["user", "assistant", "toolResult", "custom", "assistant"]);
expect(entryKinds).toEqual(["system", "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"]);
expect(messageStarts).toEqual(["system", "user", "assistant", "toolResult", "custom", "assistant"]);
});
it("produces an llm history where every tool result follows its tool call", async () => {
@@ -83,8 +83,13 @@ describe("regression #8724: in-memory fork during an active tool turn", () => {
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([]);
expect(runtime.session.messages.map((message) => message.role)).toEqual(["system"]);
expect(
runtime.session.sessionManager
.getEntries()
.filter((entry) => entry.type === "message")
.map((entry) => entry.message.role),
).toEqual(["system"]);
let capturedRoles: string[] = [];
harness.setResponses([
@@ -95,6 +100,6 @@ describe("regression #8724: in-memory fork during an active tool turn", () => {
]);
await runtime.session.prompt("next prompt");
expect(capturedRoles).toEqual(["user"]);
expect(capturedRoles).toEqual(["system", "system", "user"]);
});
});
@@ -0,0 +1,63 @@
import { type AssistantMessage, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
import { getApiProvider } from "@earendil-works/pi-ai/compat";
import { expect, it } from "vitest";
import { createHarness } from "../harness.ts";
// Regression for #8964: extensions can stream responses from providers registered with pi.registerProvider().
it.each(["stream", "streamSimple"] as const)(
"allows an extension command to use ctx.modelRegistry.%s",
async (method) => {
const faux = fauxProvider({ provider: "extension-provider", api: "issue-8964-extension-api" });
let receivedApiKey: string | undefined;
faux.setResponses([
(_context, options) => {
receivedApiKey = options?.apiKey;
return fauxAssistantMessage("custom provider response");
},
]);
let streamedText = "";
let result: AssistantMessage | undefined;
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.registerProvider(faux.provider.id, {
api: faux.api,
baseUrl: faux.getModel().baseUrl,
apiKey: "extension-key",
models: faux.models,
streamSimple: faux.provider.streamSimple,
});
},
(pi) => {
pi.registerCommand("stream-custom", {
description: "Stream a response from the custom provider",
handler: async (_args, ctx) => {
const model = ctx.modelRegistry.find(faux.provider.id, faux.getModel().id)!;
const stream = ctx.modelRegistry[method](model, {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
});
for await (const event of stream) {
if (event.type === "text_delta") streamedText += event.delta;
}
result = await stream.result();
},
});
},
],
});
try {
expect(getApiProvider(faux.api)).toBeUndefined();
await harness.session.prompt("/stream-custom");
expect(receivedApiKey).toBe("extension-key");
expect(streamedText).toBe("custom provider response");
expect(result).toMatchObject({
stopReason: "stop",
content: [{ type: "text", text: "custom provider response" }],
});
expect(getApiProvider(faux.api)).toBeUndefined();
} finally {
harness.cleanup();
}
},
);
@@ -0,0 +1,272 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentSessionRuntime } from "../../../src/core/agent-session-runtime.ts";
import type { ExtensionAPI, UserBashEvent, UserBashEventResult } from "../../../src/core/extensions/types.ts";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.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/9068
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/interactive/components/bash-execution.js", () => ({
BashExecutionComponent: class {
appendOutput(): void {}
setComplete(): void {}
},
}));
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;
}
async function startRpcHarness(extension: (pi: ExtensionAPI) => void): Promise<{
harness: Harness;
send(command: Record<string, unknown>): void;
cleanup(): void;
}> {
const listenerSnapshot = takeListenerSnapshot();
const harness = await createHarness({ extensionFactories: [extension] });
const cleanup = () => {
harness.cleanup();
restoreListeners(listenerSnapshot);
};
try {
void runRpcMode(createRuntimeHost(harness));
await vi.waitFor(() => expect(rpcIo.lineHandler).toBeDefined());
} catch (error) {
cleanup();
throw error;
}
return {
harness,
send(command) {
if (!rpcIo.lineHandler) throw new Error("RPC line handler is not attached");
rpcIo.lineHandler(JSON.stringify(command));
},
cleanup,
};
}
type InteractiveBashContext = {
defaultEditor: { onSubmit?: (text: string) => Promise<void> | void };
editor: { addToHistory?: (text: string) => void };
session: Harness["session"];
sessionManager: Harness["sessionManager"];
ui: { requestRender(): void };
chatContainer: { addChild(component: unknown): void };
pendingMessagesContainer: { addChild(component: unknown): void };
pendingBashComponents: unknown[];
isBashMode: boolean;
handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
showError(message: string): void;
updateEditorBorderColor(): void;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown as {
setupEditorSubmitHandler(this: InteractiveBashContext): void;
handleBashCommand(this: InteractiveBashContext, command: string, excludeFromContext?: boolean): Promise<void>;
};
const localResult = {
output: "local output",
exitCode: 0,
cancelled: false,
truncated: false,
};
const rpcCases: Array<{
name: string;
extension: (pi: ExtensionAPI) => void;
error?: string;
executeCount: number;
}> = [
{
name: "fails the request without executing bash when a handler throws",
extension: (pi) => {
pi.on("user_bash", async () => {
throw new Error("Routing failed");
});
},
error: "Routing failed",
executeCount: 0,
},
{
name: "fails the request without executing bash when a handler returns an empty result",
extension: (pi) => {
pi.on("user_bash", async () => ({}) as unknown as UserBashEventResult);
},
error: "Invalid user_bash handler result",
executeCount: 0,
},
{
name: "executes bash normally when a handler returns undefined",
extension: (pi) => {
pi.on("user_bash", async () => undefined);
},
executeCount: 1,
},
];
afterEach(() => {
rpcIo.outputLines = [];
rpcIo.lineHandler = undefined;
});
describe("RPC user_bash failure handling (#9068)", () => {
test.each(rpcCases)("$name", async ({ extension, error, executeCount }) => {
const rpc = await startRpcHarness(extension);
const executeBash = vi.spyOn(rpc.harness.session, "executeBash").mockResolvedValue(localResult);
try {
rpc.send({ id: "bash-request", type: "bash", command: "pwd" });
await vi.waitFor(() => {
const response = parseOutputLines().find(
(output) => output.type === "response" && output.id === "bash-request",
);
expect(response).toMatchObject({
id: "bash-request",
type: "response",
command: "bash",
success: error === undefined,
...(error ? { error: expect.stringContaining(error) } : { data: localResult }),
});
});
if (error) {
expect(parseOutputLines()).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "extension_error",
event: "user_bash",
error: expect.stringContaining(error),
}),
]),
);
}
expect(executeBash).toHaveBeenCalledTimes(executeCount);
} finally {
executeBash.mockRestore();
rpc.cleanup();
}
});
});
describe("Interactive user_bash failure handling (#9068)", () => {
test.each([
["!pwd", false],
["!!pwd", true],
])("fails closed for %s when a handler returns an empty result", async (input, excludeFromContext) => {
const events: UserBashEvent[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("user_bash", async (event) => {
events.push(event);
return {} as unknown as UserBashEventResult;
});
},
],
});
const executeBash = vi.spyOn(harness.session, "executeBash").mockResolvedValue(localResult);
const context: InteractiveBashContext = {
defaultEditor: {},
editor: { addToHistory: vi.fn() },
session: harness.session,
sessionManager: harness.sessionManager,
ui: { requestRender: vi.fn() },
chatContainer: { addChild: vi.fn() },
pendingMessagesContainer: { addChild: vi.fn() },
pendingBashComponents: [],
isBashMode: true,
handleBashCommand: interactiveModePrototype.handleBashCommand,
showError: vi.fn(),
updateEditorBorderColor: vi.fn(),
};
interactiveModePrototype.setupEditorSubmitHandler.call(context);
try {
await context.defaultEditor.onSubmit?.(input);
expect(events).toEqual([
{
type: "user_bash",
command: "pwd",
excludeFromContext,
cwd: harness.sessionManager.getCwd(),
},
]);
expect(executeBash).not.toHaveBeenCalled();
} finally {
executeBash.mockRestore();
harness.cleanup();
}
});
});
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it } from "vitest";
import { assistantMsg, userMsg } from "../../utilities.ts";
import { createHarness, getMessageText, type Harness } from "../harness.ts";
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
describe("issue #9178: tree navigation during manual compaction", () => {
let harness: Harness | undefined;
afterEach(() => harness?.cleanup());
it("rejects navigation before the active leaf can change", async () => {
const compactionStarted = createDeferred();
const compactionReleased = createDeferred();
harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => {
compactionStarted.resolve();
await compactionReleased.promise;
return {
compaction: {
summary: "summary",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
};
});
},
],
});
harness.sessionManager.appendMessage(userMsg("first user"));
const navigationTargetId = harness.sessionManager.appendMessage(assistantMsg("first assistant"));
harness.sessionManager.appendMessage(userMsg("second user"));
const originalLeafId = harness.sessionManager.appendMessage(assistantMsg("second assistant"));
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
const compactionPromise = harness.session.compact();
await compactionStarted.promise;
expect(harness.session.isCompacting).toBe(true);
try {
await expect(harness.session.navigateTree(navigationTargetId, { summarize: false })).rejects.toThrow(
"Wait for the current compaction or tree navigation to finish before navigating the session tree.",
);
expect(harness.sessionManager.getLeafId()).toBe(originalLeafId);
} finally {
compactionReleased.resolve();
}
await compactionPromise;
expect(harness.sessionManager.getEntries().at(-1)).toMatchObject({
type: "compaction",
parentId: originalLeafId,
});
expect(harness.session.messages.map(getMessageText)).toContain("second assistant");
});
it("rejects a second navigation while the first is waiting", async () => {
const navigationStarted = createDeferred();
const navigationReleased = createDeferred();
harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_before_tree", async () => {
navigationStarted.resolve();
await navigationReleased.promise;
});
},
],
});
const secondTargetId = harness.sessionManager.appendMessage(userMsg("first user"));
const firstTargetId = harness.sessionManager.appendMessage(assistantMsg("first assistant"));
harness.sessionManager.appendMessage(userMsg("second user"));
const originalLeafId = harness.sessionManager.appendMessage(assistantMsg("second assistant"));
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
const firstNavigation = harness.session.navigateTree(firstTargetId, { summarize: false });
await navigationStarted.promise;
const secondNavigation = harness.session.navigateTree(secondTargetId, { summarize: false });
expect(harness.sessionManager.getLeafId()).toBe(originalLeafId);
navigationReleased.resolve();
await expect(secondNavigation).rejects.toThrow(
"Wait for the current compaction or tree navigation to finish before navigating the session tree.",
);
await firstNavigation;
expect(harness.sessionManager.getLeafId()).toBe(firstTargetId);
});
});
@@ -0,0 +1,158 @@
import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createHarness, type Harness } from "../harness.ts";
type SessionWithCompactionInternals = {
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<boolean>;
};
function seedCompactableSession(harness: Harness): void {
const model = harness.getModel();
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "x".repeat(500) }],
timestamp: 1,
});
const assistant: AssistantMessage = {
...fauxAssistantMessage("y".repeat(200), { timestamp: 2 }),
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;
}
function runAutoCompaction(harness: Harness): Promise<boolean> {
return (harness.session as unknown as SessionWithCompactionInternals)._runAutoCompaction("threshold", false);
}
describe("automatic compaction cancellation regressions", () => {
const harnesses: Harness[] = [];
afterEach(() => {
vi.restoreAllMocks();
while (harnesses.length > 0) harnesses.pop()?.cleanup();
});
// Regression test for #9340.
it("does not start post-run auto-compaction after abort", async () => {
const harness = await createHarness({
models: [{ id: "faux-1", contextWindow: 200, maxTokens: 50 }],
settings: {
compaction: { enabled: true, reserveTokens: 50, keepRecentTokens: 1 },
retry: { enabled: false },
},
extensionFactories: [
(pi) => {
pi.on("session_before_compact", () => ({ cancel: true }));
},
],
});
harnesses.push(harness);
seedCompactableSession(harness);
harness.setResponses([
fauxAssistantMessage("", { stopReason: "error", errorMessage: "Synthetic network failure" }),
]);
harness.session.subscribe((event) => {
if (event.type === "message_end" && event.message.role === "assistant") {
harness.session.abortCompaction();
void harness.session.abort();
}
});
await harness.session.prompt("z".repeat(1000));
expect(harness.eventsOfType("compaction_start")).toHaveLength(0);
});
// Regression test for #9777.
it("cancels summarization authentication", async () => {
const harness = await createHarness({ settings: { compaction: { keepRecentTokens: 1 } } });
harnesses.push(harness);
seedCompactableSession(harness);
let markAuthStarted = () => {};
const authStarted = new Promise<void>((resolve) => {
markAuthStarted = resolve;
});
let authSignal: AbortSignal | undefined;
vi.spyOn(harness.session.modelRuntime, "getAuth").mockImplementation(async (_model, options) => {
authSignal = options?.signal;
markAuthStarted();
if (!authSignal) throw new Error("Missing auth abort signal");
return await new Promise<never>((_resolve, reject) => {
authSignal?.addEventListener("abort", () => reject(authSignal?.reason), { once: true });
});
});
const compaction = runAutoCompaction(harness);
await authStarted;
const started = harness.eventsOfType("compaction_start").length;
const wasCompacting = harness.session.isCompacting;
await Promise.all([compaction, harness.session.abort()]);
expect({ started, wasCompacting, authAborted: authSignal?.aborted }).toEqual({
started: 1,
wasCompacting: true,
authAborted: true,
});
expect(harness.eventsOfType("compaction_end").at(-1)?.aborted).toBe(true);
});
// Regression test for #9777.
it("cancels synchronously from compaction_start", async () => {
const harness = await createHarness({ settings: { compaction: { keepRecentTokens: 1 } } });
harnesses.push(harness);
seedCompactableSession(harness);
harness.session.subscribe((event) => {
if (event.type === "compaction_start") harness.session.abortCompaction();
});
await runAutoCompaction(harness);
expect(harness.faux.state.callCount).toBe(0);
expect(harness.eventsOfType("compaction_end").at(-1)?.aborted).toBe(true);
});
// Regression test for #9777.
it.each([
["matching error text", () => new Error("Compaction cancelled")],
["an unrelated AbortError", () => Object.assign(new Error("auth failed"), { name: "AbortError" })],
] as const)("reports %s as a failure", async (_label, createError) => {
const harness = await createHarness({ settings: { compaction: { keepRecentTokens: 1 } } });
harnesses.push(harness);
seedCompactableSession(harness);
vi.spyOn(harness.session.modelRuntime, "getAuth").mockRejectedValue(createError());
await runAutoCompaction(harness);
const event = harness.eventsOfType("compaction_end").at(-1);
expect(event?.aborted).toBe(false);
expect(event?.errorMessage).toContain(createError().message);
});
it("reports extension cancellation as aborted", async () => {
const harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", () => ({ cancel: true }));
},
],
});
harnesses.push(harness);
seedCompactableSession(harness);
await runAutoCompaction(harness);
expect(harness.eventsOfType("compaction_end").at(-1)?.aborted).toBe(true);
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
jitiModuleLoads: 0,
jitiStaticModuleLoads: 0,
virtualModulesLoads: 0,
createJiti: vi.fn((_id: unknown, _options: unknown) => ({
import: vi.fn(async () => () => {}),
})),
}));
vi.mock("jiti", () => {
state.jitiModuleLoads++;
return { createJiti: state.createJiti };
});
vi.mock("jiti/static", () => {
state.jitiStaticModuleLoads++;
return { createJiti: state.createJiti };
});
vi.mock("../../../src/core/extensions/virtual-modules.ts", () => {
state.virtualModulesLoads++;
return { VIRTUAL_MODULES: {} };
});
import { loadExtensions } from "../../../src/core/extensions/loader.ts";
interface JitiOptionsProbe {
alias?: unknown;
tryNative?: boolean;
tsconfigPaths?: boolean;
virtualModules?: Record<string, unknown>;
}
describe("extension loader lazy imports", () => {
// Regression test for #9540.
it("defers ordinary jiti and its virtual modules until importing an extension", async () => {
expect(state.jitiModuleLoads).toBe(0);
expect(state.jitiStaticModuleLoads).toBe(0);
expect(state.virtualModulesLoads).toBe(0);
const result = await loadExtensions(["/extension.ts"], "/");
expect(result.errors).toEqual([]);
expect(result.extensions).toHaveLength(1);
expect(state.jitiModuleLoads).toBe(1);
expect(state.jitiStaticModuleLoads).toBe(0);
expect(state.virtualModulesLoads).toBe(1);
expect(state.createJiti).toHaveBeenCalledOnce();
const options = state.createJiti.mock.calls[0][1] as JitiOptionsProbe;
expect(options.tryNative).toBeUndefined();
expect(options.tsconfigPaths).toBe(true);
expect(options.alias).toBeUndefined();
expect(options.virtualModules).toBeDefined();
});
});
@@ -0,0 +1,226 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import {
fauxAssistantMessage,
getCurrentSystemPrompt,
getCurrentTools,
type TranscriptContext,
} 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";
/** Compaction supplied by an extension hook, as in the reported sessions. */
const compactViaHook: ExtensionFactory = (pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "extension summary",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: { source: "test" },
},
}));
};
function captureRequest(harness: Harness, text: string): () => TranscriptContext {
let request: TranscriptContext | undefined;
harness.setResponses([
(context) => {
request = context;
return fauxAssistantMessage(text);
},
]);
return () => {
if (!request) throw new Error("expected a provider request");
return request;
};
}
function toolNames(context: TranscriptContext): string[] {
return getCurrentTools(context.messages).map((tool) => tool.name);
}
async function compactSession(harness: Harness): Promise<void> {
harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]);
await harness.session.prompt("first");
await harness.session.prompt("second");
await harness.session.compact();
expect(harness.session.messages.map((message) => message.role).slice(0, 2)).toEqual(["system", "compactionSummary"]);
}
describe("context handlers and system messages", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) harnesses.pop()?.cleanup();
});
// Regression #9789, #9822: pruning from the compaction summary dropped the prompt and tool checkpoint.
it("keeps the prompt and tools when a handler slices from the compaction summary", async () => {
const seen: AgentMessage[][] = [];
const harness = await createHarness({
extensionFactories: [
compactViaHook,
(pi) => {
pi.on("context", async (event) => {
seen.push(event.messages);
const summary = event.messages.findIndex((message) => message.role === "compactionSummary");
return { messages: event.messages.slice(summary) };
});
},
],
});
harnesses.push(harness);
await compactSession(harness);
const getRequest = captureRequest(harness, "after compaction");
await harness.session.prompt("third");
const request = getRequest();
expect(seen.at(-1)?.some((message) => message.role === "system")).toBe(false);
expect(request.messages[0]?.role).toBe("system");
expect(toolNames(request)).toEqual(harness.session.getActiveToolNames());
expect(getCurrentSystemPrompt(request.messages)).toBe(harness.session.systemPrompt);
expect(request.messages.filter((message) => message.role === "system")).toHaveLength(1);
});
it("keeps mid-conversation system messages in place when a handler leaves the conversation unchanged", async () => {
let turn = 0;
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("before_agent_start", (event) => {
if (++turn === 2) event.systemPromptOptions.sections.plan_mode = "Plan only.";
});
pi.on("context", async (event) => ({ messages: event.messages }));
},
],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("one")]);
await harness.session.prompt("first");
const getRequest = captureRequest(harness, "two");
await harness.session.prompt("second");
const systemMessages = getRequest().messages.filter((message) => message.role === "system");
expect(systemMessages).toHaveLength(2);
expect(systemMessages[1]?.sections).toEqual({ plan_mode: "<plan_mode>\nPlan only.\n</plan_mode>" });
});
it("applies in-place edits to event.messages without a return value", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("context", async (event) => {
event.messages.splice(0, 0, {
role: "user",
content: [{ type: "text", text: "injected" }],
timestamp: 0,
});
});
},
],
});
harnesses.push(harness);
const getRequest = captureRequest(harness, "done");
await harness.session.prompt("hello");
const request = getRequest();
expect(request.messages.map((message) => message.role)).toEqual(["system", "user", "user"]);
expect(toolNames(request)).toEqual(harness.session.getActiveToolNames());
});
it("keeps system messages a handler adds after the replayed head", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("context", async (event) => ({
messages: [{ role: "system", content: "ephemeral reminder", timestamp: 0 }, ...event.messages],
}));
},
],
});
harnesses.push(harness);
const getRequest = captureRequest(harness, "done");
await harness.session.prompt("hello");
const request = getRequest();
expect(request.messages.map((message) => message.role)).toEqual(["system", "system", "user"]);
expect(toolNames(request)).toEqual(harness.session.getActiveToolNames());
expect(getCurrentSystemPrompt(request.messages)).toContain(harness.session.systemPrompt);
expect(getCurrentSystemPrompt(request.messages)).toContain("ephemeral reminder");
});
});
describe("context_with_system handlers", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) harnesses.pop()?.cleanup();
});
it("runs after context handlers on the restored transcript and sends its output verbatim", async () => {
const seen: AgentMessage[][] = [];
const harness = await createHarness({
extensionFactories: [
compactViaHook,
(pi) => {
pi.on("context_with_system", async (event) => {
seen.push(event.messages);
return {
messages: event.messages.map((message) =>
message.role === "system" && message.toolsAdded
? { ...message, toolsAdded: message.toolsAdded.filter((tool) => tool.name !== "bash") }
: message,
),
};
});
// Registered after, but runs first: context handlers precede context_with_system.
pi.on("context", async (event) => {
const summary = event.messages.findIndex((message) => message.role === "compactionSummary");
return { messages: event.messages.slice(summary) };
});
},
],
});
harnesses.push(harness);
await compactSession(harness);
const getRequest = captureRequest(harness, "after compaction");
await harness.session.prompt("third");
const input = seen.at(-1);
expect(input?.[0]?.role).toBe("system");
expect(input?.[1]?.role).toBe("compactionSummary");
expect(harness.session.getActiveToolNames()).toContain("bash");
expect(toolNames(getRequest())).toEqual(harness.session.getActiveToolNames().filter((name) => name !== "bash"));
});
it("reports a handler that drops the leading system message but honors its output", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("context_with_system", async (event) => ({
messages: event.messages.filter((message) => message.role !== "system"),
}));
},
],
});
harnesses.push(harness);
const errors: string[] = [];
harness.session.extensionRunner.onError((error) => {
errors.push(`${error.event}: ${error.error}`);
});
const getRequest = captureRequest(harness, "done");
await harness.session.prompt("hello");
expect(getRequest().messages.map((message) => message.role)).toEqual(["user"]);
expect(errors).toEqual([
expect.stringMatching(/^context_with_system: Handler removed the leading system message/),
]);
});
});
@@ -5,6 +5,7 @@ type RebindContext = {
session: object;
unsubscribe?: () => void;
applyRuntimeSettings: () => void;
programStatus: { reset(): void };
renderCurrentSessionState: () => void;
bindCurrentSessionExtensions: () => Promise<void>;
subscribeToAgent: () => void;
@@ -40,6 +41,7 @@ describe("overlapping startup and replacement session rebinds", () => {
const context: RebindContext = {
session: startupSession,
applyRuntimeSettings: () => {},
programStatus: { reset: () => {} },
renderCurrentSessionState: () => {},
bindCurrentSessionExtensions: () => {
bindCount += 1;