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:
@@ -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 = `
|
||||
|
||||
Reference in New Issue
Block a user