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:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -8,11 +8,18 @@ import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { createExtensionRuntime, discoverAndLoadExtensions, loadExtensions } from "../src/core/extensions/loader.ts";
import { createEventBus } from "../src/core/event-bus.ts";
import {
createExtensionRuntime,
discoverAndLoadExtensions,
loadExtensionFromFactory,
loadExtensions,
} from "../src/core/extensions/loader.ts";
import { ExtensionRunner, emitProjectTrustEvent } from "../src/core/extensions/runner.ts";
import type {
ExtensionActions,
ExtensionContextActions,
ExtensionFactory,
ExtensionUIContext,
ProviderConfig,
} from "../src/core/extensions/types.ts";
@@ -20,6 +27,7 @@ import { KeybindingsManager, type KeyId } from "../src/core/keybindings.ts";
import type { ModelRegistry } from "../src/core/model-registry.ts";
import type { ScopedModel } from "../src/core/model-resolver.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { buildSystemPrompt } from "../src/core/system-prompt.ts";
describe("ExtensionRunner", () => {
let tempDir: string;
@@ -80,6 +88,7 @@ describe("ExtensionRunner", () => {
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
getSettings: () => ({}),
setActiveTools: () => {},
refreshTools: () => {},
getCommands: () => [],
@@ -390,6 +399,32 @@ describe("ExtensionRunner", () => {
expect(tools.map((t) => t.definition.name).sort()).toEqual(["tool_a", "tool_b"]);
});
// Regression test for #9300.
it("rejects extension tools without a parameter schema", async () => {
const extensionPath = path.join(extensionsDir, "missing-parameters.js");
fs.writeFileSync(
extensionPath,
`export default function(pi) {
pi.registerTool({
name: "noop",
label: "No-op",
description: "Do nothing",
execute: async () => ({ content: [{ type: "text", text: "ok" }] }),
});
}`,
);
const result = await loadExtensions([extensionPath], tempDir);
expect(result.extensions).toHaveLength(0);
expect(result.errors).toEqual([
{
path: extensionPath,
error: `Failed to load extension: Tool "noop" registered by extension "${extensionPath}" must define an object parameter schema.`,
},
]);
});
it("keeps first tool when two extensions register the same name", async () => {
const first = `
import { Type } from "typebox";
@@ -590,6 +625,84 @@ describe("ExtensionRunner", () => {
expect(errors[0].error).toContain("Handler error!");
expect(errors[0].event).toBe("context");
});
// Regression test for #9068.
it("fails closed when a user_bash handler throws", async () => {
const extCode = `
export default function(pi) {
pi.on("user_bash", async () => {
throw new Error("Routing failed");
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
const errors: Array<{ event: string; error: string }> = [];
runner.onError((error) => errors.push(error));
await expect(
runner.emitUserBash({ type: "user_bash", command: "pwd", excludeFromContext: false, cwd: tempDir }),
).rejects.toThrow("Routing failed");
expect(errors).toMatchObject([{ event: "user_bash", error: "Routing failed" }]);
});
// Regression test for #9068.
it.each([
["an empty object", "{}"],
["null operations", "{ operations: null }"],
["operations without exec", "{ operations: {} }"],
["a null result", "{ result: null }"],
["an incomplete result", '{ result: { output: "handled" } }'],
[
"operations and a result",
'{ operations: { exec: async () => ({ exitCode: 0 }) }, result: { output: "handled", exitCode: 0, cancelled: false, truncated: false } }',
],
])("fails closed when a user_bash handler returns %s", async (_description, handlerResult) => {
const extCode = `
export default function(pi) {
pi.on("user_bash", async () => (${handlerResult}));
}
`;
fs.writeFileSync(path.join(extensionsDir, "invalid-result.ts"), extCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
const errors: Array<{ event: string; error: string }> = [];
runner.onError((error) => errors.push(error));
await expect(
runner.emitUserBash({ type: "user_bash", command: "pwd", excludeFromContext: false, cwd: tempDir }),
).rejects.toThrow("Invalid user_bash handler result");
expect(errors).toMatchObject([
{ event: "user_bash", error: expect.stringContaining("Invalid user_bash handler result") },
]);
});
it("accepts valid user_bash operations and result overrides", async () => {
const extCode = `
export default function(pi) {
pi.on("user_bash", async (event) => {
if (event.command === "operations") {
return { operations: { exec: async () => ({ exitCode: 0 }) } };
}
return { result: { output: "handled", exitCode: 0, cancelled: false, truncated: false } };
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "valid-results.ts"), extCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
const event = { type: "user_bash" as const, excludeFromContext: false, cwd: tempDir };
const operations = await runner.emitUserBash({ ...event, command: "operations" });
expect(operations).toEqual({ operations: { exec: expect.any(Function) } });
await expect(runner.emitUserBash({ ...event, command: "result" })).resolves.toEqual({
result: { output: "handled", exitCode: 0, cancelled: false, truncated: false },
});
});
});
describe("message and entry renderers", () => {
@@ -764,16 +877,184 @@ describe("ExtensionRunner", () => {
runner.onError((error) => errors.push(error.error));
runner.bindCore(extensionActions, extensionContextActions);
const chained = await runner.emitBeforeAgentStart("hello", undefined, "base", {
cwd: tempDir,
});
const chained = await runner.emitBeforeAgentStart(
{ messageType: "user", prompt: "hello" },
{
cwd: tempDir,
customPrompt: "base",
},
);
expect(errors).toEqual([]);
expect(chained.messages).toEqual([]);
expect(buildSystemPrompt(chained.systemPromptOptions)).toMatch(/base[\s\S]*\nfirst\nsecond$/);
});
});
expect(chained).toEqual({
messages: undefined,
systemPrompt: "base\nfirst\nsecond",
// Issue #10285: the MCP extension renders calls to tools that are not registered.
it("resolves tool renderers in extension load order, each able to defer to the next", async () => {
const runtime = createExtensionRuntime();
const eventBus = createEventBus();
const renderCall = () => ({ render: () => [], invalidate: () => {} });
const first = await loadExtensionFromFactory(
(pi) => pi.registerToolRenderer((toolName, next) => (toolName === "a" ? { renderCall } : next())),
tempDir,
eventBus,
runtime,
);
const second = await loadExtensionFromFactory(
(pi) => pi.registerToolRenderer((_toolName, next) => next() ?? { renderShell: "self" }),
tempDir,
eventBus,
runtime,
);
const runner = new ExtensionRunner([first, second], runtime, tempDir, sessionManager, modelRegistry);
expect(runner.resolveToolRenderers("a", () => undefined)).toEqual({ renderCall });
expect(runner.resolveToolRenderers("b", () => undefined)).toEqual({ renderShell: "self" });
expect(runner.resolveToolRenderers("b", () => ({ renderCall }))).toEqual({ renderCall });
});
describe("boundary chaining", () => {
it("chains shared draft proposals and preserves omitted result fields", async () => {
const runtime = createExtensionRuntime();
const eventBus = createEventBus();
const observations: Array<{ entries: number; continuation: boolean; preview: number }> = [];
const first = await loadExtensionFromFactory(
(pi) => {
pi.on("agent_before_settle", (event) => {
observations.push({
entries: event.entries.length,
continuation: event.continue,
preview: event.context.contextEntries.length,
});
event.entries.push({ type: "custom", customType: "first", data: 1 });
return { continue: true };
});
},
tempDir,
eventBus,
runtime,
"<inline:first>",
);
const second = await loadExtensionFromFactory(
(pi) => {
pi.on("agent_before_settle", (event) => {
observations.push({
entries: event.entries.length,
continuation: event.continue,
preview: event.context.contextEntries.length,
});
return { entries: [] };
});
},
tempDir,
eventBus,
runtime,
"<inline:second>",
);
const runner = new ExtensionRunner([first, second], runtime, tempDir, sessionManager, modelRegistry);
const result = await runner.emitBoundary({ type: "agent_before_settle", outcome: "completed" }, (entries) => ({
contextEntries: entries.map((entry, index) => ({
sourceEntry: {
type: "custom",
id: `draft-${index}`,
parentId: null,
timestamp: "",
customType: entry.type,
},
messages: [],
})),
contextMessages: [],
llmMessages: [],
pendingMessages: [],
canContinue: false,
}));
expect(observations).toEqual([
{ entries: 0, continuation: false, preview: 0 },
{ entries: 1, continuation: true, preview: 1 },
]);
expect(result.entries).toEqual([]);
expect(result.continue).toBe(true);
});
it("reports invalid boundary previews and lets later handlers repair the proposal", async () => {
const runtime = createExtensionRuntime();
let secondRan = false;
const first = await loadExtensionFromFactory(
(pi) => {
pi.on("agent_before_settle", () => ({
entries: [{ type: "context_edit", targetId: "missing", replacement: null }],
}));
},
tempDir,
createEventBus(),
runtime,
"<inline:invalid>",
);
const second = await loadExtensionFromFactory(
(pi) => {
pi.on("agent_before_settle", (event) => {
secondRan = true;
expect(event.entries).toHaveLength(1);
return { entries: [] };
});
},
tempDir,
createEventBus(),
runtime,
"<inline:repair>",
);
const runner = new ExtensionRunner([first, second], runtime, tempDir, sessionManager, modelRegistry);
const errors: string[] = [];
runner.onError((error) => errors.push(error.error));
const result = await runner.emitBoundary({ type: "agent_before_settle", outcome: "completed" }, (entries) => {
if (entries.some((entry) => entry.type === "context_edit")) throw new Error("Entry missing not found");
return {
contextEntries: [],
contextMessages: [],
llmMessages: [],
pendingMessages: [],
canContinue: false,
};
});
expect(secondRan).toBe(true);
expect(errors).toContain("Invalid boundary entries: Entry missing not found");
expect(result.entries).toEqual([]);
expect(result.valid).toBe(true);
});
it("keeps shared mutations made before a handler throws", async () => {
const runtime = createExtensionRuntime();
const extension = await loadExtensionFromFactory(
(pi) => {
pi.on("agent_before_settle", (event) => {
event.entries.push({ type: "custom", customType: "kept" });
throw new Error("boundary failed");
});
},
tempDir,
createEventBus(),
runtime,
);
const runner = new ExtensionRunner([extension], runtime, tempDir, sessionManager, modelRegistry);
const errors: string[] = [];
runner.onError((error) => errors.push(error.error));
const result = await runner.emitBoundary({ type: "agent_before_settle", outcome: "completed" }, () => ({
contextEntries: [],
contextMessages: [],
llmMessages: [],
pendingMessages: [],
canContinue: false,
}));
expect(result.entries).toMatchObject([{ type: "custom", customType: "kept" }]);
expect(errors).toEqual(["boundary failed"]);
});
});
@@ -965,6 +1246,127 @@ describe("ExtensionRunner", () => {
});
});
// #8967: event handler unsubscription must not disturb other registrations.
describe("event subscriptions", () => {
async function loadSubscriptionExtension(factory: ExtensionFactory) {
const runtime = createExtensionRuntime();
const extension = await loadExtensionFromFactory(factory, tempDir, createEventBus(), runtime);
const runner = new ExtensionRunner([extension], runtime, tempDir, sessionManager, modelRegistry);
return { extension, runner };
}
it("allows self-removal without skipping neighboring handlers", async () => {
const calls: string[] = [];
const { runner } = await loadSubscriptionExtension((pi) => {
const unsubscribe = pi.on("agent_end", () => {
calls.push("A");
unsubscribe();
});
pi.on("agent_end", () => {
calls.push("B");
});
});
await runner.emit({ type: "agent_end", messages: [] });
expect(calls).toEqual(["A", "B"]);
await runner.emit({ type: "agent_end", messages: [] });
expect(calls).toEqual(["A", "B", "B"]);
});
it("removes duplicate registrations independently and cleans up the last handler", async () => {
const calls: string[] = [];
const unsubscribers: Array<() => void> = [];
const { extension, runner } = await loadSubscriptionExtension((pi) => {
const shared = () => {
calls.push("shared");
};
unsubscribers.push(pi.on("agent_end", shared));
unsubscribers.push(
pi.on("agent_end", () => {
calls.push("B");
}),
);
unsubscribers.push(pi.on("agent_end", shared));
});
const [stopFirst, stopB, stopSecond] = unsubscribers;
stopSecond();
stopSecond();
await runner.emit({ type: "agent_end", messages: [] });
expect(calls).toEqual(["shared", "B"]);
stopFirst();
await runner.emit({ type: "agent_end", messages: [] });
expect(calls).toEqual(["shared", "B", "B"]);
stopB();
expect(extension.handlers.has("agent_end")).toBe(false);
});
it("keeps removed pending handlers in the current dispatch", async () => {
const calls: string[] = [];
const { runner } = await loadSubscriptionExtension((pi) => {
pi.on("agent_end", () => {
calls.push("A");
stopB();
});
const stopB = pi.on("agent_end", () => {
calls.push("B");
});
pi.on("agent_end", () => {
calls.push("C");
});
});
await runner.emit({ type: "agent_end", messages: [] });
expect(calls).toEqual(["A", "B", "C"]);
await runner.emit({ type: "agent_end", messages: [] });
expect(calls).toEqual(["A", "B", "C", "A", "C"]);
});
it("defers registrations made during dispatch until the next dispatch", async () => {
const calls: string[] = [];
const { runner } = await loadSubscriptionExtension((pi) => {
pi.on("agent_end", () => {
calls.push("A");
pi.on("agent_end", () => {
calls.push("C");
});
});
pi.on("agent_end", () => {
calls.push("B");
});
});
await runner.emit({ type: "agent_end", messages: [] });
expect(calls).toEqual(["A", "B"]);
await runner.emit({ type: "agent_end", messages: [] });
expect(calls).toEqual(["A", "B", "A", "B", "C"]);
});
it("uses a fresh handler list for nested dispatches", async () => {
const calls: string[] = [];
const { runner } = await loadSubscriptionExtension((pi) => {
const stopA = pi.on("agent_end", async () => {
calls.push("A");
stopA();
stopB();
pi.on("agent_end", () => {
calls.push("C");
});
await runner.emit({ type: "agent_end", messages: [] });
});
const stopB = pi.on("agent_end", () => {
calls.push("B");
});
});
await runner.emit({ type: "agent_end", messages: [] });
expect(calls).toEqual(["A", "C", "B"]);
});
});
describe("hasHandlers", () => {
it("returns true when handlers exist for event type", async () => {
const extCode = `