Files
dev/third_party/pi/packages/durable/test/harness-structured-output.test.ts
alexion 6081e3e325 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
2026-10-15 17:58:48 -04:00

408 lines
16 KiB
TypeScript

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { JsonValue } from "@earendil-works/chord";
import {
type AssistantMessage,
fauxAssistantMessage,
fauxText,
fauxToolCall,
type ToolResultMessage,
Type,
} from "@earendil-works/pi-ai";
import {
defineTool,
MemoryStorage,
NestedCallDoc,
type NestedCallState,
type NestedToolExecutionResult,
type ToolExecutionResult,
type ToolRegistration,
ToolResultEntry,
ToolTask,
watchEvents,
} from "@earendil-works/pi-durable";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../src/env/node.ts";
import { createBashTool } from "../src/tools/index.ts";
import { allEntries, type ChatSetup, chatSetup, openChat } from "./chat-support.ts";
import { addHooks, addTool } from "./harness-support.ts";
import { context } from "./session-support.ts";
const Count = Type.Object({ count: Type.Number() });
/** A tool that returns `result`, with `extra` such as a schema. */
function returning(name: string, result: ToolExecutionResult, extra: Partial<ToolRegistration> = {}): ToolRegistration {
return defineTool({
name,
description: name,
parameters: Type.Object({}),
execute: async () => result,
...extra,
});
}
function call(name: string): AssistantMessage {
return fauxAssistantMessage([fauxToolCall(name, {}, { id: "c1" })], { stopReason: "toolUse" });
}
const DONE = fauxAssistantMessage([fauxText("done")]);
/** Run `name` as a nested call of a `probe` tool and return what the probe received. */
async function nested(setup: ChatSetup, name: string): Promise<NestedToolExecutionResult> {
let received: NestedToolExecutionResult | undefined;
addTool(
setup.registry,
defineTool({
name: "probe",
description: "probe",
parameters: Type.Object({}),
execute: async (_args, api, callContext) => {
received = await api.executeTool(name, {}, callContext);
return {};
},
}),
);
setup.faux.setResponses([call("probe"), DONE]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await (await root.submit({ type: "input", content: "go" }, context)).wait(context);
await harness.close(context);
return received!;
}
/** Run `name` as a model-issued call and return its transcript message. */
async function direct(setup: ChatSetup, name: string): Promise<ToolResultMessage> {
setup.faux.setResponses([call(name), DONE]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await (await root.submit({ type: "input", content: "go" }, context)).wait(context);
const entry = (await allEntries(root)).find((candidate) => ToolResultEntry.is(candidate))!;
await harness.close(context);
return entry.model![0] as ToolResultMessage;
}
function codes(result: { readonly diagnostics?: readonly { readonly code?: string }[] }): (string | undefined)[] {
return (result.diagnostics ?? []).map((diagnostic) => diagnostic.code);
}
describe("structured output", () => {
it("gives callers a schema-less tool's output: one text as a string, one image as itself, else the list", async () => {
const text = { type: "text" as const, text: "one" };
const image = { type: "image" as const, data: "AAAA", mimeType: "image/png" };
const cases: [output: (typeof text | typeof image)[], value: JsonValue][] = [
[[text], "one"],
[[image], image],
[[], ""],
[
[text, image],
[text, image],
],
[
[text, text],
[text, text],
],
];
for (const [output, value] of cases) {
const setup = chatSetup();
addTool(setup.registry, returning("plain", { output }));
const received = await nested(setup, "plain");
expect(received).toEqual({
taskId: received.taskId,
structuredOutput: value,
isError: false,
diagnostics: [],
durationMs: received.durationMs,
});
}
});
it("gives callers the bounded output, as the model sees it, without the rendered diagnostics", async () => {
const setup = chatSetup();
addTool(
setup.registry,
returning(
"long",
{ output: [{ type: "text", text: "a\nb\nc" }], diagnostics: [{ severity: "info", message: "note" }] },
{ outputLimits: { maxLines: 1 } },
),
);
const received = await nested(setup, "long");
expect(received.structuredOutput).toBe("a\n");
expect(codes(received)).toEqual([undefined, "truncated"]);
});
it("stores exactly what callers and events get, without the model's output, and keeps a stored null", async () => {
const setup = chatSetup();
addTool(setup.registry, returning("plain", { output: [{ type: "text", text: "a" }] }));
addTool(
setup.registry,
returning("nothing", { structuredOutput: null }, { structuredOutputSchema: Type.Null() }),
);
const received: NestedToolExecutionResult[] = [];
addTool(
setup.registry,
defineTool({
name: "probe",
description: "probe",
parameters: Type.Object({}),
execute: async (_args, api, callContext) => {
received.push(await api.executeTool("plain", {}, callContext));
received.push(await api.executeTool("nothing", {}, callContext));
return {};
},
}),
);
setup.faux.setResponses([call("probe"), DONE]);
const storage = new MemoryStorage();
const { harness, root } = await openChat(storage, setup);
// The result each nested call's document got, as committed.
const stored: JsonValue[] = [];
harness.subscribeCommits((publication) => {
for (const change of publication.changes) {
if (change.type !== "document" || change.record.kind !== NestedCallDoc.definition.kind) continue;
const result = (change.value as NestedCallState | null)?.result;
if (result !== undefined && change.ops.length > 0) stored.push(result as unknown as JsonValue);
}
});
const stream = await watchEvents(harness, root.id, context);
const ends: NestedToolExecutionResult[] = [];
stream.start(async (batch) => {
for (const event of batch) {
if (event.type === "tool_execution_end" && event.parentToolCallId !== undefined) {
ends.push(event.result as NestedToolExecutionResult);
}
}
});
await (await root.submit({ type: "input", content: "go" }, context)).wait(context);
await harness.waitForIdle(context);
await stream.stop();
await harness.close(context);
expect(received[0]).toMatchObject({ structuredOutput: "a", isError: false });
expect(received[1]).toMatchObject({ structuredOutput: null, isError: false });
expect(received[0]).not.toHaveProperty("output");
expect(stored).toEqual(received);
expect(ends).toEqual(received);
});
it("gives callers a schema tool's validated structured output, which the transcript does not store", async () => {
const setup = chatSetup();
const result = { output: [{ type: "text" as const, text: "3 things" }], structuredOutput: { count: 3 } };
addTool(setup.registry, returning("counter", result, { structuredOutputSchema: Count }));
expect(await nested(setup, "counter")).toMatchObject({ structuredOutput: { count: 3 }, isError: false });
const modelSetup = chatSetup();
addTool(modelSetup.registry, returning("counter", result, { structuredOutputSchema: Count }));
const stored = await direct(modelSetup, "counter");
expect(stored).toMatchObject({ isError: false, content: [{ type: "text", text: "3 things" }] });
expect(stored).not.toHaveProperty("structuredOutput");
});
it("turns structured output that breaks the schema into an error result for callers, and a report for the model's calls", async () => {
const cases: [name: string, result: ToolExecutionResult, schema: boolean, message: string][] = [
["wrong", { structuredOutput: { count: "three" } }, true, "does not match its schema: count:"],
["missing", { output: [] }, true, "returned no structuredOutput"],
["undeclared", { structuredOutput: { count: 3 } }, false, "declares no structuredOutputSchema"],
];
for (const [name, result, schema, message] of cases) {
const setup = chatSetup();
addTool(setup.registry, returning(name, result, schema ? { structuredOutputSchema: Count } : {}));
const received = await nested(setup, name);
expect(received.isError).toBe(true);
// The broken value is dropped; a schema-less tool's error result still carries its output, here none.
if (schema) expect(received).not.toHaveProperty("structuredOutput");
else expect(received.structuredOutput).toBe("");
expect(codes(received)).toEqual(["invalid_structured_output"]);
expect(received.diagnostics[0]?.message).toContain(message);
const modelSetup = chatSetup();
addTool(modelSetup.registry, returning(name, result, schema ? { structuredOutputSchema: Count } : {}));
// The model never sees structured output: its call stands, and the host gets a report.
const stored = await direct(modelSetup, name);
expect(stored.isError).toBe(false);
expect(JSON.stringify(stored.content)).not.toContain(message);
expect(modelSetup.reports.map((error) => (error as Error).message).join("\n")).toContain(message);
}
});
it("gives callers the output of an error result of a tool without a schema", async () => {
const setup = chatSetup();
addTool(setup.registry, returning("bad", { output: [{ type: "text", text: "bad" }], isError: true }));
expect(await nested(setup, "bad")).toMatchObject({ isError: true, structuredOutput: "bad" });
});
it("gives callers no structured output for an error result the Harness wrote, only diagnostics", async () => {
const setup = chatSetup();
const received = await nested(setup, "missing");
expect(received).toMatchObject({ isError: true, diagnostics: [{ code: "tool_unavailable" }] });
expect(received).not.toHaveProperty("structuredOutput");
});
it("gives callers a schema tool's error text as a diagnostic, never as structured output", async () => {
const setup = chatSetup();
addTool(
setup.registry,
returning(
"fails",
{ output: [{ type: "text", text: "nope" }], isError: true },
{ structuredOutputSchema: Count },
),
);
// Its text reaches the caller as an error diagnostic: structuredOutput is only ever the tool's own value.
const received = await nested(setup, "fails");
expect(received).toMatchObject({
isError: true,
diagnostics: [{ severity: "error", code: "tool_error", message: "nope" }],
});
expect(received).not.toHaveProperty("structuredOutput");
// The model's call keeps the text in its output, without the extra diagnostic.
const modelSetup = chatSetup();
addTool(
modelSetup.registry,
returning(
"fails",
{ output: [{ type: "text", text: "nope" }], isError: true },
{ structuredOutputSchema: Count },
),
);
const stored = await direct(modelSetup, "fails");
expect(stored.content).toEqual([{ type: "text", text: "nope" }]);
// A thrown error's message is in its tool_error diagnostic, not in the output.
const thrown = chatSetup();
addTool(
thrown.registry,
defineTool({
name: "throws",
description: "throws",
parameters: Type.Object({}),
structuredOutputSchema: Count,
execute: async () => {
throw new Error("permission denied");
},
}),
);
const thrownResult = await nested(thrown, "throws");
// One diagnostic: the thrown message, not a second one from its (empty) output.
expect(thrownResult).toMatchObject({
isError: true,
diagnostics: [{ code: "tool_error", message: "permission denied" }],
});
expect(thrownResult.diagnostics).toHaveLength(1);
expect(thrownResult).not.toHaveProperty("structuredOutput");
// An error result whose own diagnostics say what went wrong gets no second one from its output.
const explained = chatSetup();
addTool(
explained.registry,
returning(
"fails",
{
output: [{ type: "text", text: "partial log" }],
isError: true,
diagnostics: [{ severity: "error", code: "quota", message: "quota exceeded" }],
},
{ structuredOutputSchema: Count },
),
);
expect((await nested(explained, "fails")).diagnostics).toEqual([
{ severity: "error", code: "quota", message: "quota exceeded" },
]);
// An error result that has structured output keeps it.
const kept = chatSetup();
const withValue = {
output: [{ type: "text" as const, text: "nope" }],
structuredOutput: { count: 0 },
isError: true,
};
addTool(kept.registry, returning("fails", withValue, { structuredOutputSchema: Count }));
expect(await nested(kept, "fails")).toMatchObject({ isError: true, structuredOutput: { count: 0 } });
});
it("keeps structured output when afterTool replaces the output, and validates one afterTool replaces", async () => {
const setup = chatSetup();
addTool(setup.registry, returning("plain", { output: [{ type: "text", text: "secret" }] }));
addTool(
setup.registry,
returning("counter", { structuredOutput: { count: 1 } }, { structuredOutputSchema: Count }),
);
addTool(
setup.registry,
returning("other", { structuredOutput: { count: 1 } }, { structuredOutputSchema: Count }),
);
addHooks(setup.registry, ToolTask, {
afterTool: (hookCall, result) =>
hookCall.name === "other"
? { ...result, structuredOutput: { count: "broken" } as unknown as JsonValue }
: hookCall.name === "probe"
? result
: { ...result, output: [{ type: "text", text: "rewritten" }] },
});
let plain: NestedToolExecutionResult | undefined;
let counter: NestedToolExecutionResult | undefined;
let other: NestedToolExecutionResult | undefined;
addTool(
setup.registry,
defineTool({
name: "probe",
description: "probe",
parameters: Type.Object({}),
execute: async (_args, api, callContext) => {
plain = await api.executeTool("plain", {}, callContext);
counter = await api.executeTool("counter", {}, callContext);
other = await api.executeTool("other", {}, callContext);
return {};
},
}),
);
setup.faux.setResponses([call("probe"), DONE]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await (await root.submit({ type: "input", content: "go" }, context)).wait(context);
await harness.close(context);
// A schema-less tool's value is its output, so redacting the output redacts what programs get.
expect(plain?.structuredOutput).toBe("rewritten");
expect(counter).toMatchObject({ structuredOutput: { count: 1 } });
expect(other?.isError).toBe(true);
expect(codes(other!)).toEqual(["invalid_structured_output"]);
});
it("gives callers of bash its retained output and exit code, also for a nonzero exit", async () => {
const directory = mkdtempSync(join(tmpdir(), "pi-durable-structured-"));
try {
const setup = chatSetup();
addTool(setup.registry, createBashTool());
const received: NestedToolExecutionResult[] = [];
addTool(
setup.registry,
defineTool({
name: "probe",
description: "probe",
parameters: Type.Object({}),
execute: async (_args, api, callContext) => {
received.push(await api.executeTool("bash", { command: "printf ok" }, callContext));
received.push(await api.executeTool("bash", { command: "printf bad; exit 4" }, callContext));
return {};
},
}),
);
setup.faux.setResponses([call("probe"), DONE]);
const env = new NodeExecutionEnv({ cwd: directory });
const { harness, root } = await openChat(new MemoryStorage(), setup, { env });
await (await root.submit({ type: "input", content: "go" }, context)).wait(context);
await harness.close(context);
expect(received[0]).toMatchObject({
isError: false,
structuredOutput: { output: "ok", truncated: false, exitCode: 0 },
});
expect(received[1]).toMatchObject({
isError: true,
structuredOutput: { output: "bad", truncated: false, exitCode: 4 },
});
expect(codes(received[1]!)).toEqual(["exit_code"]);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
});