Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
1380 lines
44 KiB
TypeScript
1380 lines
44 KiB
TypeScript
import type { ConnectorTool } from "@rakazo/adapter-kit";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const fakeAgentState = vi.hoisted(() => ({
|
|
mode: "dispatch" as
|
|
| "dispatch"
|
|
| "empty"
|
|
| "two-boundaries"
|
|
| "silent-continuation"
|
|
| "subagent-limit"
|
|
| "parent-limit"
|
|
| "parent-parallel"
|
|
| "ask-pause"
|
|
| "nested-ask-pause",
|
|
emitFinalAfterFollowUp: true,
|
|
abortCount: 0,
|
|
tools: [] as Array<{
|
|
name: string;
|
|
prepareArguments?: (args: unknown) => Record<string, unknown>;
|
|
execute: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
}>,
|
|
invoke: {
|
|
name: "destination_write",
|
|
args: { collection: "notes", title: "Result", body: "Done" } as Record<string, unknown>,
|
|
},
|
|
preparedMessages: [] as unknown[],
|
|
steeredMessages: [] as unknown[],
|
|
followUpMessages: [] as unknown[],
|
|
initialMessages: [] as unknown[],
|
|
promptInputs: [] as string[],
|
|
promptImages: [] as unknown[][],
|
|
toolResult: undefined as unknown,
|
|
}));
|
|
|
|
vi.mock("@earendil-works/pi-agent-core", () => ({
|
|
Agent: class {
|
|
state = { errorMessage: undefined as string | undefined, messages: [] as unknown[] };
|
|
private readonly tools: typeof fakeAgentState.tools;
|
|
private readonly listeners: Array<(event: Record<string, unknown>) => void> = [];
|
|
private readonly prepareNextTurnWithContext?: (input: {
|
|
context: { messages: unknown[] };
|
|
}) => Promise<{ context?: { messages: unknown[] } } | undefined>;
|
|
private aborted = false;
|
|
|
|
constructor(options: {
|
|
initialState: { tools: typeof fakeAgentState.tools; messages: unknown[] };
|
|
prepareNextTurnWithContext?: (input: {
|
|
context: { messages: unknown[] };
|
|
}) => Promise<{ context?: { messages: unknown[] } } | undefined>;
|
|
}) {
|
|
this.tools = options.initialState.tools;
|
|
this.prepareNextTurnWithContext = options.prepareNextTurnWithContext;
|
|
fakeAgentState.tools = this.tools;
|
|
fakeAgentState.initialMessages = options.initialState.messages;
|
|
}
|
|
|
|
subscribe(listener: (event: Record<string, unknown>) => void) {
|
|
this.listeners.push(listener);
|
|
}
|
|
|
|
async prompt(prompt: string, images?: unknown[]) {
|
|
fakeAgentState.promptInputs.push(prompt);
|
|
fakeAgentState.promptImages.push(images ?? []);
|
|
if (fakeAgentState.mode === "empty" || fakeAgentState.mode === "two-boundaries") {
|
|
await this.prepareNextTurnWithContext?.({ context: { messages: [] } });
|
|
if (fakeAgentState.mode === "two-boundaries") {
|
|
await this.prepareNextTurnWithContext?.({ context: { messages: [] } });
|
|
}
|
|
fakeAgentState.preparedMessages = [...fakeAgentState.steeredMessages];
|
|
return;
|
|
}
|
|
|
|
if (fakeAgentState.mode === "dispatch") {
|
|
const target =
|
|
this.tools.find((tool) => tool.name === fakeAgentState.invoke.name) ?? this.tools[0];
|
|
if (!target) throw new Error("expected tool was not exposed");
|
|
const rawArgs = fakeAgentState.invoke.args;
|
|
const args = target.prepareArguments?.(rawArgs) ?? rawArgs;
|
|
this.emit({ type: "tool_execution_start", toolName: target.name, args });
|
|
fakeAgentState.toolResult = await target.execute("call-1", args);
|
|
return;
|
|
}
|
|
|
|
if (fakeAgentState.mode === "silent-continuation") {
|
|
const target =
|
|
this.tools.find((tool) => tool.name === fakeAgentState.invoke.name) ?? this.tools[0];
|
|
if (!target) throw new Error("expected tool was not exposed");
|
|
const rawArgs = fakeAgentState.invoke.args;
|
|
const args = target.prepareArguments?.(rawArgs) ?? rawArgs;
|
|
const narration = "I will check the destination first.";
|
|
this.emit({
|
|
type: "message_update",
|
|
assistantMessageEvent: { type: "text_delta", delta: narration },
|
|
});
|
|
this.emit({ type: "tool_execution_start", toolName: target.name, args });
|
|
await target.execute("call-1", args);
|
|
this.emit({
|
|
type: "turn_end",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "text", text: narration },
|
|
{
|
|
type: "toolCall",
|
|
id: "call-1",
|
|
name: target.name,
|
|
arguments: args,
|
|
},
|
|
],
|
|
},
|
|
toolResults: [{ toolCallId: "call-1", result: { ok: true } }],
|
|
});
|
|
this.emit({
|
|
type: "turn_end",
|
|
message: { role: "assistant", content: [] },
|
|
toolResults: [],
|
|
});
|
|
if (fakeAgentState.followUpMessages.length > 0 && fakeAgentState.emitFinalAfterFollowUp) {
|
|
const text = "The final answer.";
|
|
this.emit({
|
|
type: "message_update",
|
|
assistantMessageEvent: { type: "text_delta", delta: text },
|
|
});
|
|
this.emit({
|
|
type: "message_end",
|
|
message: { role: "assistant", content: [{ type: "text", text }] },
|
|
});
|
|
this.emit({
|
|
type: "turn_end",
|
|
message: { role: "assistant", content: [{ type: "text", text }] },
|
|
toolResults: [],
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (fakeAgentState.mode === "parent-limit") {
|
|
const shell = this.tools.find((tool) => tool.name === "shell");
|
|
if (!shell) throw new Error("shell was not exposed");
|
|
for (let index = 0; index < 100; index += 1) {
|
|
const args = { command: `echo ${index}` };
|
|
this.emit({ type: "tool_execution_start", toolName: shell.name, args });
|
|
if (this.aborted) break;
|
|
await shell.execute(`shell-${index}`, args);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (fakeAgentState.mode === "parent-parallel") {
|
|
const shell = this.tools.find((tool) => tool.name === "shell");
|
|
if (!shell) throw new Error("shell was not exposed");
|
|
const batch = Array.from({ length: 4 }, (_, index) => ({
|
|
args: { command: `echo ${index}` },
|
|
}));
|
|
for (const item of batch) {
|
|
this.emit({ type: "tool_execution_start", toolName: shell.name, args: item.args });
|
|
}
|
|
await Promise.all(
|
|
batch.map((item, index) =>
|
|
this.aborted ? Promise.resolve() : shell.execute(`shell-${index}`, item.args),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (fakeAgentState.mode === "ask-pause" || fakeAgentState.mode === "nested-ask-pause") {
|
|
if (fakeAgentState.mode === "nested-ask-pause") {
|
|
const delegation = this.tools.find((tool) => tool.name === "run_subagent");
|
|
if (delegation) {
|
|
const args = { name: "helper", task: "ask the user" };
|
|
this.emit({ type: "tool_execution_start", toolName: delegation.name, args });
|
|
await delegation.execute("delegate-1", args);
|
|
return;
|
|
}
|
|
}
|
|
const shell = this.tools.find((tool) => tool.name === "shell");
|
|
const askUser = this.tools.find((tool) => tool.name === "ask_user");
|
|
if (!shell) throw new Error("shell was not exposed");
|
|
if (!askUser) throw new Error("ask_user was not exposed");
|
|
for (let index = 0; index < 2; index += 1) {
|
|
const args = { command: `echo ${index}` };
|
|
this.emit({ type: "tool_execution_start", toolName: shell.name, args });
|
|
if (this.aborted) return;
|
|
await shell.execute(`shell-pause-${index}`, args);
|
|
}
|
|
const askArgs = {
|
|
question: "Which option?",
|
|
options: ["Keep going", "Stop here"],
|
|
};
|
|
this.emit({ type: "tool_execution_start", toolName: askUser.name, args: askArgs });
|
|
if (!this.aborted) await askUser.execute("ask-1", askArgs);
|
|
return;
|
|
}
|
|
|
|
const delegation = this.tools.find((tool) => tool.name === "run_subagent");
|
|
if (delegation) {
|
|
const args = { name: "loop", task: "keep calling shell" };
|
|
this.emit({ type: "tool_execution_start", toolName: delegation.name, args });
|
|
await delegation.execute("delegate-1", args);
|
|
return;
|
|
}
|
|
|
|
const shell = this.tools.find((tool) => tool.name === "shell");
|
|
if (!shell) throw new Error("shell was not exposed to the subagent");
|
|
for (let index = 0; index < 100; index += 1) {
|
|
const args = { command: `echo ${index}` };
|
|
this.emit({ type: "tool_execution_start", toolName: shell.name, args });
|
|
if (this.aborted) break;
|
|
await shell.execute(`shell-${index}`, args);
|
|
}
|
|
}
|
|
|
|
async waitForIdle() {}
|
|
|
|
steer(message: unknown) {
|
|
fakeAgentState.steeredMessages.push(message);
|
|
}
|
|
|
|
followUp(message: unknown) {
|
|
fakeAgentState.followUpMessages.push(message);
|
|
}
|
|
|
|
abort() {
|
|
this.aborted = true;
|
|
// Mirror pi-agent-core: abort settles the run with an errorMessage that
|
|
// would otherwise make PiAgentRuntime throw and skip a final reply.
|
|
this.state.errorMessage = "Request aborted by user";
|
|
fakeAgentState.abortCount += 1;
|
|
}
|
|
|
|
private emit(event: Record<string, unknown>) {
|
|
for (const listener of this.listeners) listener(event);
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock("@earendil-works/pi-ai/providers/all", () => ({
|
|
builtinModels: () => ({
|
|
getModel: (_provider: string, modelId: string) =>
|
|
modelId === "dispatch-test-model" ? { provider: "test", id: modelId } : undefined,
|
|
streamSimple: () => {
|
|
throw new Error("the fake agent must not call a provider");
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./pi-local-provider.js", () => ({
|
|
registerLocalProvider: (models: unknown) => models,
|
|
}));
|
|
|
|
vi.mock("./pi-openai-compatible-provider.js", () => ({
|
|
OPENAI_COMPATIBLE_PROVIDER_ID: "openai-compatible",
|
|
registerOpenAiCompatibleCatalog: (models: unknown) => models,
|
|
registerOpenAiCompatibleRuntime: (models: unknown) => models,
|
|
}));
|
|
|
|
import { maxToolCallsPerTurn, PiAgentRuntime } from "./pi-runtime.js";
|
|
import { TOOL_RESULT_TEXT_LIMIT } from "./pi-runtime-limits.js";
|
|
|
|
const destinationTool: ConnectorTool = {
|
|
name: "destination.write",
|
|
description: "Write a record to the connected destination",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
collection: { type: "string" },
|
|
title: { type: "string" },
|
|
body: { type: "string" },
|
|
},
|
|
},
|
|
route: { connectorId: "destination", toolName: "destination.write" },
|
|
};
|
|
|
|
const writeFileTool: ConnectorTool = {
|
|
name: "write_file",
|
|
description: "Write a UTF-8 file into this bot's home.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string" },
|
|
content: { type: "string" },
|
|
},
|
|
},
|
|
};
|
|
|
|
const shellTool = {
|
|
name: "shell",
|
|
description: "Run a command",
|
|
inputSchema: { type: "object", properties: { command: { type: "string" } } },
|
|
};
|
|
|
|
const previousMaxToolCalls = process.env.MAX_TOOL_CALLS_PER_TURN;
|
|
|
|
describe("Pi connector tool dispatch", () => {
|
|
beforeEach(() => {
|
|
fakeAgentState.mode = "dispatch";
|
|
fakeAgentState.abortCount = 0;
|
|
fakeAgentState.tools = [];
|
|
fakeAgentState.preparedMessages = [];
|
|
fakeAgentState.steeredMessages = [];
|
|
fakeAgentState.followUpMessages = [];
|
|
fakeAgentState.emitFinalAfterFollowUp = true;
|
|
fakeAgentState.initialMessages = [];
|
|
fakeAgentState.promptInputs = [];
|
|
fakeAgentState.promptImages = [];
|
|
fakeAgentState.toolResult = undefined;
|
|
fakeAgentState.invoke = {
|
|
name: "destination_write",
|
|
args: { collection: "notes", title: "Result", body: "Done" },
|
|
};
|
|
delete process.env.MAX_TOOL_CALLS_PER_TURN;
|
|
});
|
|
|
|
it("injects durable steering at Pi's next safe turn boundary", async () => {
|
|
fakeAgentState.mode = "empty";
|
|
let claimCount = 0;
|
|
const claimSteering = vi.fn(async () => {
|
|
claimCount += 1;
|
|
return claimCount === 1
|
|
? []
|
|
: [
|
|
{ id: "steering-1", messageId: "message-1", text: "Use the newer customer totals." },
|
|
{ id: "steering-2", messageId: "message-2", text: "Keep the original date range." },
|
|
];
|
|
});
|
|
const runtime = new PiAgentRuntime();
|
|
|
|
for await (const _event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "r",
|
|
prompt: "prepare the report",
|
|
instructions: "Follow the user's instructions.",
|
|
history: [],
|
|
tools: [],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
claimSteering,
|
|
},
|
|
{ signal: new AbortController().signal },
|
|
)) {
|
|
// Exhaust the runtime event stream.
|
|
}
|
|
|
|
expect(claimSteering).toHaveBeenNthCalledWith(1, []);
|
|
expect(claimSteering).toHaveBeenNthCalledWith(2, []);
|
|
expect(fakeAgentState.preparedMessages).toEqual([
|
|
expect.objectContaining({ role: "user", content: "Use the newer customer totals." }),
|
|
expect.objectContaining({ role: "user", content: "Keep the original date range." }),
|
|
]);
|
|
});
|
|
|
|
it("defers steering arriving during a continuation to the following boundary", async () => {
|
|
fakeAgentState.mode = "two-boundaries";
|
|
let claimCount = 0;
|
|
const claimSteering = vi.fn(async () => {
|
|
claimCount += 1;
|
|
if (claimCount === 1) return [];
|
|
return claimCount === 2
|
|
? [{ id: "steering-1", messageId: "message-1", text: "First boundary context." }]
|
|
: [{ id: "steering-2", messageId: "message-2", text: "Next boundary context." }];
|
|
});
|
|
const runtime = new PiAgentRuntime();
|
|
|
|
for await (const _event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "r",
|
|
prompt: "continue",
|
|
instructions: "Follow the user's instructions.",
|
|
history: [],
|
|
tools: [],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
claimSteering,
|
|
},
|
|
{ signal: new AbortController().signal },
|
|
)) {
|
|
// Exhaust the runtime event stream.
|
|
}
|
|
|
|
expect(claimSteering).toHaveBeenNthCalledWith(1, []);
|
|
expect(claimSteering).toHaveBeenNthCalledWith(2, []);
|
|
expect(claimSteering).toHaveBeenNthCalledWith(3, ["steering-1"]);
|
|
expect(fakeAgentState.preparedMessages).toEqual([
|
|
expect.objectContaining({ content: "First boundary context." }),
|
|
expect.objectContaining({ content: "Next boundary context." }),
|
|
]);
|
|
});
|
|
|
|
it("consumes pending continuation steering once in the first prompt", async () => {
|
|
fakeAgentState.mode = "empty";
|
|
const steering = [
|
|
{ id: "steering-1", messageId: "message-1", text: "Use the newer customer totals." },
|
|
{ id: "steering-2", messageId: "message-2", text: "Keep the original date range." },
|
|
];
|
|
const claimSteering = vi.fn(async (seenIds: string[]) => (seenIds.length ? [] : steering));
|
|
const runtime = new PiAgentRuntime();
|
|
|
|
for await (const _event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "continuation",
|
|
prompt: "Respond to the user's steering context.",
|
|
instructions: "Follow the user's instructions.",
|
|
history: [
|
|
{ id: steering[0]!.messageId, role: "user", content: steering[0]!.text },
|
|
{ id: steering[1]!.messageId, role: "user", content: steering[1]!.text },
|
|
{ role: "assistant", content: "Here is the first result." },
|
|
],
|
|
tools: [],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
claimSteering,
|
|
},
|
|
{ signal: new AbortController().signal },
|
|
)) {
|
|
// Exhaust the runtime event stream.
|
|
}
|
|
|
|
expect(fakeAgentState.promptInputs).toHaveLength(1);
|
|
for (const item of steering) {
|
|
expect(fakeAgentState.promptInputs[0]?.split(item.text)).toHaveLength(2);
|
|
expect(JSON.stringify(fakeAgentState.initialMessages)).not.toContain(item.text);
|
|
}
|
|
expect(fakeAgentState.steeredMessages).toEqual([]);
|
|
});
|
|
|
|
it("delivers images attached to initial and boundary steering", async () => {
|
|
fakeAgentState.mode = "empty";
|
|
let claimCount = 0;
|
|
const claimSteering = vi.fn(async () => {
|
|
claimCount += 1;
|
|
if (claimCount === 1) {
|
|
return [
|
|
{
|
|
id: "initial-image",
|
|
messageId: "initial-image-message",
|
|
text: "Inspect the first image.",
|
|
images: [
|
|
{
|
|
name: "first.png",
|
|
mimeType: "image/png" as const,
|
|
data: new Uint8Array([1, 2, 3]),
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
return [
|
|
{
|
|
id: "boundary-image",
|
|
messageId: "boundary-image-message",
|
|
text: "Compare the second image.",
|
|
images: [
|
|
{ name: "second.png", mimeType: "image/png" as const, data: new Uint8Array([4, 5, 6]) },
|
|
],
|
|
},
|
|
];
|
|
});
|
|
const runtime = new PiAgentRuntime();
|
|
|
|
for await (const _event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "image-steering",
|
|
prompt: "continue",
|
|
instructions: "Inspect attached images.",
|
|
history: [],
|
|
tools: [],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
claimSteering,
|
|
},
|
|
{ signal: new AbortController().signal },
|
|
)) {
|
|
// Exhaust the runtime event stream.
|
|
}
|
|
|
|
expect(fakeAgentState.promptImages).toEqual([
|
|
[{ type: "image", data: "AQID", mimeType: "image/png" }],
|
|
]);
|
|
expect(fakeAgentState.steeredMessages).toContainEqual({
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Compare the second image." },
|
|
{ type: "image", data: "BAUG", mimeType: "image/png" },
|
|
],
|
|
timestamp: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (previousMaxToolCalls === undefined) delete process.env.MAX_TOOL_CALLS_PER_TURN;
|
|
else process.env.MAX_TOOL_CALLS_PER_TURN = previousMaxToolCalls;
|
|
});
|
|
|
|
it.each([undefined, "", ".", "/home/rakazo", "subdir"])(
|
|
"preserves executor-owned shell cwd defaults (%j)",
|
|
async (cwd) => {
|
|
const command = "printf WORKSPACE_OK > roundtrip.txt";
|
|
fakeAgentState.invoke = {
|
|
name: "shell",
|
|
args: cwd === undefined ? { command } : { command, cwd },
|
|
};
|
|
const executeTool = vi.fn(async () => ({ ok: true }));
|
|
const runtime = new PiAgentRuntime();
|
|
for await (const _event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "r",
|
|
prompt: "write the workspace test file",
|
|
instructions: "Use shell.",
|
|
history: [],
|
|
tools: [shellTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool,
|
|
},
|
|
{ signal: new AbortController().signal },
|
|
)) {
|
|
// Exercise Pi argument preparation and execution, not just path helpers.
|
|
}
|
|
expect(executeTool.mock.calls).toStrictEqual([
|
|
["shell", cwd ? { command, cwd } : { command }, "call-1"],
|
|
]);
|
|
},
|
|
);
|
|
|
|
it("exposes a provider-safe name while executing the original connector name", async () => {
|
|
const executeTool = vi.fn(async () => ({ ok: true }));
|
|
const runtime = new PiAgentRuntime();
|
|
|
|
for await (const _event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "r",
|
|
prompt: "write the result",
|
|
instructions: "Use destination_write for connected destination records.",
|
|
history: [],
|
|
tools: [destinationTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool,
|
|
},
|
|
{
|
|
operationId: "1",
|
|
traceId: "1",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
// Exhaust the runtime event stream so tool execution completes.
|
|
}
|
|
|
|
expect(fakeAgentState.tools.map((tool) => tool.name)).toEqual(["destination_write"]);
|
|
expect(executeTool).toHaveBeenCalledWith(
|
|
"destination.write",
|
|
{ collection: "notes", title: "Result", body: "Done" },
|
|
"call-1",
|
|
{ connectorId: "destination", toolName: "destination.write" },
|
|
);
|
|
});
|
|
|
|
it("does not claim a tool-only turn finished work the model never described", async () => {
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
|
|
for await (const event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "tool-only",
|
|
prompt: "send the update",
|
|
instructions: "Use the destination tool.",
|
|
history: [],
|
|
tools: [destinationTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool: vi.fn(async () => ({ ok: true })),
|
|
},
|
|
{
|
|
operationId: "tool-only",
|
|
traceId: "tool-only",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toContainEqual({
|
|
type: "progress",
|
|
text: "Using destination_write",
|
|
activity: true,
|
|
});
|
|
expect(events).not.toContainEqual({ type: "text", text: "I finished the work." });
|
|
expect(events.at(-1)).toEqual({ type: "done" });
|
|
});
|
|
|
|
it("continues after narration is followed by a silent tool result turn", async () => {
|
|
fakeAgentState.mode = "silent-continuation";
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
const onToolCompleted = vi.fn();
|
|
|
|
for await (const event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "silent-continuation",
|
|
prompt: "send the update and report back",
|
|
instructions: "Use the destination tool, then tell the user what happened.",
|
|
history: [],
|
|
tools: [destinationTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool: vi.fn(async () => ({ ok: true })),
|
|
onToolCompleted,
|
|
},
|
|
{
|
|
operationId: "silent-continuation",
|
|
traceId: "silent-continuation",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(fakeAgentState.followUpMessages).toHaveLength(1);
|
|
expect(fakeAgentState.followUpMessages[0]).toEqual(
|
|
expect.objectContaining({
|
|
role: "user",
|
|
content: expect.stringContaining("Continue"),
|
|
}),
|
|
);
|
|
expect(onToolCompleted).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "destination.write",
|
|
executionId: "call-1",
|
|
result: expect.objectContaining({ content: expect.any(Array) }),
|
|
durationMs: expect.any(Number),
|
|
}),
|
|
);
|
|
expect(events).toContainEqual({ type: "text", text: "The final answer." });
|
|
expect(events.at(-1)).toEqual({
|
|
type: "done",
|
|
text: "I will check the destination first.The final answer.",
|
|
});
|
|
});
|
|
|
|
it("does not wait for a slow tool completion hook", async () => {
|
|
let resolveAudit!: () => void;
|
|
const audit = new Promise<void>((resolve) => {
|
|
resolveAudit = resolve;
|
|
});
|
|
const onToolCompleted = vi.fn(() => audit);
|
|
let settled = false;
|
|
const runtime = new PiAgentRuntime();
|
|
const run = (async () => {
|
|
for await (const _event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "non-blocking-audit",
|
|
prompt: "send the update",
|
|
instructions: "Use the destination tool.",
|
|
history: [],
|
|
tools: [destinationTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool: vi.fn(async () => ({ ok: true })),
|
|
onToolCompleted,
|
|
},
|
|
{
|
|
operationId: "non-blocking-audit",
|
|
traceId: "non-blocking-audit",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
// Exhaust the runtime event stream.
|
|
}
|
|
})().finally(() => {
|
|
settled = true;
|
|
});
|
|
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(settled).toBe(true);
|
|
expect(onToolCompleted).toHaveBeenCalledOnce();
|
|
} finally {
|
|
resolveAudit();
|
|
await run;
|
|
}
|
|
});
|
|
|
|
it("makes an unfinished tool turn visible instead of completing silently", async () => {
|
|
fakeAgentState.mode = "silent-continuation";
|
|
fakeAgentState.emitFinalAfterFollowUp = false;
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
|
|
for await (const event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "silent-fallback",
|
|
prompt: "send the update and report back",
|
|
instructions: "Use the destination tool, then tell the user what happened.",
|
|
history: [],
|
|
tools: [destinationTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool: vi.fn(async () => ({ ok: true })),
|
|
},
|
|
{
|
|
operationId: "silent-fallback",
|
|
traceId: "silent-fallback",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toContainEqual({
|
|
type: "text",
|
|
text: "I completed the tool step but could not produce a final response. Please ask me to continue.",
|
|
});
|
|
expect(events.at(-1)).toEqual({
|
|
type: "done",
|
|
text: "I completed the tool step but could not produce a final response. Please ask me to continue.",
|
|
});
|
|
});
|
|
|
|
it("keeps FYI bot-message wakes silent when the model produces nothing", async () => {
|
|
fakeAgentState.mode = "empty";
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
|
|
for await (const event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "bot-wake-silent",
|
|
prompt: "[bot] FYI only",
|
|
instructions: "Stay silent when there is nothing to do.",
|
|
history: [],
|
|
tools: [],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
allowSilentEmpty: true,
|
|
executeTool: vi.fn(async () => ({ ok: true })),
|
|
},
|
|
{
|
|
operationId: "bot-wake-silent",
|
|
traceId: "bot-wake-silent",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).not.toContainEqual({ type: "text", text: "No response. Try again." });
|
|
expect(events).toEqual([{ type: "done" }]);
|
|
});
|
|
|
|
it("still asks the user to retry when a normal empty turn produces nothing", async () => {
|
|
fakeAgentState.mode = "empty";
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
|
|
for await (const event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "user-empty",
|
|
prompt: "hello",
|
|
instructions: "Reply to the user.",
|
|
history: [],
|
|
tools: [],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool: vi.fn(async () => ({ ok: true })),
|
|
},
|
|
{
|
|
operationId: "user-empty",
|
|
traceId: "user-empty",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toContainEqual({ type: "text", text: "No response. Try again." });
|
|
expect(events.at(-1)).toEqual({ type: "done", text: "No response. Try again." });
|
|
});
|
|
|
|
it("surfaces a contextual peer fallback when that run produces nothing", async () => {
|
|
fakeAgentState.mode = "empty";
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
|
|
for await (const event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "peer-result-empty",
|
|
prompt: "[bot] result",
|
|
instructions: "Summarize the result.",
|
|
history: [],
|
|
tools: [],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
emptyResponseText: "Update from Researcher: The answer is 42.",
|
|
executeTool: vi.fn(async () => ({ ok: true })),
|
|
},
|
|
{
|
|
operationId: "peer-result-empty",
|
|
traceId: "peer-result-empty",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toContainEqual({
|
|
type: "text",
|
|
text: "Update from Researcher: The answer is 42.",
|
|
});
|
|
expect(events.at(-1)).toEqual({
|
|
type: "done",
|
|
text: "Update from Researcher: The answer is 42.",
|
|
});
|
|
});
|
|
|
|
it("normalizes a blank contextual fallback", async () => {
|
|
fakeAgentState.mode = "empty";
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
|
|
for await (const event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "peer-result-blank",
|
|
prompt: "[bot] result",
|
|
instructions: "Summarize the result.",
|
|
history: [],
|
|
tools: [],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
emptyResponseText: " ",
|
|
executeTool: vi.fn(async () => ({ ok: true })),
|
|
},
|
|
{
|
|
operationId: "peer-result-blank",
|
|
traceId: "peer-result-blank",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events.at(-1)).toEqual({ type: "done", text: "No response. Try again." });
|
|
});
|
|
|
|
it("allows more than 80 tool calls by default when no fuse is configured", async () => {
|
|
fakeAgentState.mode = "parent-limit";
|
|
const executeTool = vi.fn(async () => ({ ok: true }));
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
|
|
for await (const event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "unlimited",
|
|
prompt: "keep going",
|
|
instructions: "Use shell.",
|
|
history: [],
|
|
tools: [shellTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool,
|
|
},
|
|
{
|
|
operationId: "2a",
|
|
traceId: "2a",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(executeTool).toHaveBeenCalledTimes(100);
|
|
expect(fakeAgentState.abortCount).toBe(0);
|
|
expect(events).not.toContainEqual(
|
|
expect.objectContaining({
|
|
type: "progress",
|
|
text: expect.stringContaining("tool calls in one turn"),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("soft-stops with a final message when MAX_TOOL_CALLS_PER_TURN is set", async () => {
|
|
process.env.MAX_TOOL_CALLS_PER_TURN = "80";
|
|
fakeAgentState.mode = "parent-limit";
|
|
const executeTool = vi.fn(async () => ({ ok: true }));
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
|
|
for await (const event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "parent-limited",
|
|
prompt: "keep going",
|
|
instructions: "Use shell.",
|
|
history: [],
|
|
tools: [shellTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool,
|
|
},
|
|
{
|
|
operationId: "2b",
|
|
traceId: "2b",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(executeTool).toHaveBeenCalledTimes(80);
|
|
expect(fakeAgentState.abortCount).toBeGreaterThanOrEqual(1);
|
|
expect(events).toContainEqual({
|
|
type: "progress",
|
|
text: "Stopped: more than 80 tool calls in one turn.",
|
|
});
|
|
expect(events).toContainEqual({
|
|
type: "text",
|
|
text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.",
|
|
});
|
|
expect(events.at(-1)).toEqual({
|
|
type: "done",
|
|
text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.",
|
|
});
|
|
});
|
|
|
|
it("shares an optional tool-call fuse with subagents and still emits a final message", async () => {
|
|
process.env.MAX_TOOL_CALLS_PER_TURN = "80";
|
|
fakeAgentState.mode = "subagent-limit";
|
|
const executeTool = vi.fn(async () => ({ ok: true }));
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
|
|
for await (const event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "limited",
|
|
prompt: "delegate the loop",
|
|
instructions: "Use run_subagent.",
|
|
history: [],
|
|
tools: [
|
|
{
|
|
name: "run_subagent",
|
|
description: "Delegate work",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
shellTool,
|
|
],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool,
|
|
},
|
|
{
|
|
operationId: "2",
|
|
traceId: "2",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(executeTool).toHaveBeenCalledTimes(79);
|
|
expect(fakeAgentState.abortCount).toBeGreaterThanOrEqual(1);
|
|
expect(events).toContainEqual({
|
|
type: "progress",
|
|
text: "Stopped: more than 80 tool calls in one turn.",
|
|
});
|
|
expect(events).toContainEqual({
|
|
type: "text",
|
|
text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.",
|
|
});
|
|
expect(events).toContainEqual({
|
|
type: "done",
|
|
text: "I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.",
|
|
});
|
|
const subagentEvents = events.filter(
|
|
(event): event is { type: "subagent"; status: string; result?: string } =>
|
|
typeof event === "object" &&
|
|
event !== null &&
|
|
"type" in event &&
|
|
(event as { type?: string }).type === "subagent",
|
|
);
|
|
expect(subagentEvents.some((event) => event.status === "failed")).toBe(false);
|
|
expect(subagentEvents).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "subagent",
|
|
status: "completed",
|
|
result:
|
|
"I stopped after reaching the limit of 80 tool calls in this turn. Send another message to continue.",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("treats unset, empty, and non-positive MAX_TOOL_CALLS_PER_TURN as unlimited", () => {
|
|
expect(maxToolCallsPerTurn({})).toBe(0);
|
|
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "" })).toBe(0);
|
|
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "0" })).toBe(0);
|
|
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "-5" })).toBe(0);
|
|
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "abc" })).toBe(0);
|
|
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: "80" })).toBe(80);
|
|
expect(maxToolCallsPerTurn({ MAX_TOOL_CALLS_PER_TURN: " 12.9 " })).toBe(12);
|
|
});
|
|
|
|
it("lets a parallel tool batch finish before the optional fuse aborts", async () => {
|
|
process.env.MAX_TOOL_CALLS_PER_TURN = "2";
|
|
fakeAgentState.mode = "parent-parallel";
|
|
const executeTool = vi.fn(async () => ({ ok: true }));
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
|
|
for await (const event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "parallel-fuse",
|
|
prompt: "run them together",
|
|
instructions: "Use shell.",
|
|
history: [],
|
|
tools: [shellTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool,
|
|
},
|
|
{
|
|
operationId: "2c",
|
|
traceId: "2c",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(executeTool).toHaveBeenCalledTimes(2);
|
|
expect(fakeAgentState.abortCount).toBeGreaterThanOrEqual(1);
|
|
expect(events).toContainEqual({
|
|
type: "progress",
|
|
text: "Stopped: more than 2 tool calls in one turn.",
|
|
});
|
|
});
|
|
|
|
it("keeps an optional tool-call fuse across a second run() for the same runId", async () => {
|
|
process.env.MAX_TOOL_CALLS_PER_TURN = "5";
|
|
fakeAgentState.mode = "parent-limit";
|
|
const executeTool = vi.fn(async () => ({ ok: true }));
|
|
const runtime = new PiAgentRuntime();
|
|
const abort = new AbortController();
|
|
let started = 0;
|
|
executeTool.mockImplementation(async () => {
|
|
started += 1;
|
|
if (started === 2) abort.abort();
|
|
return { ok: true };
|
|
});
|
|
|
|
await expect(async () => {
|
|
for await (const _event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "fuse-resume",
|
|
prompt: "keep going",
|
|
instructions: "Use shell.",
|
|
history: [],
|
|
tools: [shellTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool,
|
|
},
|
|
{
|
|
operationId: "2d",
|
|
traceId: "2d",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: abort.signal,
|
|
},
|
|
)) {
|
|
// Exhaust until abort fails the turn.
|
|
}
|
|
}).rejects.toThrow();
|
|
|
|
executeTool.mockImplementation(async () => ({ ok: true }));
|
|
const events: unknown[] = [];
|
|
for await (const event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "fuse-resume",
|
|
prompt: "keep going",
|
|
instructions: "Use shell.",
|
|
history: [],
|
|
tools: [shellTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool,
|
|
},
|
|
{
|
|
operationId: "2e",
|
|
traceId: "2e",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(executeTool).toHaveBeenCalledTimes(5);
|
|
expect(events).toContainEqual({
|
|
type: "progress",
|
|
text: "Stopped: more than 5 tool calls in one turn.",
|
|
});
|
|
});
|
|
|
|
it("keeps an optional tool-call fuse across ask_user pause and continueRun", async () => {
|
|
process.env.MAX_TOOL_CALLS_PER_TURN = "4";
|
|
fakeAgentState.mode = "ask-pause";
|
|
const executeTool = vi.fn(async () => ({ ok: true }));
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
const request = {
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "fuse-ask-pause",
|
|
prompt: "keep going",
|
|
instructions: "Use shell, then ask.",
|
|
history: [],
|
|
tools: [
|
|
shellTool,
|
|
{
|
|
name: "ask_user",
|
|
description: "Ask a short multiple-choice question",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool,
|
|
};
|
|
|
|
for await (const event of runtime.run(request, {
|
|
operationId: "2f",
|
|
traceId: "2f",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(executeTool).toHaveBeenCalledTimes(2);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "ask",
|
|
text: "Which option?",
|
|
}),
|
|
);
|
|
|
|
fakeAgentState.mode = "parent-limit";
|
|
const resumed: unknown[] = [];
|
|
for await (const event of runtime.run(
|
|
{
|
|
...request,
|
|
tools: [shellTool],
|
|
},
|
|
{
|
|
operationId: "2g",
|
|
traceId: "2g",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
resumed.push(event);
|
|
}
|
|
|
|
expect(executeTool).toHaveBeenCalledTimes(3);
|
|
expect(resumed).toContainEqual({
|
|
type: "progress",
|
|
text: "Stopped: more than 4 tool calls in one turn.",
|
|
});
|
|
});
|
|
|
|
it("keeps an optional tool-call fuse across nested ask_user pause and continueRun", async () => {
|
|
process.env.MAX_TOOL_CALLS_PER_TURN = "4";
|
|
fakeAgentState.mode = "nested-ask-pause";
|
|
const executeTool = vi.fn(async () => ({ ok: true }));
|
|
const runtime = new PiAgentRuntime();
|
|
const events: unknown[] = [];
|
|
const request = {
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "fuse-nested-ask-pause",
|
|
prompt: "delegate then ask",
|
|
instructions: "Use a subagent.",
|
|
history: [],
|
|
tools: [
|
|
{
|
|
name: "run_subagent",
|
|
description: "Delegate work",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
shellTool,
|
|
{
|
|
name: "ask_user",
|
|
description: "Ask a short multiple-choice question",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool,
|
|
};
|
|
|
|
for await (const event of runtime.run(request, {
|
|
operationId: "2h",
|
|
traceId: "2h",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
})) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(executeTool).toHaveBeenCalledTimes(2);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "ask",
|
|
text: "Which option?",
|
|
}),
|
|
);
|
|
|
|
fakeAgentState.mode = "parent-limit";
|
|
const resumed: unknown[] = [];
|
|
for await (const event of runtime.run(
|
|
{
|
|
...request,
|
|
tools: [shellTool],
|
|
},
|
|
{
|
|
operationId: "2i",
|
|
traceId: "2i",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
resumed.push(event);
|
|
}
|
|
|
|
expect(executeTool).toHaveBeenCalledTimes(2);
|
|
expect(resumed).toContainEqual({
|
|
type: "progress",
|
|
text: "Stopped: more than 4 tool calls in one turn.",
|
|
});
|
|
});
|
|
|
|
it("clips structured tool results to one aggregate text budget", async () => {
|
|
const first = "a".repeat(TOOL_RESULT_TEXT_LIMIT - 5);
|
|
const image = { type: "image" as const, data: "iVBORw0KGgo=", mimeType: "image/png" as const };
|
|
fakeAgentState.invoke = {
|
|
name: "destination_write",
|
|
args: { collection: "notes", title: "Result", body: "Done" },
|
|
};
|
|
const executeTool = vi.fn(async () => ({
|
|
kind: "agent_tool_result",
|
|
content: [
|
|
{ type: "text" as const, text: first },
|
|
image,
|
|
{ type: "text" as const, text: "bbbbbbbbbb" },
|
|
{ type: "text" as const, text: "omit me" },
|
|
],
|
|
details: { ok: true },
|
|
}));
|
|
const runtime = new PiAgentRuntime();
|
|
|
|
for await (const _event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "clip-multipart",
|
|
prompt: "write it",
|
|
instructions: "Use the destination.",
|
|
history: [],
|
|
tools: [destinationTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool,
|
|
},
|
|
{
|
|
operationId: "clip",
|
|
traceId: "2h",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
// Exhaust so the fake agent executes the tool.
|
|
}
|
|
|
|
const result = fakeAgentState.toolResult as {
|
|
content: Array<{ type: string; text?: string }>;
|
|
};
|
|
expect(result.content).toHaveLength(3);
|
|
expect(result.content[0]).toEqual({ type: "text", text: first });
|
|
expect(result.content[1]).toEqual(image);
|
|
expect(result.content[2]).toEqual({ type: "text", text: "bbbbb…" });
|
|
});
|
|
|
|
it("serialises object content instead of writing [object Object] for write_file", async () => {
|
|
fakeAgentState.invoke = {
|
|
name: "write_file",
|
|
args: { path: "shared/state.json", content: { last_run: 1_787_648_953 } },
|
|
};
|
|
const executeTool = vi.fn(async () => ({ ok: true }));
|
|
const runtime = new PiAgentRuntime();
|
|
|
|
for await (const _event of runtime.run(
|
|
{
|
|
botId: "b",
|
|
threadId: "t",
|
|
runId: "r",
|
|
prompt: "save the state file",
|
|
instructions: "Use write_file to save state.",
|
|
history: [],
|
|
tools: [writeFileTool],
|
|
model: { provider: "test", id: "dispatch-test-model" },
|
|
executeTool,
|
|
},
|
|
{
|
|
operationId: "3",
|
|
traceId: "3",
|
|
spaceId: "w",
|
|
userId: "u",
|
|
signal: new AbortController().signal,
|
|
},
|
|
)) {
|
|
// Exhaust the runtime event stream so tool execution completes.
|
|
}
|
|
|
|
expect(executeTool).toHaveBeenCalledWith(
|
|
"write_file",
|
|
{
|
|
path: "shared/state.json",
|
|
content: '{\n "last_run": 1787648953\n}',
|
|
},
|
|
"call-1",
|
|
);
|
|
});
|
|
});
|