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:
535
third_party/pi/packages/agent/test/agent.test.ts
vendored
535
third_party/pi/packages/agent/test/agent.test.ts
vendored
@@ -1,4 +1,11 @@
|
||||
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
createAssistantMessageEventStream,
|
||||
getCurrentSystemMessage,
|
||||
getModel,
|
||||
toToolDeclaration,
|
||||
type UserMessage,
|
||||
} from "@earendil-works/pi-ai/compat";
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -10,18 +17,8 @@ import {
|
||||
setDefaultStreamFn,
|
||||
} from "../src/index.ts";
|
||||
|
||||
// Mock stream that mimics AssistantMessageEventStream
|
||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
constructor() {
|
||||
super(
|
||||
(event) => event.type === "done" || event.type === "error",
|
||||
(event) => {
|
||||
if (event.type === "done") return event.message;
|
||||
if (event.type === "error") return event.error;
|
||||
throw new Error("Unexpected event type");
|
||||
},
|
||||
);
|
||||
}
|
||||
function createUserMessage(text: string): UserMessage {
|
||||
return { role: "user", content: text, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
function createAssistantMessage(text: string): AssistantMessage {
|
||||
@@ -46,6 +43,16 @@ function createAssistantMessage(text: string): AssistantMessage {
|
||||
|
||||
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
||||
|
||||
function createTool(name: string): AgentTool {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} tool`,
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: name }], details: {} }),
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistantToolUseMessage(content: ToolCallContent[]): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
@@ -86,7 +93,7 @@ describe("Agent", () => {
|
||||
let calls = 0;
|
||||
setDefaultStreamFn(() => {
|
||||
calls++;
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
const message = createAssistantMessage("fallback");
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
@@ -107,7 +114,6 @@ describe("Agent", () => {
|
||||
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||
|
||||
expect(agent.state).toBeDefined();
|
||||
expect(agent.state.systemPrompt).toBe("");
|
||||
expect(agent.state.model).toBeDefined();
|
||||
expect(agent.state.thinkingLevel).toBe("off");
|
||||
expect(agent.state.tools).toEqual([]);
|
||||
@@ -129,11 +135,176 @@ describe("Agent", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(agent.state.systemPrompt).toBe("You are a helpful assistant.");
|
||||
expect(agent.state.messages).toEqual([{ role: "system", content: "You are a helpful assistant.", timestamp: 0 }]);
|
||||
expect(agent.state.model).toBe(customModel);
|
||||
expect(agent.state.thinkingLevel).toBe("low");
|
||||
});
|
||||
|
||||
it("converts initial prompt and tools into transcript state", () => {
|
||||
const tool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo input",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
|
||||
};
|
||||
const agent = new Agent({
|
||||
initialState: { systemPrompt: "You are helpful.", tools: [tool] },
|
||||
streamFn: unusedStreamFunction,
|
||||
});
|
||||
|
||||
const initial = agent.state.messages[0];
|
||||
expect(initial?.role).toBe("system");
|
||||
if (initial?.role !== "system") throw new Error("expected initial system message");
|
||||
expect(initial.content).toBe("You are helpful.");
|
||||
expect(initial.toolsAdded?.map((value) => value.name)).toEqual(["echo"]);
|
||||
});
|
||||
|
||||
it("declares tool loadout changes to the model before the next request", async () => {
|
||||
const first = createTool("first");
|
||||
const second = createTool("second");
|
||||
const requests: string[][] = [];
|
||||
const agent = new Agent({
|
||||
initialState: { systemPrompt: "You are helpful.", tools: [first] },
|
||||
streamFn: (_model, context) => {
|
||||
requests.push(
|
||||
context.messages.flatMap((message) =>
|
||||
message.role === "system"
|
||||
? [
|
||||
`+${(message.toolsAdded ?? []).map((tool) => tool.name).join(",")}`,
|
||||
`-${(message.toolsRemoved ?? []).map((tool) => tool.name).join(",")}`,
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
await agent.prompt("one");
|
||||
agent.state.tools = [second];
|
||||
await agent.prompt("two");
|
||||
await agent.prompt("three");
|
||||
|
||||
expect(requests).toEqual([
|
||||
["+first", "-"],
|
||||
["+first", "-", "+second", "-first"],
|
||||
["+first", "-", "+second", "-first"],
|
||||
]);
|
||||
const update = agent.state.messages.find((message) => message.role === "system" && message.toolsRemoved);
|
||||
expect(update).toEqual({
|
||||
role: "system",
|
||||
content: "",
|
||||
toolsAdded: [{ name: "second", description: "second tool", parameters: Type.Object({}) }],
|
||||
toolsRemoved: [{ name: "first" }],
|
||||
timestamp: expect.any(Number),
|
||||
});
|
||||
const initial = agent.state.messages[0];
|
||||
if (initial?.role !== "system") throw new Error("expected initial system message");
|
||||
expect(initial.toolsAdded?.[0]).not.toHaveProperty("execute");
|
||||
});
|
||||
|
||||
it("merges tool changes into a pending system message", async () => {
|
||||
const tool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo input",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
|
||||
};
|
||||
const agent = new Agent({
|
||||
initialState: { systemPrompt: "You are helpful." },
|
||||
streamFn: (_model, context) => {
|
||||
expect(context.messages.filter((message) => message.role === "system")).toHaveLength(2);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
agent.state.tools = [tool];
|
||||
await agent.prompt([
|
||||
{ role: "system", content: "", sections: { skills: "<skills>x</skills>" }, timestamp: 1 },
|
||||
{ role: "user", content: "hi", timestamp: 2 },
|
||||
]);
|
||||
|
||||
expect(agent.state.messages[1]).toEqual({
|
||||
role: "system",
|
||||
content: "",
|
||||
sections: { skills: "<skills>x</skills>" },
|
||||
toolsAdded: [{ name: "echo", description: "Echo input", parameters: Type.Object({}) }],
|
||||
timestamp: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites pending tool declarations to match the executable set", async () => {
|
||||
const agent = new Agent({
|
||||
initialState: { systemPrompt: "You are helpful.", tools: [createTool("first")] },
|
||||
streamFn: () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
// The pending message claims to add `second` and remove `first`, but the executable
|
||||
// set still has `first` and lacks `second`: the executable set wins.
|
||||
await agent.prompt([
|
||||
{
|
||||
role: "system",
|
||||
content: "",
|
||||
sections: { note: "<note>x</note>" },
|
||||
toolsAdded: [toToolDeclaration(createTool("second"))],
|
||||
toolsRemoved: [{ name: "first" }],
|
||||
timestamp: 1,
|
||||
},
|
||||
{ role: "user", content: "hi", timestamp: 2 },
|
||||
]);
|
||||
|
||||
expect(agent.state.messages[1]).toEqual({
|
||||
role: "system",
|
||||
content: "",
|
||||
sections: { note: "<note>x</note>" },
|
||||
timestamp: 1,
|
||||
});
|
||||
expect(getCurrentSystemMessage(agent.state.messages)?.toolsAdded?.map((tool) => tool.name)).toEqual(["first"]);
|
||||
});
|
||||
|
||||
it("restores the transcript baseline when reset", () => {
|
||||
const tool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo input",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
|
||||
};
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [tool],
|
||||
messages: [{ role: "user", content: "old", timestamp: 1 }],
|
||||
},
|
||||
streamFn: unusedStreamFunction,
|
||||
});
|
||||
|
||||
agent.reset();
|
||||
|
||||
expect(agent.state.messages).toHaveLength(1);
|
||||
const initial = agent.state.messages[0];
|
||||
expect(initial?.role).toBe("system");
|
||||
if (initial?.role !== "system") throw new Error("expected initial system message");
|
||||
expect(initial.content).toBe("You are helpful.");
|
||||
expect(initial.toolsAdded?.map((value) => value.name)).toEqual(["echo"]);
|
||||
});
|
||||
|
||||
it("should subscribe to events", () => {
|
||||
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||
|
||||
@@ -146,13 +317,13 @@ describe("Agent", () => {
|
||||
expect(eventCount).toBe(0);
|
||||
|
||||
// State mutators don't emit events
|
||||
agent.state.systemPrompt = "Test prompt";
|
||||
agent.state.thinkingLevel = "low";
|
||||
expect(eventCount).toBe(0);
|
||||
expect(agent.state.systemPrompt).toBe("Test prompt");
|
||||
expect(agent.state.thinkingLevel).toBe("low");
|
||||
|
||||
// Unsubscribe should work
|
||||
unsubscribe();
|
||||
agent.state.systemPrompt = "Another prompt";
|
||||
agent.state.thinkingLevel = "high";
|
||||
expect(eventCount).toBe(0); // Should not increase
|
||||
});
|
||||
|
||||
@@ -191,7 +362,7 @@ describe("Agent", () => {
|
||||
const barrier = createDeferred();
|
||||
const agent = new Agent({
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
|
||||
});
|
||||
@@ -229,7 +400,7 @@ describe("Agent", () => {
|
||||
const barrier = createDeferred();
|
||||
const agent = new Agent({
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
|
||||
});
|
||||
@@ -264,7 +435,7 @@ describe("Agent", () => {
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
const agent = new Agent({
|
||||
streamFn: (_model, _context, options) => {
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
const checkAbort = () => {
|
||||
@@ -327,7 +498,7 @@ describe("Agent", () => {
|
||||
const agent = new Agent({
|
||||
initialState: { tools: [tool] },
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
type: "done",
|
||||
@@ -402,7 +573,7 @@ describe("Agent", () => {
|
||||
const agent = new Agent({
|
||||
initialState: { tools: [settledTool, slowTool] },
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
type: "done",
|
||||
@@ -442,10 +613,6 @@ describe("Agent", () => {
|
||||
it("should update state with mutators", () => {
|
||||
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||
|
||||
// Test setSystemPrompt
|
||||
agent.state.systemPrompt = "Custom prompt";
|
||||
expect(agent.state.systemPrompt).toBe("Custom prompt");
|
||||
|
||||
// Test setModel
|
||||
const newModel = getModel("google", "gemini-2.5-flash");
|
||||
agent.state.model = newModel;
|
||||
@@ -510,7 +677,7 @@ describe("Agent", () => {
|
||||
const releaseResponse = createDeferred();
|
||||
const agent = new Agent({
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(async () => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
streamStarted.resolve();
|
||||
@@ -545,7 +712,7 @@ describe("Agent", () => {
|
||||
// Use a stream function that responds to abort
|
||||
streamFn: (_model, _context, options) => {
|
||||
abortSignal = options?.signal;
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
// Check abort signal periodically
|
||||
@@ -584,7 +751,7 @@ describe("Agent", () => {
|
||||
const agent = new Agent({
|
||||
streamFn: (_model, _context, options) => {
|
||||
abortSignal = options?.signal;
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
const checkAbort = () => {
|
||||
@@ -618,7 +785,7 @@ describe("Agent", () => {
|
||||
it("continue() should process queued follow-up messages after an assistant turn", async () => {
|
||||
const agent = new Agent({
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") });
|
||||
});
|
||||
@@ -653,48 +820,40 @@ describe("Agent", () => {
|
||||
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;
|
||||
it.each([
|
||||
{ mode: "one-at-a-time" as const, expectedRequests: 2 },
|
||||
{ mode: "all" as const, expectedRequests: 1 },
|
||||
])("continue() keeps $mode steering semantics for assistant-tail fallback", async ({ mode, expectedRequests }) => {
|
||||
const requests: string[][] = [];
|
||||
const agent = new Agent({
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
responseCount++;
|
||||
steeringMode: mode,
|
||||
streamFn: (_model, context) => {
|
||||
requests.push(
|
||||
context.messages.flatMap((message) =>
|
||||
message.role === "user" && typeof message.content === "string" ? [message.content] : [],
|
||||
),
|
||||
);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: createAssistantMessage(`Processed ${responseCount}`),
|
||||
});
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
agent.state.messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Initial" }],
|
||||
timestamp: Date.now() - 10,
|
||||
},
|
||||
createAssistantMessage("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,
|
||||
});
|
||||
agent.state.messages = [createUserMessage("Initial"), createAssistantMessage("Initial response")];
|
||||
agent.steer(createUserMessage("Steering 1"));
|
||||
agent.steer(createUserMessage("Steering 2"));
|
||||
|
||||
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);
|
||||
expect(requests).toHaveLength(expectedRequests);
|
||||
expect(requests[0]).toContain("Steering 1");
|
||||
if (mode === "one-at-a-time") {
|
||||
expect(requests[0]).not.toContain("Steering 2");
|
||||
expect(requests[1]).toContain("Steering 2");
|
||||
} else {
|
||||
expect(requests[0]).toContain("Steering 2");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps legacy prepareNextTurn signal callback behavior", async () => {
|
||||
@@ -716,7 +875,7 @@ describe("Agent", () => {
|
||||
},
|
||||
streamFn: () => {
|
||||
requestCount++;
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
if (requestCount === 1) {
|
||||
const message = createAssistantToolUseMessage([
|
||||
@@ -738,7 +897,7 @@ describe("Agent", () => {
|
||||
expect(sawAbortSignal).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards shouldStopAfterTurn through AgentOptions", async () => {
|
||||
it("forwards finishTurn through AgentOptions with the active abort signal", async () => {
|
||||
const schema = Type.Object({});
|
||||
const tool: AgentTool<typeof schema> = {
|
||||
name: "noop",
|
||||
@@ -752,14 +911,14 @@ describe("Agent", () => {
|
||||
let callbackContextRoles: string[] = [];
|
||||
const agent = new Agent({
|
||||
initialState: { tools: [tool] },
|
||||
shouldStopAfterTurn: (context, signal) => {
|
||||
finishTurn: (context, signal) => {
|
||||
sawAbortSignal = signal instanceof AbortSignal;
|
||||
callbackContextRoles = context.context.messages.map((message) => message.role);
|
||||
return true;
|
||||
return { action: "end" };
|
||||
},
|
||||
streamFn: () => {
|
||||
requestCount++;
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
if (requestCount === 1) {
|
||||
const message = createAssistantToolUseMessage([
|
||||
@@ -779,7 +938,239 @@ describe("Agent", () => {
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
expect(sawAbortSignal).toBe(true);
|
||||
expect(callbackContextRoles).toEqual(["user", "assistant", "toolResult"]);
|
||||
expect(callbackContextRoles).toEqual(["system", "user", "assistant", "toolResult"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "empty", messages: [] },
|
||||
{ name: "system-only", messages: [{ role: "system" as const, content: "system only", timestamp: 1 }] },
|
||||
])("rejects a queued continuation from $name context without draining queues", async ({ messages }) => {
|
||||
const agent = new Agent({ initialState: { messages }, streamFn: unusedStreamFunction });
|
||||
const steering = createUserMessage("steering");
|
||||
const followUp = createUserMessage("follow-up");
|
||||
agent.steer(steering);
|
||||
agent.followUp(followUp);
|
||||
|
||||
await expect(agent.continue()).rejects.toThrow("No messages to continue from");
|
||||
expect(agent.peekQueuedMessages()).toEqual([steering]);
|
||||
agent.clearSteeringQueue();
|
||||
expect(agent.peekQueuedMessages()).toEqual([followUp]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "user",
|
||||
messages: [createUserMessage("existing user")],
|
||||
},
|
||||
{
|
||||
name: "toolResult",
|
||||
messages: [
|
||||
createUserMessage("existing user"),
|
||||
createAssistantToolUseMessage([{ type: "toolCall", id: "call-1", name: "noop", arguments: {} }]),
|
||||
{
|
||||
role: "toolResult" as const,
|
||||
toolCallId: "call-1",
|
||||
toolName: "noop",
|
||||
content: [{ type: "text" as const, text: "done" }],
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
])("defers follow-up input on the first continuation request from a $name tail", async ({ messages }) => {
|
||||
const requests: string[][] = [];
|
||||
const agent = new Agent({
|
||||
initialState: { messages },
|
||||
streamFn: (_model, context) => {
|
||||
requests.push(
|
||||
context.messages.flatMap((message) =>
|
||||
message.role === "user" && typeof message.content === "string" ? [message.content] : [],
|
||||
),
|
||||
);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() =>
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") }),
|
||||
);
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
agent.followUp(createUserMessage("follow-up"));
|
||||
|
||||
await agent.continue();
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0]).not.toContain("follow-up");
|
||||
expect(requests[1]).toContain("follow-up");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mode: "one-at-a-time" as const, expectedRequests: 2 },
|
||||
{ mode: "all" as const, expectedRequests: 1 },
|
||||
])("polls $mode steering at continuation startup", async ({ mode, expectedRequests }) => {
|
||||
const requests: string[][] = [];
|
||||
const agent = new Agent({
|
||||
initialState: { messages: [createUserMessage("existing")] },
|
||||
steeringMode: mode,
|
||||
streamFn: (_model, context) => {
|
||||
requests.push(
|
||||
context.messages.flatMap((message) =>
|
||||
message.role === "user" && typeof message.content === "string" ? [message.content] : [],
|
||||
),
|
||||
);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() =>
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") }),
|
||||
);
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
agent.steer(createUserMessage("first"));
|
||||
agent.steer(createUserMessage("second"));
|
||||
|
||||
await agent.continue();
|
||||
|
||||
expect(requests).toHaveLength(expectedRequests);
|
||||
expect(requests[0]).toContain("first");
|
||||
if (mode === "one-at-a-time") {
|
||||
expect(requests[0]).not.toContain("second");
|
||||
expect(requests[1]).toContain("second");
|
||||
} else {
|
||||
expect(requests[0]).toContain("second");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps steering ahead of follow-up from a non-assistant continuation tail", async () => {
|
||||
const requests: string[][] = [];
|
||||
const agent = new Agent({
|
||||
initialState: { messages: [createUserMessage("existing")] },
|
||||
streamFn: (_model, context) => {
|
||||
requests.push(
|
||||
context.messages.flatMap((message) =>
|
||||
message.role === "user" && typeof message.content === "string" ? [message.content] : [],
|
||||
),
|
||||
);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() =>
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") }),
|
||||
);
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
agent.steer(createUserMessage("steering"));
|
||||
agent.followUp(createUserMessage("follow-up"));
|
||||
|
||||
await agent.continue();
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0]).toContain("steering");
|
||||
expect(requests[0]).not.toContain("follow-up");
|
||||
expect(requests[1]).toContain("follow-up");
|
||||
});
|
||||
|
||||
it.each(["error", "aborted"] as const)(
|
||||
"keeps queues on a %s response even when finishTurn requests continuation",
|
||||
async (stopReason) => {
|
||||
const queuedDuringResponse = createUserMessage("steering");
|
||||
const followUp = createUserMessage("follow-up");
|
||||
const agent = new Agent({
|
||||
finishTurn: () => ({ action: "continue" }),
|
||||
streamFn: () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason: stopReason,
|
||||
error: {
|
||||
...createAssistantMessage(stopReason),
|
||||
stopReason,
|
||||
errorMessage: stopReason,
|
||||
},
|
||||
});
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
agent.followUp(followUp);
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "message_end" && event.message.role === "assistant") {
|
||||
agent.steer(queuedDuringResponse);
|
||||
}
|
||||
});
|
||||
|
||||
await agent.prompt("start");
|
||||
|
||||
expect(agent.peekQueuedMessages()).toEqual([queuedDuringResponse]);
|
||||
agent.clearSteeringQueue();
|
||||
expect(agent.peekQueuedMessages()).toEqual([followUp]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps queues when finishTurn ends the run", async () => {
|
||||
const queuedDuringResponse = createUserMessage("steering");
|
||||
const followUp = createUserMessage("follow-up");
|
||||
const agent = new Agent({
|
||||
finishTurn: () => ({ action: "end" }),
|
||||
streamFn: () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() =>
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") }),
|
||||
);
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
agent.followUp(followUp);
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "message_end" && event.message.role === "assistant") {
|
||||
agent.steer(queuedDuringResponse);
|
||||
}
|
||||
});
|
||||
|
||||
await agent.prompt("start");
|
||||
|
||||
expect(agent.peekQueuedMessages()).toEqual([queuedDuringResponse]);
|
||||
agent.clearSteeringQueue();
|
||||
expect(agent.peekQueuedMessages()).toEqual([followUp]);
|
||||
});
|
||||
|
||||
it("previews the next selected queued messages without consuming them", () => {
|
||||
const agent = new Agent({
|
||||
steeringMode: "one-at-a-time",
|
||||
followUpMode: "all",
|
||||
streamFn: () => createAssistantMessageEventStream(),
|
||||
});
|
||||
const first = createUserMessage("first steering");
|
||||
const second = createUserMessage("second steering");
|
||||
const followUp = createUserMessage("follow-up");
|
||||
agent.steer(first);
|
||||
agent.steer(second);
|
||||
agent.followUp(followUp);
|
||||
|
||||
expect(agent.peekQueuedMessages()).toEqual([first]);
|
||||
expect(agent.peekQueuedMessages()).toEqual([first]);
|
||||
agent.clearSteeringQueue();
|
||||
expect(agent.peekQueuedMessages()).toEqual([followUp]);
|
||||
});
|
||||
|
||||
it("forwards provider stream event observers through AgentOptions", async () => {
|
||||
const providerEvents: unknown[] = [];
|
||||
const agent = new Agent({
|
||||
onProviderStreamEvent: (data) => {
|
||||
providerEvents.push(data);
|
||||
},
|
||||
streamFn: (model, _context, options) => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(async () => {
|
||||
await options?.onProviderStreamEvent?.({ request_cost: 0.01 }, model);
|
||||
const message = createAssistantMessage("ok");
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
await agent.prompt("hello");
|
||||
|
||||
expect(providerEvents).toEqual([{ request_cost: 0.01 }]);
|
||||
});
|
||||
|
||||
it("forwards sessionId to streamFunction options", async () => {
|
||||
@@ -788,7 +1179,7 @@ describe("Agent", () => {
|
||||
sessionId: "session-abc",
|
||||
streamFn: (_model, _context, options) => {
|
||||
receivedSessionId = options?.sessionId;
|
||||
const stream = new MockAssistantStream();
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
const message = createAssistantMessage("ok");
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
|
||||
Reference in New Issue
Block a user