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:
1 parent
719370c8f7
commit
6081e3e325
1743 files changed
+205459
-131704
No files matched your search
Vendored
+72
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -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"]]);
|
||||
});
|
||||
});
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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();
|
||||
});
|
||||
|
||||
Vendored
+15
-16
@@ -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"]);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -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`
|
||||
|
||||
+2
-2
@@ -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();
|
||||
});
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+135
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
+18
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -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());
|
||||
}
|
||||
|
||||
Vendored
+12
-44
@@ -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"]);
|
||||
|
||||
+65
-110
@@ -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");
|
||||
},
|
||||
]);
|
||||
|
||||
Vendored
+1
-1
@@ -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"]);
|
||||
});
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
Vendored
+101
-9
@@ -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();
|
||||
});
|
||||
});
|
||||
+5
-4
@@ -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") }),
|
||||
);
|
||||
|
||||
Vendored
+23
-2
@@ -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();
|
||||
|
||||
|
||||
+10
-3
@@ -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 () => {
|
||||
|
||||
Vendored
+8
-3
@@ -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"]);
|
||||
});
|
||||
});
|
||||
+63
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
Vendored
+272
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Vendored
+102
@@ -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);
|
||||
});
|
||||
});
|
||||
+158
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+58
@@ -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();
|
||||
});
|
||||
});
|
||||
+226
@@ -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/),
|
||||
]);
|
||||
});
|
||||
});
|
||||
+2
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user