- Added ModelManager API with createModelManager() factory for managing bundled and dynamically discovered models with configurable refresh strategies. - Exported discovery utilities for fetching models from Antigravity, Codex, Cursor, Gemini, and OpenAI-compatible endpoints with provider-specific model manager configuration helpers. - Renamed public API functions for clarity: getModel() -> getBundledModel(), getModels() -> getBundledModels(), getProviders() -> getBundledProviders(). - Added on-disk model caching with TTL-based invalidation and resolveProviderModels() function for runtime model resolution with source precedence. - Refactored model discovery script to dynamically fetch models from Codex, Cursor, and Antigravity using OAuth credentials instead of hardcoded lists.
260 lines
7.5 KiB
TypeScript
260 lines
7.5 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import { Agent } from "@oh-my-pi/pi-agent-core";
|
|
import { type AssistantMessage, getBundledModel, type ThinkingBudgets, type Usage } from "@oh-my-pi/pi-ai";
|
|
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
|
|
|
class MockAssistantStream extends AssistantMessageEventStream {}
|
|
|
|
function createUsage(): Usage {
|
|
return {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 0,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
};
|
|
}
|
|
|
|
function createAssistantMessage(
|
|
content: AssistantMessage["content"],
|
|
stopReason: AssistantMessage["stopReason"] = "stop",
|
|
): AssistantMessage {
|
|
return {
|
|
role: "assistant",
|
|
content,
|
|
api: "openai-responses",
|
|
provider: "openai",
|
|
model: "mock",
|
|
usage: createUsage(),
|
|
stopReason,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
describe("Agent", () => {
|
|
it("should create an agent instance with default state", () => {
|
|
const agent = new Agent();
|
|
|
|
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([]);
|
|
expect(agent.state.messages).toEqual([]);
|
|
expect(agent.state.isStreaming).toBe(false);
|
|
expect(agent.state.streamMessage).toBe(null);
|
|
expect(agent.state.pendingToolCalls).toEqual(new Set());
|
|
expect(agent.state.error).toBeUndefined();
|
|
});
|
|
|
|
it("should create an agent instance with custom initial state", () => {
|
|
const customModel = getBundledModel("openai", "gpt-4o-mini");
|
|
const agent = new Agent({
|
|
initialState: {
|
|
systemPrompt: "You are a helpful assistant.",
|
|
model: customModel,
|
|
thinkingLevel: "low",
|
|
},
|
|
});
|
|
|
|
expect(agent.state.systemPrompt).toBe("You are a helpful assistant.");
|
|
expect(agent.state.model).toBe(customModel);
|
|
expect(agent.state.thinkingLevel).toBe("low");
|
|
});
|
|
|
|
it("should subscribe to events", () => {
|
|
const agent = new Agent();
|
|
|
|
let eventCount = 0;
|
|
const unsubscribe = agent.subscribe(_event => {
|
|
eventCount++;
|
|
});
|
|
|
|
// No initial event on subscribe
|
|
expect(eventCount).toBe(0);
|
|
|
|
// State mutators don't emit events
|
|
agent.setSystemPrompt("Test prompt");
|
|
expect(eventCount).toBe(0);
|
|
expect(agent.state.systemPrompt).toBe("Test prompt");
|
|
|
|
// Unsubscribe should work
|
|
unsubscribe();
|
|
agent.setSystemPrompt("Another prompt");
|
|
expect(eventCount).toBe(0); // Should not increase
|
|
});
|
|
|
|
it("should update state with mutators", () => {
|
|
const agent = new Agent();
|
|
|
|
// Test setSystemPrompt
|
|
agent.setSystemPrompt("Custom prompt");
|
|
expect(agent.state.systemPrompt).toBe("Custom prompt");
|
|
|
|
// Test setModel
|
|
const newModel = getBundledModel("google", "gemini-2.5-flash");
|
|
agent.setModel(newModel);
|
|
expect(agent.state.model).toBe(newModel);
|
|
|
|
// Test setThinkingLevel
|
|
agent.setThinkingLevel("high");
|
|
expect(agent.state.thinkingLevel).toBe("high");
|
|
|
|
// Test setTools
|
|
const tools = [{ name: "test", description: "test tool" } as any];
|
|
agent.setTools(tools);
|
|
expect(agent.state.tools).toBe(tools);
|
|
|
|
// Test replaceMessages
|
|
const messages = [{ role: "user" as const, content: "Hello", timestamp: Date.now() }];
|
|
agent.replaceMessages(messages);
|
|
expect(agent.state.messages).toEqual(messages);
|
|
expect(agent.state.messages).not.toBe(messages); // Should be a copy
|
|
|
|
// Test appendMessage
|
|
const newMessage = createAssistantMessage([{ type: "text", text: "Hi" }]);
|
|
agent.appendMessage(newMessage);
|
|
expect(agent.state.messages).toHaveLength(2);
|
|
expect(agent.state.messages[1]).toBe(newMessage);
|
|
|
|
// Test clearMessages
|
|
agent.clearMessages();
|
|
expect(agent.state.messages).toEqual([]);
|
|
});
|
|
|
|
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("should handle abort controller", () => {
|
|
const agent = new Agent();
|
|
|
|
// Should not throw even if nothing is running
|
|
expect(() => agent.abort()).not.toThrow();
|
|
});
|
|
|
|
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("forwards sessionId and thinkingBudgets to streamFn options", async () => {
|
|
let receivedSessionId: string | undefined;
|
|
let receivedBudgets: ThinkingBudgets | undefined;
|
|
|
|
const agent = new Agent({
|
|
sessionId: "session-abc",
|
|
thinkingBudgets: { minimal: 64, low: 256 },
|
|
streamFn: (_model, _context, options) => {
|
|
receivedSessionId = options?.sessionId;
|
|
receivedBudgets = options?.thinkingBudgets;
|
|
const stream = new MockAssistantStream();
|
|
queueMicrotask(() => {
|
|
const message = createAssistantMessage([{ type: "text", text: "ok" }]);
|
|
stream.push({ type: "done", reason: "stop", message });
|
|
});
|
|
return stream;
|
|
},
|
|
});
|
|
|
|
await agent.prompt("hello");
|
|
expect(receivedSessionId).toBe("session-abc");
|
|
expect(receivedBudgets).toEqual({ minimal: 64, low: 256 });
|
|
|
|
agent.sessionId = "session-def";
|
|
agent.thinkingBudgets = { medium: 512 };
|
|
|
|
await agent.prompt("hello again");
|
|
expect(receivedSessionId).toBe("session-def");
|
|
expect(receivedBudgets).toEqual({ medium: 512 });
|
|
});
|
|
});
|