test(coding-agent): add AgentSession test harness with faux provider
Adds a reusable test harness for AgentSession runtime testing: - Faux stream function with declarative response sequencing (text, tool calls, thinking, errors, custom usage, delays) - Token-level streaming with realistic delta events (3-5 char chunks) - One-call session factory with real in-memory dependencies - Event capture with type-filtered access - 14 self-tests covering text, tool calls, errors, retry, streaming deltas, thinking, event ordering, persistence ref #2023
This commit is contained in:
269
packages/coding-agent/test/test-harness.test.ts
Normal file
269
packages/coding-agent/test/test-harness.test.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the test harness itself.
|
||||||
|
* Validates that the faux provider and session factory work correctly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createHarness, type Harness } from "./test-harness.js";
|
||||||
|
|
||||||
|
describe("test harness", () => {
|
||||||
|
let harness: Harness;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
harness?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("simple text response", async () => {
|
||||||
|
harness = createHarness({ responses: ["hello world"] });
|
||||||
|
|
||||||
|
await harness.session.prompt("hi");
|
||||||
|
|
||||||
|
expect(harness.faux.callCount).toBe(1);
|
||||||
|
|
||||||
|
const assistantMessages = harness.session.messages.filter((m) => m.role === "assistant");
|
||||||
|
expect(assistantMessages).toHaveLength(1);
|
||||||
|
|
||||||
|
const msg = assistantMessages[0] as AssistantMessage;
|
||||||
|
expect(msg.content).toEqual([{ type: "text", text: "hello world" }]);
|
||||||
|
expect(msg.stopReason).toBe("stop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("response sequence", async () => {
|
||||||
|
harness = createHarness({ responses: ["first", "second", "third"] });
|
||||||
|
|
||||||
|
await harness.session.prompt("a");
|
||||||
|
await harness.session.prompt("b");
|
||||||
|
await harness.session.prompt("c");
|
||||||
|
|
||||||
|
expect(harness.faux.callCount).toBe(3);
|
||||||
|
|
||||||
|
const assistantTexts = harness.session.messages
|
||||||
|
.filter((m): m is AssistantMessage => m.role === "assistant")
|
||||||
|
.map((m) => m.content.find((c) => c.type === "text")?.text);
|
||||||
|
|
||||||
|
expect(assistantTexts).toEqual(["first", "second", "third"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tool call response triggers tool execution", async () => {
|
||||||
|
let toolExecuted = false;
|
||||||
|
const echoTool: AgentTool = {
|
||||||
|
name: "echo",
|
||||||
|
label: "Echo",
|
||||||
|
description: "Echo back",
|
||||||
|
parameters: Type.Object({ text: Type.String() }),
|
||||||
|
execute: async () => {
|
||||||
|
toolExecuted = true;
|
||||||
|
return { content: [{ type: "text", text: "echoed" }], details: {} };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
harness = createHarness({
|
||||||
|
responses: [{ toolCalls: [{ name: "echo", args: { text: "hi" } }] }, "done after tool"],
|
||||||
|
tools: [echoTool],
|
||||||
|
baseToolsOverride: { echo: echoTool },
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.session.prompt("use the tool");
|
||||||
|
|
||||||
|
expect(toolExecuted).toBe(true);
|
||||||
|
expect(harness.faux.callCount).toBe(2);
|
||||||
|
|
||||||
|
const toolResults = harness.session.messages.filter((m) => m.role === "toolResult");
|
||||||
|
expect(toolResults).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("error response", async () => {
|
||||||
|
harness = createHarness({
|
||||||
|
responses: [{ error: "something broke" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.session.prompt("hi");
|
||||||
|
|
||||||
|
const assistantMessages = harness.session.messages.filter((m): m is AssistantMessage => m.role === "assistant");
|
||||||
|
expect(assistantMessages).toHaveLength(1);
|
||||||
|
expect(assistantMessages[0].stopReason).toBe("error");
|
||||||
|
expect(assistantMessages[0].errorMessage).toBe("something broke");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retry on transient error", async () => {
|
||||||
|
harness = createHarness({
|
||||||
|
responses: [{ error: "overloaded_error" }, "recovered"],
|
||||||
|
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.session.prompt("hi");
|
||||||
|
|
||||||
|
expect(harness.faux.callCount).toBe(2);
|
||||||
|
|
||||||
|
const retryStarts = harness.eventsOfType("auto_retry_start");
|
||||||
|
expect(retryStarts).toHaveLength(1);
|
||||||
|
|
||||||
|
const retryEnds = harness.eventsOfType("auto_retry_end");
|
||||||
|
expect(retryEnds).toHaveLength(1);
|
||||||
|
expect(retryEnds[0].success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("custom usage numbers", async () => {
|
||||||
|
harness = createHarness({
|
||||||
|
responses: [{ text: "big response", usage: { input: 100000, output: 5000 } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.session.prompt("hi");
|
||||||
|
|
||||||
|
const msg = harness.session.messages.find((m): m is AssistantMessage => m.role === "assistant")!;
|
||||||
|
expect(msg.usage.input).toBe(100000);
|
||||||
|
expect(msg.usage.output).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("event capture", async () => {
|
||||||
|
harness = createHarness({ responses: ["hello"] });
|
||||||
|
|
||||||
|
await harness.session.prompt("hi");
|
||||||
|
|
||||||
|
const agentStarts = harness.eventsOfType("agent_start");
|
||||||
|
expect(agentStarts).toHaveLength(1);
|
||||||
|
|
||||||
|
const agentEnds = harness.eventsOfType("agent_end");
|
||||||
|
expect(agentEnds).toHaveLength(1);
|
||||||
|
|
||||||
|
const messageEnds = harness.eventsOfType("message_end");
|
||||||
|
expect(messageEnds.length).toBeGreaterThanOrEqual(2); // user + assistant
|
||||||
|
});
|
||||||
|
|
||||||
|
it("context capture", async () => {
|
||||||
|
harness = createHarness({ responses: ["reply"] });
|
||||||
|
|
||||||
|
await harness.session.prompt("my question");
|
||||||
|
|
||||||
|
expect(harness.faux.contexts).toHaveLength(1);
|
||||||
|
const ctx = harness.faux.contexts[0];
|
||||||
|
const userMsg = ctx.messages.find((m) => m.role === "user");
|
||||||
|
expect(userMsg).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps around when more calls than responses", async () => {
|
||||||
|
harness = createHarness({ responses: ["a", "b"] });
|
||||||
|
|
||||||
|
await harness.session.prompt("1");
|
||||||
|
await harness.session.prompt("2");
|
||||||
|
await harness.session.prompt("3");
|
||||||
|
|
||||||
|
expect(harness.faux.callCount).toBe(3);
|
||||||
|
|
||||||
|
const texts = harness.session.messages
|
||||||
|
.filter((m): m is AssistantMessage => m.role === "assistant")
|
||||||
|
.map((m) => m.content.find((c) => c.type === "text")?.text);
|
||||||
|
|
||||||
|
expect(texts).toEqual(["a", "b", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams text deltas", async () => {
|
||||||
|
harness = createHarness({ responses: ["hello world"] });
|
||||||
|
|
||||||
|
await harness.session.prompt("hi");
|
||||||
|
|
||||||
|
const updates = harness.eventsOfType("message_update");
|
||||||
|
const textDeltas = updates.filter((e) => e.assistantMessageEvent.type === "text_delta");
|
||||||
|
expect(textDeltas.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Deltas should reconstruct the full text
|
||||||
|
const reconstructed = textDeltas.map((e) => (e.assistantMessageEvent as { delta: string }).delta).join("");
|
||||||
|
expect(reconstructed).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams thinking deltas", async () => {
|
||||||
|
harness = createHarness({
|
||||||
|
responses: [{ thinking: "let me think about this", text: "answer" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.session.prompt("hi");
|
||||||
|
|
||||||
|
const updates = harness.eventsOfType("message_update");
|
||||||
|
const thinkingStarts = updates.filter((e) => e.assistantMessageEvent.type === "thinking_start");
|
||||||
|
const thinkingDeltas = updates.filter((e) => e.assistantMessageEvent.type === "thinking_delta");
|
||||||
|
const thinkingEnds = updates.filter((e) => e.assistantMessageEvent.type === "thinking_end");
|
||||||
|
|
||||||
|
expect(thinkingStarts).toHaveLength(1);
|
||||||
|
expect(thinkingDeltas.length).toBeGreaterThan(0);
|
||||||
|
expect(thinkingEnds).toHaveLength(1);
|
||||||
|
|
||||||
|
const reconstructed = thinkingDeltas.map((e) => (e.assistantMessageEvent as { delta: string }).delta).join("");
|
||||||
|
expect(reconstructed).toBe("let me think about this");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams tool call deltas", async () => {
|
||||||
|
const echoTool: AgentTool = {
|
||||||
|
name: "echo",
|
||||||
|
label: "Echo",
|
||||||
|
description: "Echo back",
|
||||||
|
parameters: Type.Object({ text: Type.String() }),
|
||||||
|
execute: async () => ({ content: [{ type: "text", text: "echoed" }], details: {} }),
|
||||||
|
};
|
||||||
|
|
||||||
|
harness = createHarness({
|
||||||
|
responses: [{ toolCalls: [{ name: "echo", args: { text: "hi" } }] }, "done"],
|
||||||
|
tools: [echoTool],
|
||||||
|
baseToolsOverride: { echo: echoTool },
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.session.prompt("use tool");
|
||||||
|
|
||||||
|
const updates = harness.eventsOfType("message_update");
|
||||||
|
const toolcallStarts = updates.filter((e) => e.assistantMessageEvent.type === "toolcall_start");
|
||||||
|
const toolcallDeltas = updates.filter((e) => e.assistantMessageEvent.type === "toolcall_delta");
|
||||||
|
const toolcallEnds = updates.filter((e) => e.assistantMessageEvent.type === "toolcall_end");
|
||||||
|
|
||||||
|
expect(toolcallStarts).toHaveLength(1);
|
||||||
|
expect(toolcallDeltas.length).toBeGreaterThan(0);
|
||||||
|
expect(toolcallEnds).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams thinking then text then tool call in order", async () => {
|
||||||
|
const echoTool: AgentTool = {
|
||||||
|
name: "echo",
|
||||||
|
label: "Echo",
|
||||||
|
description: "Echo back",
|
||||||
|
parameters: Type.Object({ text: Type.String() }),
|
||||||
|
execute: async () => ({ content: [{ type: "text", text: "echoed" }], details: {} }),
|
||||||
|
};
|
||||||
|
|
||||||
|
harness = createHarness({
|
||||||
|
responses: [
|
||||||
|
{
|
||||||
|
thinking: "hmm",
|
||||||
|
text: "I will call a tool",
|
||||||
|
toolCalls: [{ name: "echo", args: { text: "x" } }],
|
||||||
|
},
|
||||||
|
"final",
|
||||||
|
],
|
||||||
|
tools: [echoTool],
|
||||||
|
baseToolsOverride: { echo: echoTool },
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.session.prompt("do it");
|
||||||
|
|
||||||
|
const updates = harness.eventsOfType("message_update");
|
||||||
|
const streamTypes = updates.map((e) => e.assistantMessageEvent.type);
|
||||||
|
|
||||||
|
// Thinking events should come before text events, text before toolcall
|
||||||
|
const firstThinking = streamTypes.indexOf("thinking_start");
|
||||||
|
const firstText = streamTypes.indexOf("text_start");
|
||||||
|
const firstToolcall = streamTypes.indexOf("toolcall_start");
|
||||||
|
|
||||||
|
expect(firstThinking).toBeLessThan(firstText);
|
||||||
|
expect(firstText).toBeLessThan(firstToolcall);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("session persistence works", async () => {
|
||||||
|
harness = createHarness({ responses: ["persisted"] });
|
||||||
|
|
||||||
|
await harness.session.prompt("hi");
|
||||||
|
|
||||||
|
const entries = harness.sessionManager.getEntries();
|
||||||
|
const messageEntries = entries.filter((e) => e.type === "message");
|
||||||
|
expect(messageEntries.length).toBeGreaterThanOrEqual(2); // user + assistant
|
||||||
|
});
|
||||||
|
});
|
||||||
414
packages/coding-agent/test/test-harness.ts
Normal file
414
packages/coding-agent/test/test-harness.ts
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
/**
|
||||||
|
* Test harness for AgentSession runtime testing.
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - A faux stream function with declarative response sequencing
|
||||||
|
* - A one-call factory for a fully wired AgentSession with real in-memory dependencies
|
||||||
|
* - Event capture for assertions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
|
import { Agent } from "@mariozechner/pi-agent-core";
|
||||||
|
import type {
|
||||||
|
AssistantMessage,
|
||||||
|
AssistantMessageEvent,
|
||||||
|
AssistantMessageEventStream,
|
||||||
|
Context,
|
||||||
|
Model,
|
||||||
|
SimpleStreamOptions,
|
||||||
|
StopReason,
|
||||||
|
TextContent,
|
||||||
|
ThinkingContent,
|
||||||
|
ToolCall,
|
||||||
|
Usage,
|
||||||
|
} from "@mariozechner/pi-ai";
|
||||||
|
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
|
||||||
|
import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.js";
|
||||||
|
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||||
|
import { ModelRegistry } from "../src/core/model-registry.js";
|
||||||
|
import { SessionManager } from "../src/core/session-manager.js";
|
||||||
|
import type { Settings } from "../src/core/settings-manager.js";
|
||||||
|
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||||
|
import { createTestResourceLoader } from "./utilities.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Faux model
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const FAUX_PROVIDER = "faux";
|
||||||
|
const FAUX_MODEL_ID = "faux-1";
|
||||||
|
const FAUX_API = "anthropic-messages" as const;
|
||||||
|
|
||||||
|
export const fauxModel: Model<typeof FAUX_API> = {
|
||||||
|
id: FAUX_MODEL_ID,
|
||||||
|
name: "Faux Model",
|
||||||
|
api: FAUX_API,
|
||||||
|
provider: FAUX_PROVIDER,
|
||||||
|
baseUrl: "http://localhost:0",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text", "image"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 16384,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Response description
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface FauxResponse {
|
||||||
|
/** Text content blocks. String shorthand becomes a single text block. */
|
||||||
|
text?: string;
|
||||||
|
/** Tool calls to include in the response. */
|
||||||
|
toolCalls?: Array<{ id?: string; name: string; args: Record<string, unknown> }>;
|
||||||
|
/** Thinking content. */
|
||||||
|
thinking?: string;
|
||||||
|
/** Stop reason. Defaults to "stop", or "toolUse" if toolCalls are present, or "error" if error is set. */
|
||||||
|
stopReason?: StopReason;
|
||||||
|
/** Error message. Sets stopReason to "error" if not explicitly set. */
|
||||||
|
error?: string;
|
||||||
|
/** Usage numbers. Merged with defaults (input: 100, output: 50). */
|
||||||
|
usage?: Partial<Usage>;
|
||||||
|
/** Delay in ms before the response starts. */
|
||||||
|
delayMs?: number;
|
||||||
|
/** Model overrides (provider, model id) for responses that should look like they came from a different model. */
|
||||||
|
model?: { provider?: string; id?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorthand: a string becomes a simple text response. */
|
||||||
|
export type FauxResponseInput = FauxResponse | string;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Faux stream function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function normalizeResponse(input: FauxResponseInput): FauxResponse {
|
||||||
|
if (typeof input === "string") {
|
||||||
|
return { text: input };
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUsage(partial?: Partial<Usage>): Usage {
|
||||||
|
const input = partial?.input ?? 100;
|
||||||
|
const output = partial?.output ?? 50;
|
||||||
|
const cacheRead = partial?.cacheRead ?? 0;
|
||||||
|
const cacheWrite = partial?.cacheWrite ?? 0;
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
cacheRead,
|
||||||
|
cacheWrite,
|
||||||
|
totalTokens: partial?.totalTokens ?? input + output + cacheRead + cacheWrite,
|
||||||
|
cost: partial?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let toolCallIdCounter = 0;
|
||||||
|
|
||||||
|
function buildAssistantMessage(resp: FauxResponse): AssistantMessage {
|
||||||
|
const content: (TextContent | ThinkingContent | ToolCall)[] = [];
|
||||||
|
|
||||||
|
if (resp.thinking) {
|
||||||
|
content.push({ type: "thinking", thinking: resp.thinking });
|
||||||
|
}
|
||||||
|
if (resp.text !== undefined) {
|
||||||
|
content.push({ type: "text", text: resp.text });
|
||||||
|
}
|
||||||
|
if (resp.toolCalls) {
|
||||||
|
for (const tc of resp.toolCalls) {
|
||||||
|
content.push({
|
||||||
|
type: "toolCall",
|
||||||
|
id: tc.id ?? `faux_tc_${++toolCallIdCounter}`,
|
||||||
|
name: tc.name,
|
||||||
|
arguments: tc.args,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no content was added at all, add empty text
|
||||||
|
if (content.length === 0 && !resp.error) {
|
||||||
|
content.push({ type: "text", text: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopReason: StopReason;
|
||||||
|
if (resp.stopReason) {
|
||||||
|
stopReason = resp.stopReason;
|
||||||
|
} else if (resp.error) {
|
||||||
|
stopReason = "error";
|
||||||
|
} else if (resp.toolCalls && resp.toolCalls.length > 0) {
|
||||||
|
stopReason = "toolUse";
|
||||||
|
} else {
|
||||||
|
stopReason = "stop";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
api: FAUX_API,
|
||||||
|
provider: resp.model?.provider ?? FAUX_PROVIDER,
|
||||||
|
model: resp.model?.id ?? FAUX_MODEL_ID,
|
||||||
|
usage: buildUsage(resp.usage),
|
||||||
|
stopReason,
|
||||||
|
errorMessage: resp.error,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Token-level streaming
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Split a string into chunks of varying size (3-5 chars) for simulating token-by-token streaming. */
|
||||||
|
function chunkString(text: string): string[] {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < text.length) {
|
||||||
|
const size = 3 + Math.floor(Math.random() * 3); // 3, 4, or 5
|
||||||
|
chunks.push(text.slice(i, i + size));
|
||||||
|
i += size;
|
||||||
|
}
|
||||||
|
return chunks.length > 0 ? chunks : [""];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a complete AssistantMessage through an EventStream with realistic
|
||||||
|
* intermediate delta events for each content block.
|
||||||
|
*/
|
||||||
|
function streamWithDeltas(stream: AssistantMessageEventStream, message: AssistantMessage): void {
|
||||||
|
const isError = message.stopReason === "error" || message.stopReason === "aborted";
|
||||||
|
|
||||||
|
// Build partial progressively as we stream content blocks
|
||||||
|
const partial: AssistantMessage = { ...message, content: [] };
|
||||||
|
stream.push({ type: "start", partial: { ...partial } });
|
||||||
|
|
||||||
|
for (let i = 0; i < message.content.length; i++) {
|
||||||
|
const block = message.content[i];
|
||||||
|
|
||||||
|
if (block.type === "thinking") {
|
||||||
|
partial.content = [...partial.content, { type: "thinking", thinking: "" }];
|
||||||
|
stream.push({ type: "thinking_start", contentIndex: i, partial: { ...partial } });
|
||||||
|
|
||||||
|
for (const chunk of chunkString(block.thinking)) {
|
||||||
|
(partial.content[i] as ThinkingContent).thinking += chunk;
|
||||||
|
stream.push(makeEvent("thinking_delta", i, chunk, partial));
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.push({
|
||||||
|
type: "thinking_end",
|
||||||
|
contentIndex: i,
|
||||||
|
content: block.thinking,
|
||||||
|
partial: { ...partial },
|
||||||
|
});
|
||||||
|
} else if (block.type === "text") {
|
||||||
|
partial.content = [...partial.content, { type: "text", text: "" }];
|
||||||
|
stream.push({ type: "text_start", contentIndex: i, partial: { ...partial } });
|
||||||
|
|
||||||
|
for (const chunk of chunkString(block.text)) {
|
||||||
|
(partial.content[i] as TextContent).text += chunk;
|
||||||
|
stream.push(makeEvent("text_delta", i, chunk, partial));
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.push({
|
||||||
|
type: "text_end",
|
||||||
|
contentIndex: i,
|
||||||
|
content: block.text,
|
||||||
|
partial: { ...partial },
|
||||||
|
});
|
||||||
|
} else if (block.type === "toolCall") {
|
||||||
|
const argsJson = JSON.stringify(block.arguments);
|
||||||
|
partial.content = [...partial.content, { type: "toolCall", id: block.id, name: block.name, arguments: {} }];
|
||||||
|
stream.push({ type: "toolcall_start", contentIndex: i, partial: { ...partial } });
|
||||||
|
|
||||||
|
for (const chunk of chunkString(argsJson)) {
|
||||||
|
stream.push(makeEvent("toolcall_delta", i, chunk, partial));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final toolcall has the real parsed arguments
|
||||||
|
(partial.content[i] as ToolCall).arguments = block.arguments;
|
||||||
|
stream.push({
|
||||||
|
type: "toolcall_end",
|
||||||
|
contentIndex: i,
|
||||||
|
toolCall: block,
|
||||||
|
partial: { ...partial },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
stream.push({ type: "error", reason: message.stopReason as "error" | "aborted", error: message });
|
||||||
|
} else {
|
||||||
|
stream.push({ type: "done", reason: message.stopReason as "stop" | "length" | "toolUse", message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEvent(
|
||||||
|
type: "text_delta" | "thinking_delta" | "toolcall_delta",
|
||||||
|
contentIndex: number,
|
||||||
|
delta: string,
|
||||||
|
partial: AssistantMessage,
|
||||||
|
): AssistantMessageEvent {
|
||||||
|
return { type, contentIndex, delta, partial: { ...partial } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stream function factory
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface FauxStreamFnState {
|
||||||
|
/** Number of times the stream function has been called. */
|
||||||
|
callCount: number;
|
||||||
|
/** The context passed to each call, in order. */
|
||||||
|
contexts: Context[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a faux stream function from a sequence of response descriptions.
|
||||||
|
*
|
||||||
|
* The function cycles through responses in order. If more calls are made than
|
||||||
|
* responses provided, it wraps around.
|
||||||
|
*
|
||||||
|
* Returns the stream function and a state object for inspection.
|
||||||
|
*/
|
||||||
|
export function createFauxStreamFn(responses: FauxResponseInput[]): {
|
||||||
|
streamFn: (model: Model<any>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
|
||||||
|
state: FauxStreamFnState;
|
||||||
|
} {
|
||||||
|
if (responses.length === 0) {
|
||||||
|
throw new Error("createFauxStreamFn requires at least one response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: FauxStreamFnState = { callCount: 0, contexts: [] };
|
||||||
|
|
||||||
|
const streamFn = (_model: Model<any>, context: Context, _options?: SimpleStreamOptions) => {
|
||||||
|
const index = state.callCount % responses.length;
|
||||||
|
state.callCount++;
|
||||||
|
state.contexts.push(context);
|
||||||
|
|
||||||
|
const resp = normalizeResponse(responses[index]);
|
||||||
|
const message = buildAssistantMessage(resp);
|
||||||
|
const stream = createAssistantMessageEventStream();
|
||||||
|
|
||||||
|
const emit = () => {
|
||||||
|
streamWithDeltas(stream, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resp.delayMs && resp.delayMs > 0) {
|
||||||
|
setTimeout(emit, resp.delayMs);
|
||||||
|
} else {
|
||||||
|
queueMicrotask(emit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { streamFn, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session harness
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface HarnessOptions {
|
||||||
|
/** Response sequence for the faux provider. Default: single "ok" response. */
|
||||||
|
responses?: FauxResponseInput[];
|
||||||
|
/** Model to use. Default: fauxModel. */
|
||||||
|
model?: Model<any>;
|
||||||
|
/** Context window override (applied to the model). */
|
||||||
|
contextWindow?: number;
|
||||||
|
/** Settings overrides (retry, compaction, etc.). */
|
||||||
|
settings?: Partial<Settings>;
|
||||||
|
/** System prompt. Default: "You are a test assistant." */
|
||||||
|
systemPrompt?: string;
|
||||||
|
/** Custom tools to register on the agent. */
|
||||||
|
tools?: AgentTool[];
|
||||||
|
/** Base tools override (replaces built-in read/bash/edit/write). */
|
||||||
|
baseToolsOverride?: Record<string, AgentTool>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Harness {
|
||||||
|
session: AgentSession;
|
||||||
|
agent: Agent;
|
||||||
|
sessionManager: SessionManager;
|
||||||
|
settingsManager: SettingsManager;
|
||||||
|
/** Faux stream function state (call count, captured contexts). */
|
||||||
|
faux: FauxStreamFnState;
|
||||||
|
/** All events emitted by the session, in order. */
|
||||||
|
events: AgentSessionEvent[];
|
||||||
|
/** Filter captured events by type. */
|
||||||
|
eventsOfType<T extends AgentSessionEvent["type"]>(type: T): Extract<AgentSessionEvent, { type: T }>[];
|
||||||
|
/** Temp directory (cleaned up by cleanup()). */
|
||||||
|
tempDir: string;
|
||||||
|
/** Dispose session and remove temp directory. */
|
||||||
|
cleanup: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHarness(options: HarnessOptions = {}): Harness {
|
||||||
|
const tempDir = join(tmpdir(), `pi-harness-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
const baseModel = options.model ?? fauxModel;
|
||||||
|
const model: Model<any> = options.contextWindow ? { ...baseModel, contextWindow: options.contextWindow } : baseModel;
|
||||||
|
|
||||||
|
const { streamFn, state: fauxState } = createFauxStreamFn(options.responses ?? ["ok"]);
|
||||||
|
|
||||||
|
const agent = new Agent({
|
||||||
|
getApiKey: () => "faux-key",
|
||||||
|
initialState: {
|
||||||
|
model,
|
||||||
|
systemPrompt: options.systemPrompt ?? "You are a test assistant.",
|
||||||
|
tools: options.tools ?? [],
|
||||||
|
},
|
||||||
|
streamFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionManager = SessionManager.inMemory();
|
||||||
|
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||||
|
|
||||||
|
if (options.settings) {
|
||||||
|
settingsManager.applyOverrides(options.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||||
|
authStorage.setRuntimeApiKey(model.provider, "faux-key");
|
||||||
|
const modelRegistry = new ModelRegistry(authStorage, tempDir);
|
||||||
|
|
||||||
|
const session = new AgentSession({
|
||||||
|
agent,
|
||||||
|
sessionManager,
|
||||||
|
settingsManager,
|
||||||
|
cwd: tempDir,
|
||||||
|
modelRegistry,
|
||||||
|
resourceLoader: createTestResourceLoader(),
|
||||||
|
baseToolsOverride: options.baseToolsOverride,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events: AgentSessionEvent[] = [];
|
||||||
|
session.subscribe((event) => {
|
||||||
|
events.push(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
session.dispose();
|
||||||
|
if (existsSync(tempDir)) {
|
||||||
|
rmSync(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
agent,
|
||||||
|
sessionManager,
|
||||||
|
settingsManager,
|
||||||
|
faux: fauxState,
|
||||||
|
events,
|
||||||
|
eventsOfType<T extends AgentSessionEvent["type"]>(type: T) {
|
||||||
|
return events.filter((e): e is Extract<AgentSessionEvent, { type: T }> => e.type === type);
|
||||||
|
},
|
||||||
|
tempDir,
|
||||||
|
cleanup,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user