333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import { Agent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
import { getBundledModel, type SimpleStreamOptions } from "@oh-my-pi/pi-ai";
|
|
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
|
import { Type } from "@sinclair/typebox";
|
|
import { createAssistantMessage, pushAlphaThenDoneEvent } from "./helpers";
|
|
|
|
class MockAssistantStream extends AssistantMessageEventStream {}
|
|
|
|
describe("Agent", () => {
|
|
it("should support steering message queueing", async () => {
|
|
const agent = new Agent();
|
|
|
|
const message = { role: "user" as const, content: "Queued message", timestamp: Date.now() };
|
|
agent.steer(message);
|
|
|
|
// The message is queued but not yet in state.messages
|
|
expect(agent.state.messages).not.toContainEqual(message);
|
|
});
|
|
|
|
it("continue() should process queued follow-up messages after an assistant turn", async () => {
|
|
const agent = new Agent({
|
|
streamFn: () => {
|
|
const stream = new MockAssistantStream();
|
|
queueMicrotask(() => {
|
|
stream.push({
|
|
type: "done",
|
|
reason: "stop",
|
|
message: createAssistantMessage([{ type: "text", text: "Processed" }]),
|
|
});
|
|
});
|
|
return stream;
|
|
},
|
|
});
|
|
|
|
agent.replaceMessages([
|
|
{
|
|
role: "user",
|
|
content: [{ type: "text", text: "Initial" }],
|
|
timestamp: Date.now() - 10,
|
|
},
|
|
createAssistantMessage([{ type: "text", text: "Initial response" }]),
|
|
]);
|
|
|
|
agent.followUp({
|
|
role: "user",
|
|
content: [{ type: "text", text: "Queued follow-up" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
await expect(agent.continue()).resolves.toBeUndefined();
|
|
|
|
const hasQueuedFollowUp = agent.state.messages.some(message => {
|
|
if (message.role !== "user") return false;
|
|
if (typeof message.content === "string") return message.content === "Queued follow-up";
|
|
return message.content.some(part => part.type === "text" && part.text === "Queued follow-up");
|
|
});
|
|
|
|
expect(hasQueuedFollowUp).toBe(true);
|
|
expect(agent.state.messages[agent.state.messages.length - 1].role).toBe("assistant");
|
|
});
|
|
|
|
it("continue() should keep one-at-a-time steering semantics from assistant tail", async () => {
|
|
let responseCount = 0;
|
|
const agent = new Agent({
|
|
streamFn: () => {
|
|
const stream = new MockAssistantStream();
|
|
responseCount++;
|
|
queueMicrotask(() => {
|
|
stream.push({
|
|
type: "done",
|
|
reason: "stop",
|
|
message: createAssistantMessage([{ type: "text", text: `Processed ${responseCount}` }]),
|
|
});
|
|
});
|
|
return stream;
|
|
},
|
|
});
|
|
|
|
agent.replaceMessages([
|
|
{
|
|
role: "user",
|
|
content: [{ type: "text", text: "Initial" }],
|
|
timestamp: Date.now() - 10,
|
|
},
|
|
createAssistantMessage([{ type: "text", text: "Initial response" }]),
|
|
]);
|
|
|
|
agent.steer({
|
|
role: "user",
|
|
content: [{ type: "text", text: "Steering 1" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
agent.steer({
|
|
role: "user",
|
|
content: [{ type: "text", text: "Steering 2" }],
|
|
timestamp: Date.now() + 1,
|
|
});
|
|
|
|
await expect(agent.continue()).resolves.toBeUndefined();
|
|
|
|
const recentMessages = agent.state.messages.slice(-4);
|
|
expect(recentMessages.map(m => m.role)).toEqual(["user", "assistant", "user", "assistant"]);
|
|
expect(responseCount).toBe(2);
|
|
});
|
|
|
|
it("prompt() refreshes tools and system prompt between same-turn model calls", async () => {
|
|
const toolSchema = Type.Object({ value: Type.String() });
|
|
type Details = { value: string };
|
|
let callIndex = 0;
|
|
const callContexts: Array<{ systemPrompt: string; toolNames: string[] }> = [];
|
|
|
|
const betaTool: AgentTool<typeof toolSchema, Details> = {
|
|
name: "beta",
|
|
label: "Beta",
|
|
description: "Beta tool",
|
|
parameters: toolSchema,
|
|
async execute(_toolCallId, params) {
|
|
return { content: [{ type: "text", text: `beta:${params.value}` }], details: { value: params.value } };
|
|
},
|
|
};
|
|
const alphaTool: AgentTool<typeof toolSchema, Details> = {
|
|
name: "alpha",
|
|
label: "Alpha",
|
|
description: "Alpha tool",
|
|
parameters: toolSchema,
|
|
async execute(_toolCallId, params) {
|
|
return { content: [{ type: "text", text: `alpha:${params.value}` }], details: { value: params.value } };
|
|
},
|
|
};
|
|
|
|
const agent = new Agent({
|
|
initialState: {
|
|
model: getBundledModel("openai", "gpt-4o-mini"),
|
|
systemPrompt: ["prompt-one"],
|
|
tools: [alphaTool],
|
|
messages: [],
|
|
},
|
|
streamFn: (_model, context) => {
|
|
callContexts.push({
|
|
systemPrompt: context.systemPrompt?.join("\n\n") ?? "",
|
|
toolNames: (context.tools ?? []).map(tool => tool.name),
|
|
});
|
|
const stream = new MockAssistantStream();
|
|
queueMicrotask(() => {
|
|
pushAlphaThenDoneEvent(stream, callIndex, createAssistantMessage);
|
|
callIndex += 1;
|
|
});
|
|
return stream;
|
|
},
|
|
});
|
|
|
|
const unsubscribe = agent.subscribe(event => {
|
|
if (event.type === "message_end" && event.message.role === "toolResult") {
|
|
agent.setSystemPrompt(["prompt-two"]);
|
|
agent.setTools([alphaTool, betaTool]);
|
|
}
|
|
});
|
|
|
|
await agent.prompt("refresh tools");
|
|
unsubscribe();
|
|
|
|
expect(callContexts).toEqual([
|
|
{ systemPrompt: "prompt-one", toolNames: ["alpha"] },
|
|
{ systemPrompt: "prompt-two", toolNames: ["alpha", "beta"] },
|
|
]);
|
|
});
|
|
|
|
it("prompt() drops stale forced toolChoice after same-turn tool refresh", async () => {
|
|
const toolSchema = Type.Object({ value: Type.String() });
|
|
type Details = { value: string };
|
|
let callIndex = 0;
|
|
const providerCalls: Array<{ toolNames: string[]; toolChoice: SimpleStreamOptions["toolChoice"] }> = [];
|
|
|
|
const betaTool: AgentTool<typeof toolSchema, Details> = {
|
|
name: "beta",
|
|
label: "Beta",
|
|
description: "Beta tool",
|
|
parameters: toolSchema,
|
|
async execute(_toolCallId, params) {
|
|
return { content: [{ type: "text", text: `beta:${params.value}` }], details: { value: params.value } };
|
|
},
|
|
};
|
|
const alphaTool: AgentTool<typeof toolSchema, Details> = {
|
|
name: "alpha",
|
|
label: "Alpha",
|
|
description: "Alpha tool",
|
|
parameters: toolSchema,
|
|
async execute(_toolCallId, params) {
|
|
return { content: [{ type: "text", text: `alpha:${params.value}` }], details: { value: params.value } };
|
|
},
|
|
};
|
|
|
|
const agent = new Agent({
|
|
initialState: {
|
|
model: getBundledModel("openai", "gpt-4o-mini"),
|
|
tools: [alphaTool],
|
|
messages: [],
|
|
},
|
|
streamFn: (_model, context, options) => {
|
|
providerCalls.push({
|
|
toolNames: (context.tools ?? []).map(tool => tool.name),
|
|
toolChoice: options?.toolChoice,
|
|
});
|
|
const stream = new MockAssistantStream();
|
|
queueMicrotask(() => {
|
|
pushAlphaThenDoneEvent(stream, callIndex, createAssistantMessage);
|
|
callIndex += 1;
|
|
});
|
|
return stream;
|
|
},
|
|
});
|
|
|
|
const unsubscribe = agent.subscribe(event => {
|
|
if (event.type === "message_end" && event.message.role === "toolResult") {
|
|
agent.setTools([betaTool]);
|
|
}
|
|
});
|
|
|
|
await agent.prompt("refresh tools", { toolChoice: { type: "function", name: "alpha" } });
|
|
unsubscribe();
|
|
|
|
expect(providerCalls).toEqual([
|
|
{ toolNames: ["alpha"], toolChoice: { type: "function", name: "alpha" } },
|
|
{ toolNames: ["beta"], toolChoice: undefined },
|
|
]);
|
|
});
|
|
|
|
it("re-reads thinking level for each model call within a run", async () => {
|
|
const toolSchema = Type.Object({ value: Type.String() });
|
|
type Details = { value: string };
|
|
const alphaTool: AgentTool<typeof toolSchema, Details> = {
|
|
name: "alpha",
|
|
label: "Alpha",
|
|
description: "Alpha tool",
|
|
parameters: toolSchema,
|
|
async execute(_toolCallId, params) {
|
|
return { content: [{ type: "text", text: `alpha:${params.value}` }], details: { value: params.value } };
|
|
},
|
|
};
|
|
|
|
let callIndex = 0;
|
|
const reasoningPerCall: Array<SimpleStreamOptions["reasoning"]> = [];
|
|
|
|
const agent = new Agent({
|
|
initialState: {
|
|
model: getBundledModel("openai", "gpt-4o-mini"),
|
|
thinkingLevel: ThinkingLevel.Low,
|
|
tools: [alphaTool],
|
|
messages: [],
|
|
},
|
|
streamFn: (_model, _context, options) => {
|
|
reasoningPerCall.push(options?.reasoning);
|
|
const stream = new MockAssistantStream();
|
|
queueMicrotask(() => {
|
|
pushAlphaThenDoneEvent(stream, callIndex, createAssistantMessage);
|
|
callIndex += 1;
|
|
});
|
|
return stream;
|
|
},
|
|
});
|
|
|
|
// Bump thinking level mid-run, after the first assistant turn finishes
|
|
// and before the second model call (which follows the tool result).
|
|
const unsubscribe = agent.subscribe(event => {
|
|
if (event.type === "message_end" && event.message.role === "toolResult") {
|
|
agent.setThinkingLevel(ThinkingLevel.High);
|
|
}
|
|
});
|
|
|
|
await agent.prompt("run");
|
|
unsubscribe();
|
|
|
|
expect(reasoningPerCall).toEqual([ThinkingLevel.Low, ThinkingLevel.High]);
|
|
});
|
|
|
|
it("returns static metadata via the plain setter", () => {
|
|
const agent = new Agent();
|
|
expect(agent.metadata).toBeUndefined();
|
|
|
|
const value = { user_id: "static" };
|
|
agent.metadata = value;
|
|
expect(agent.metadata).toEqual({ user_id: "static" });
|
|
|
|
agent.metadata = undefined;
|
|
expect(agent.metadata).toBeUndefined();
|
|
});
|
|
|
|
it("metadataForProvider resolves dynamic value at every call when a resolver is installed", () => {
|
|
const agent = new Agent();
|
|
let live = "alpha";
|
|
agent.setMetadataResolver(() => ({ user_id: live }));
|
|
|
|
expect(agent.metadataForProvider("anthropic")).toEqual({ user_id: "alpha" });
|
|
live = "beta";
|
|
expect(agent.metadataForProvider("anthropic")).toEqual({ user_id: "beta" });
|
|
// Static getter is unaffected by the resolver.
|
|
expect(agent.metadata).toBeUndefined();
|
|
});
|
|
|
|
it("clears any installed resolver when assigning the plain setter", () => {
|
|
const agent = new Agent();
|
|
agent.setMetadataResolver(() => ({ user_id: "from-resolver" }));
|
|
expect(agent.metadataForProvider("any")).toEqual({ user_id: "from-resolver" });
|
|
|
|
agent.metadata = { user_id: "from-static" };
|
|
expect(agent.metadata).toEqual({ user_id: "from-static" });
|
|
expect(agent.metadataForProvider("any")).toEqual({ user_id: "from-static" });
|
|
});
|
|
|
|
it("metadataForProvider returns undefined from the resolver even when a static value is set", () => {
|
|
// Pin the contract that an installed resolver wins unconditionally over
|
|
// `#metadata` in the per-provider path.
|
|
const agent = new Agent();
|
|
agent.metadata = { user_id: "static" };
|
|
agent.setMetadataResolver(() => undefined);
|
|
expect(agent.metadataForProvider("any")).toBeUndefined();
|
|
// The static getter returns the pre-set static value; the resolver does not affect it.
|
|
expect(agent.metadata).toEqual({ user_id: "static" });
|
|
});
|
|
|
|
it("reverts to the plain-setter value when the resolver is cleared via setMetadataResolver(undefined)", () => {
|
|
const agent = new Agent();
|
|
agent.metadata = { user_id: "static" };
|
|
agent.setMetadataResolver(() => ({ user_id: "from-resolver" }));
|
|
expect(agent.metadataForProvider("any")).toEqual({ user_id: "from-resolver" });
|
|
|
|
agent.setMetadataResolver(undefined);
|
|
expect(agent.metadataForProvider("any")).toEqual({ user_id: "static" });
|
|
expect(agent.metadata).toEqual({ user_id: "static" });
|
|
});
|
|
});
|