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:
@@ -2,20 +2,40 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
fauxAssistantMessage,
|
||||
fauxToolCall,
|
||||
getCurrentSystemPrompt,
|
||||
type Model,
|
||||
type TranscriptContext,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { ExtensionAPI, InputEvent } from "../../src/core/extensions/index.ts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BeforeAgentStartEvent, ExtensionAPI, InputEvent } from "../../src/core/extensions/index.ts";
|
||||
import type { PromptTemplate } from "../../src/core/prompt-templates.ts";
|
||||
import { createSyntheticSourceInfo } from "../../src/core/source-info.ts";
|
||||
import { createTestResourceLoader } from "../utilities.ts";
|
||||
import { createHarness, getMessageText, type Harness } from "./harness.ts";
|
||||
|
||||
const processImage = vi.hoisted(() =>
|
||||
vi.fn(async (_bytes: Uint8Array, mimeType: string) => ({
|
||||
ok: true as const,
|
||||
data: Buffer.from("normalized").toString("base64"),
|
||||
mimeType,
|
||||
hints: [],
|
||||
})),
|
||||
);
|
||||
vi.mock("../../src/utils/image-process.ts", () => ({ processImage }));
|
||||
|
||||
const TINY_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
|
||||
describe("AgentSession prompt characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
processImage.mockClear();
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
@@ -35,8 +55,8 @@ describe("AgentSession prompt characterization", () => {
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||
expect(getMessageText(harness.session.messages[0]!)).toBe("hi");
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual(["system", "user", "assistant"]);
|
||||
expect(getMessageText(harness.session.messages[1]!)).toBe("hi");
|
||||
expect(harness.getPendingResponseCount()).toBe(0);
|
||||
});
|
||||
|
||||
@@ -68,13 +88,14 @@ describe("AgentSession prompt characterization", () => {
|
||||
|
||||
expect(toolRuns).toEqual(["hello"]);
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual([
|
||||
"system",
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
expect(harness.session.messages[2]?.role).toBe("toolResult");
|
||||
expect(harness.session.messages[3]?.role).toBe("assistant");
|
||||
expect(harness.session.messages[3]?.role).toBe("toolResult");
|
||||
expect(harness.session.messages[4]?.role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("executes multiple tool calls from one response and continues with a single follow-up response", async () => {
|
||||
@@ -144,6 +165,44 @@ describe("AgentSession prompt characterization", () => {
|
||||
expect(sawImage).toBe(true);
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/earendil-works/pi/issues/9631
|
||||
it("uses the model selected by before_agent_start for image normalization", async () => {
|
||||
let strictModel: Model<string> | undefined;
|
||||
const harness = await createHarness({
|
||||
models: [{ id: "wide" }, { id: "strict" }],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("before_agent_start", async () => {
|
||||
if (!strictModel) throw new Error("Expected strict model");
|
||||
await pi.setModel(strictModel);
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
strictModel = harness.getModel("strict");
|
||||
if (!strictModel) throw new Error("Expected strict model");
|
||||
const resizeOptions = { maxWidth: 1000, maxHeight: 1000, maxBytes: 500000, jpegQuality: 70 };
|
||||
strictModel.inputLimits = { images: { resize: resizeOptions } };
|
||||
harness.setResponses([fauxAssistantMessage("done")]);
|
||||
|
||||
await harness.session.prompt("inspect", {
|
||||
images: [{ type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" }],
|
||||
});
|
||||
|
||||
expect(harness.session.model?.id).toBe("strict");
|
||||
expect(processImage).toHaveBeenCalledWith(expect.any(Uint8Array), "image/png", {
|
||||
autoResizeImages: true,
|
||||
resizeOptions,
|
||||
});
|
||||
const userMessage = harness.session.messages.find((message) => message.role === "user");
|
||||
expect(userMessage?.content).toContainEqual({
|
||||
type: "image",
|
||||
data: Buffer.from("normalized").toString("base64"),
|
||||
mimeType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
it("expands skill commands before sending the prompt", async () => {
|
||||
const tempDir = join(tmpdir(), `pi-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
@@ -318,8 +377,8 @@ describe("AgentSession prompt characterization", () => {
|
||||
|
||||
await harness.session.sendUserMessage("from extension");
|
||||
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||
expect(getMessageText(harness.session.messages[0]!)).toBe("from extension");
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual(["system", "user", "assistant"]);
|
||||
expect(getMessageText(harness.session.messages[1]!)).toBe("from extension");
|
||||
});
|
||||
|
||||
it("does not report streamingBehavior to input handlers while idle", async () => {
|
||||
@@ -503,4 +562,162 @@ describe("AgentSession prompt characterization", () => {
|
||||
`No API key found for ${harness.getModel().provider}.`,
|
||||
);
|
||||
});
|
||||
|
||||
// Issue #10267, #5581: a run started by sendMessage(..., { triggerTurn: true }) skipped
|
||||
// before_agent_start, so its second request patched away the sections handlers had added.
|
||||
describe("runs started by a custom message", () => {
|
||||
it("keeps the same system prompt for every request of the run", async () => {
|
||||
const events: BeforeAgentStartEvent[] = [];
|
||||
const noopTool: AgentTool = {
|
||||
name: "noop",
|
||||
label: "Noop",
|
||||
description: "Does nothing",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [noopTool],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("before_agent_start", (event) => {
|
||||
events.push(event);
|
||||
event.systemPromptOptions.sections.extension_rules = "Answer in one word.";
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const prompts: string[] = [];
|
||||
const record = (response: ReturnType<typeof fauxAssistantMessage>) => (context: TranscriptContext) => {
|
||||
prompts.push(getCurrentSystemPrompt(context.messages));
|
||||
return response;
|
||||
};
|
||||
harness.setResponses([
|
||||
record(fauxAssistantMessage("hi")),
|
||||
record(fauxAssistantMessage([fauxToolCall("noop", {})], { stopReason: "toolUse" })),
|
||||
record(fauxAssistantMessage("done")),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hello");
|
||||
await harness.session.sendCustomMessage(
|
||||
{ customType: "task-done", content: "background task finished", display: true },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
|
||||
expect(prompts).toHaveLength(3);
|
||||
expect(prompts[0]).toContain("Answer in one word.");
|
||||
expect(new Set(prompts).size).toBe(1);
|
||||
expect(events.map((event) => event.messageType)).toEqual(["user", "custom"]);
|
||||
const customEvent = events[1];
|
||||
expect(customEvent.messageType === "custom" && customEvent.message.customType).toBe("task-done");
|
||||
});
|
||||
|
||||
it("queues a custom message sent while before_agent_start runs into the starting run", async () => {
|
||||
let releaseHandler: () => void = () => {};
|
||||
const handlerStarted = Promise.withResolvers<void>();
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
if (event.messageType !== "custom" || event.message.customType !== "first") return;
|
||||
handlerStarted.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseHandler = resolve;
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const requests: TranscriptContext[] = [];
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
requests.push(context);
|
||||
return fauxAssistantMessage("handled both");
|
||||
},
|
||||
]);
|
||||
|
||||
const first = harness.session.sendCustomMessage(
|
||||
{ customType: "first", content: "task one finished", display: true },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
await handlerStarted.promise;
|
||||
expect(harness.session.isIdle).toBe(false);
|
||||
const second = harness.session.sendCustomMessage(
|
||||
{ customType: "second", content: "task two finished", display: true },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
releaseHandler();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
const texts = requests[0].messages.flatMap((message) =>
|
||||
message.role === "user" && typeof message.content !== "string"
|
||||
? message.content.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
: [],
|
||||
);
|
||||
expect(texts).toEqual(["task one finished", "task two finished"]);
|
||||
});
|
||||
|
||||
it("includes a custom message sent without triggerTurn while the run starts in its first request", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("before_agent_start", () => {
|
||||
pi.sendMessage(
|
||||
{ customType: "note", content: "context from handler", display: true },
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const requests: TranscriptContext[] = [];
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
requests.push(context);
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hello");
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(JSON.stringify(requests[0].messages)).toContain("context from handler");
|
||||
});
|
||||
|
||||
it("aborts a run when abort is requested while before_agent_start runs", async () => {
|
||||
let releaseHandler: () => void = () => {};
|
||||
const handlerStarted = Promise.withResolvers<void>();
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("before_agent_start", async () => {
|
||||
handlerStarted.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseHandler = resolve;
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("should not complete")]);
|
||||
|
||||
const run = harness.session.sendCustomMessage(
|
||||
{ customType: "task-done", content: "background task finished", display: true },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
await handlerStarted.promise;
|
||||
const aborted = harness.session.abort();
|
||||
releaseHandler();
|
||||
await Promise.all([run, aborted]);
|
||||
|
||||
const last = harness.session.messages.at(-1);
|
||||
expect(last?.role).toBe("assistant");
|
||||
expect(last?.role === "assistant" && last.stopReason).toBe("aborted");
|
||||
expect(harness.session.isIdle).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user