feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
@@ -0,0 +1,209 @@
import {
createModels,
type FauxProviderHandle,
fauxAssistantMessage,
fauxProvider,
fauxToolCall,
type StreamOptions,
} from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { Session } from "../../src/harness/session/session.ts";
import type { AgentHarnessOptions } from "../../src/harness/types.ts";
import { calculateTool } from "../utils/calculate.ts";
/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */
const models = createModels();
let fauxCount = 0;
function newFaux(): FauxProviderHandle {
const faux = fauxProvider({ provider: `faux-${++fauxCount}` });
models.setProvider(faux.provider);
return faux;
}
function createHarness(options: AgentHarnessOptions): AgentHarness {
return new AgentHarness(options);
}
function captureOptions(options: StreamOptions | undefined): StreamOptions {
return {
...options,
headers: options?.headers ? { ...options.headers } : undefined,
metadata: options?.metadata ? { ...options.metadata } : undefined,
};
}
describe("AgentHarness stream configuration", () => {
it("snapshots stream options before provider request hooks", async () => {
let capturedOptions: StreamOptions | undefined;
const registration = newFaux();
registration.setResponses([
(_context, options) => {
capturedOptions = options;
return fauxAssistantMessage("ok");
},
]);
const session = new Session(new InMemorySessionStorage({ metadata: { id: "session-1", createdAt: "now" } }));
const harness = createHarness({
models,
session,
model: registration.getModel(),
streamOptions: {
timeoutMs: 1000,
maxRetries: 2,
maxRetryDelayMs: 3000,
headers: { "x-base": "base" },
metadata: { base: true },
cacheRetention: "none",
},
});
harness.on("before_provider_request", (event) => {
expect(event.sessionId).toBe("session-1");
expect(event.streamOptions.headers).toEqual({ "x-base": "base" });
return {
streamOptions: {
headers: { "x-hook": "hook" },
metadata: { hook: true },
},
};
});
await harness.prompt("hello");
expect(capturedOptions).toMatchObject({
timeoutMs: 1000,
maxRetries: 2,
maxRetryDelayMs: 3000,
sessionId: "session-1",
cacheRetention: "none",
});
expect(capturedOptions?.headers).toEqual({ "x-base": "base", "x-hook": "hook" });
expect(capturedOptions?.metadata).toEqual({ base: true, hook: true });
});
it("chains provider request patches and supports deletion semantics", async () => {
let capturedOptions: StreamOptions | undefined;
const registration = newFaux();
registration.setResponses([
(_context, options) => {
capturedOptions = options;
return fauxAssistantMessage("ok");
},
]);
const harness = createHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
streamOptions: {
timeoutMs: 1000,
maxRetries: 2,
headers: { keep: "base", remove: "base" },
metadata: { keep: "base", remove: "base" },
},
});
harness.on("before_provider_request", (event) => {
expect(event.streamOptions.headers).toEqual({ keep: "base", remove: "base" });
return {
streamOptions: {
headers: { first: "1", remove: undefined },
metadata: { first: 1, remove: undefined },
},
};
});
harness.on("before_provider_request", (event) => {
expect(event.streamOptions.headers).toEqual({ keep: "base", first: "1" });
expect(event.streamOptions.metadata).toEqual({ keep: "base", first: 1 });
return {
streamOptions: {
timeoutMs: undefined,
headers: { second: "2" },
metadata: undefined,
},
};
});
await harness.prompt("hello");
expect(capturedOptions?.timeoutMs).toBeUndefined();
expect(capturedOptions?.maxRetries).toBe(2);
expect(capturedOptions?.headers).toEqual({ keep: "base", first: "1", second: "2" });
expect(capturedOptions?.metadata).toBeUndefined();
});
it("uses updated stream options for save-point snapshots without mutating the active request", async () => {
const capturedOptions: StreamOptions[] = [];
const registration = newFaux();
registration.setResponses([
(_context, options) => {
capturedOptions.push(captureOptions(options));
return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), {
stopReason: "toolUse",
});
},
(_context, options) => {
capturedOptions.push(captureOptions(options));
return fauxAssistantMessage("done");
},
]);
const harness = createHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
tools: [calculateTool],
streamOptions: { timeoutMs: 1000, headers: { turn: "first" } },
});
harness.subscribe((event) => {
if (event.type === "tool_execution_start") {
harness.setStreamOptions({ timeoutMs: 2000, headers: { turn: "second" } });
}
});
await harness.prompt("hello");
expect(capturedOptions).toHaveLength(2);
expect(capturedOptions[0].timeoutMs).toBe(1000);
expect(capturedOptions[0].headers).toEqual({ turn: "first" });
expect(capturedOptions[1].timeoutMs).toBe(2000);
expect(capturedOptions[1].headers).toEqual({ turn: "second" });
});
it("chains provider payload hooks", async () => {
const seenPayloads: unknown[] = [];
let finalPayload: unknown;
const registration = newFaux();
registration.setResponses([
async (_context, options, _state, model) => {
finalPayload = await options?.onPayload?.({ steps: ["provider"] }, model);
return fauxAssistantMessage("ok");
},
]);
const harness = createHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
harness.on("before_provider_payload", (event) => {
seenPayloads.push(event.payload);
return { payload: { steps: ["provider", "first"] } };
});
harness.on("before_provider_payload", (event) => {
seenPayloads.push(event.payload);
return { payload: { steps: ["provider", "first", "second"] } };
});
await harness.prompt("hello");
expect(seenPayloads).toEqual([{ steps: ["provider"] }, { steps: ["provider", "first"] }]);
expect(finalPayload).toEqual({ steps: ["provider", "first", "second"] });
});
});
@@ -0,0 +1,957 @@
import {
createModels,
type FauxProviderHandle,
fauxAssistantMessage,
fauxProvider,
fauxToolCall,
type RegisterFauxProviderOptions,
type Usage,
} from "@earendil-works/pi-ai";
import { getModel } from "@earendil-works/pi-ai/compat";
import { describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { Session } from "../../src/harness/session/session.ts";
import type { AgentHarnessTool, PromptTemplate, Skill } from "../../src/harness/types.ts";
import type { AgentMessage, AgentTool } from "../../src/types.ts";
import { calculateTool, createCalculateToolWithUsage } from "../utils/calculate.ts";
import { getCurrentTimeTool } from "../utils/get-current-time.ts";
interface AppSkill extends Skill {
source: "project" | "user";
}
interface AppPromptTemplate extends PromptTemplate {
source: "project" | "user";
}
/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */
const models = createModels();
let fauxCount = 0;
function newFaux(options: RegisterFauxProviderOptions = {}): FauxProviderHandle {
const faux = fauxProvider({ provider: `faux-${++fauxCount}`, ...options });
models.setProvider(faux.provider);
return faux;
}
function textFromUserMessages(messages: Array<{ role: string; content: unknown }>): string[] {
return messages.flatMap((message) => {
if (message.role !== "user") return [];
if (typeof message.content === "string") return [message.content];
if (!Array.isArray(message.content)) return [];
return message.content.flatMap((part) => {
if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return [];
return "text" in part && typeof part.text === "string" ? [part.text] : [];
});
});
}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve = () => {};
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function getReasoning(options: unknown): unknown {
if (!options || typeof options !== "object" || !("reasoning" in options)) return undefined;
return options.reasoning;
}
function createUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage {
return {
input,
output,
cacheRead,
cacheWrite,
totalTokens: input + output + cacheRead + cacheWrite,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function createUserMessage(text: string): AgentMessage {
return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() };
}
function createAssistantMessage(text: string): AgentMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "faux",
provider: "faux",
model: "faux-1",
usage: createUsage(100, 50),
stopReason: "stop",
timestamp: Date.now(),
};
}
describe("AgentHarness", () => {
it("constructs directly and exposes queue modes", () => {
const session = new Session(new InMemorySessionStorage());
const initialModel = getModel("anthropic", "claude-sonnet-4-5");
const harness = new AgentHarness({
models,
session,
model: initialModel,
thinkingLevel: "high",
systemPrompt: "You are helpful.",
steeringMode: "all",
followUpMode: "all",
});
expect(harness.getModel()).toBe(initialModel);
expect(harness.getThinkingLevel()).toBe("high");
expect(harness.getSteeringMode()).toBe("all");
expect(harness.getFollowUpMode()).toBe("all");
harness.setSteeringMode("one-at-a-time");
harness.setFollowUpMode("one-at-a-time");
expect(harness.getSteeringMode()).toBe("one-at-a-time");
expect(harness.getFollowUpMode()).toBe("one-at-a-time");
});
it("drains one queued steering message at a time and emits queue updates", async () => {
const registration = newFaux();
const userCounts: number[] = [];
registration.setResponses([
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("first");
},
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("second");
},
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("third");
},
]);
const harness = new AgentHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
steeringMode: "one-at-a-time",
});
const steerQueueLengths: number[] = [];
let queued = false;
harness.subscribe((event) => {
if (event.type === "queue_update") {
steerQueueLengths.push(event.steer.length);
}
if (event.type === "message_start" && event.message.role === "assistant" && !queued) {
queued = true;
harness.steer("one");
harness.steer("two");
}
});
await harness.prompt("hello");
expect(userCounts).toEqual([1, 2, 3]);
expect(steerQueueLengths).toEqual([1, 2, 1, 0]);
});
it("appends before_agent_start messages and persists them", async () => {
const registration = newFaux();
let requestText: string[] = [];
registration.setResponses([
(context) => {
requestText = textFromUserMessages(context.messages);
return fauxAssistantMessage("ok");
},
]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
});
harness.on("before_agent_start", () => ({
messages: [{ role: "user", content: [{ type: "text", text: "hook" }], timestamp: Date.now() }],
}));
await harness.prompt("hello");
const persistedText = (await session.getEntries()).flatMap((entry) => {
if (entry.type !== "message" || entry.message.role !== "user") return [];
const content = entry.message.content;
if (typeof content === "string") return [content];
return content.flatMap((part) => (part.type === "text" ? [part.text] : []));
});
expect(requestText).toEqual(["hello", "hook"]);
expect(persistedText).toEqual(["hello", "hook"]);
});
it("abort clears steer and follow-up queues but preserves next-turn messages", async () => {
const registration = newFaux();
let releaseFirstResponse: (() => void) | undefined;
let abortedSignal: AbortSignal | undefined;
const firstResponseReleased = new Promise<void>((resolve) => {
releaseFirstResponse = resolve;
});
const secondRequestText: string[] = [];
registration.setResponses([
async (_context, options) => {
abortedSignal = options?.signal;
await firstResponseReleased;
return fauxAssistantMessage("aborted-ish");
},
(context) => {
secondRequestText.push(...textFromUserMessages(context.messages));
return fauxAssistantMessage("second");
},
]);
const harness = new AgentHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
const queueUpdates: Array<{ steer: number; followUp: number; nextTurn: number }> = [];
harness.subscribe((event) => {
if (event.type === "queue_update") {
queueUpdates.push({
steer: event.steer.length,
followUp: event.followUp.length,
nextTurn: event.nextTurn.length,
});
}
});
const firstPrompt = harness.prompt("first");
await new Promise((resolve) => setTimeout(resolve, 0));
harness.steer("steer");
harness.followUp("follow");
harness.nextTurn("next");
const abortResultPromise = harness.abort();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(abortedSignal?.aborted).toBe(true);
releaseFirstResponse?.();
const abortResult = await abortResultPromise;
await firstPrompt;
await harness.prompt("second");
expect(abortResult.clearedSteer).toHaveLength(1);
expect(abortResult.clearedFollowUp).toHaveLength(1);
expect(queueUpdates).toContainEqual({ steer: 0, followUp: 0, nextTurn: 1 });
expect(secondRequestText).toEqual(["first", "next", "second"]);
});
it("drains follow-up messages one at a time after the agent would otherwise stop", async () => {
const registration = newFaux();
const userCounts: number[] = [];
registration.setResponses([
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("first");
},
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("second");
},
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("third");
},
]);
const harness = new AgentHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
followUpMode: "one-at-a-time",
});
const followUpQueueLengths: number[] = [];
let queued = false;
harness.subscribe((event) => {
if (event.type === "queue_update") {
followUpQueueLengths.push(event.followUp.length);
}
if (event.type === "message_start" && event.message.role === "assistant" && !queued) {
queued = true;
harness.followUp("one");
harness.followUp("two");
}
});
await harness.prompt("hello");
expect(userCounts).toEqual([1, 2, 3]);
expect(followUpQueueLengths).toEqual([1, 2, 1, 0]);
});
it("settles thrown hook failures with persisted assistant error messages", async () => {
const registration = newFaux();
registration.setResponses([() => fauxAssistantMessage("should not be used")]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
});
const events: string[] = [];
harness.subscribe((event) => {
events.push(event.type);
});
harness.on("context", () => {
throw new Error("context exploded");
});
const response = await harness.prompt("hello");
await expect(harness.prompt("after failure")).resolves.toMatchObject({ role: "assistant" });
const entries = await session.getEntries();
const messages = entries.flatMap((entry) => (entry.type === "message" ? [entry.message] : []));
expect(response.stopReason).toBe("error");
expect(response.errorMessage).toBe("context exploded");
expect(messages[0]?.role).toBe("user");
expect(messages[1]).toMatchObject({ role: "assistant", stopReason: "error", errorMessage: "context exploded" });
expect(events).toContain("agent_end");
expect(events).toContain("settled");
});
it("refreshes model, thinking level, resources, system prompt, and active tools at save points", async () => {
const registration = newFaux({
models: [
{ id: "first", reasoning: true },
{ id: "second", reasoning: true },
],
});
const secondModel = registration.getModel("second");
if (!secondModel) throw new Error("missing second faux model");
const captured: Array<{ modelId: string; reasoning: unknown; systemPrompt: string; tools: string[] }> = [];
registration.setResponses([
(context, options, _state, model) => {
captured.push({
modelId: model.id,
reasoning: getReasoning(options),
systemPrompt: context.systemPrompt ?? "",
tools: context.tools?.map((tool) => tool.name) ?? [],
});
return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), {
stopReason: "toolUse",
});
},
(context, options, _state, model) => {
captured.push({
modelId: model.id,
reasoning: getReasoning(options),
systemPrompt: context.systemPrompt ?? "",
tools: context.tools?.map((tool) => tool.name) ?? [],
});
return fauxAssistantMessage("done");
},
]);
const harness = new AgentHarness<undefined, Skill, PromptTemplate, AgentTool>({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
thinkingLevel: "off",
resources: {
skills: [{ name: "prompt", description: "prompt", content: "first prompt", filePath: "/skills/prompt" }],
},
systemPrompt: ({ resources }) => resources.skills?.[0]?.content ?? "missing prompt",
tools: [calculateTool],
});
harness.subscribe((event) => {
if (event.type === "tool_execution_start") {
void harness.setModel(secondModel);
void harness.setThinkingLevel("high");
void harness.setResources({
skills: [
{ name: "prompt", description: "prompt", content: "second prompt", filePath: "/skills/prompt" },
],
});
void harness.setTools([calculateTool, getCurrentTimeTool], [getCurrentTimeTool.name]);
}
});
await harness.prompt("hello");
expect(captured).toEqual([
{ modelId: "first", reasoning: undefined, systemPrompt: "first prompt", tools: ["calculate"] },
{ modelId: "second", reasoning: "high", systemPrompt: "second prompt", tools: ["get_current_time"] },
]);
});
it("orders pending listener session writes after agent-emitted messages", async () => {
const registration = newFaux();
registration.setResponses([() => fauxAssistantMessage("ok")]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
});
let wrotePendingMessage = false;
harness.subscribe(async (event) => {
if (event.type === "message_end" && event.message.role === "assistant" && !wrotePendingMessage) {
wrotePendingMessage = true;
await harness.appendMessage({
role: "custom",
customType: "listener",
content: "listener write",
display: true,
timestamp: Date.now(),
} as AgentMessage);
}
});
await harness.prompt("hello");
const entries = await session.getEntries();
const roles = entries.flatMap((entry) => (entry.type === "message" ? [entry.message.role] : []));
expect(roles).toEqual(["user", "assistant", "custom"]);
});
it("waitForIdle waits for external run settlement and awaited listeners", async () => {
const registration = newFaux();
registration.setResponses([() => fauxAssistantMessage("ok")]);
const barrier = deferred();
const harness = new AgentHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
let listenerFinished = false;
harness.subscribe(async (event) => {
if (event.type === "agent_end") {
await barrier.promise;
listenerFinished = true;
}
});
const promptPromise = harness.prompt("hello");
let idleResolved = false;
const idlePromise = harness.waitForIdle().then(() => {
idleResolved = true;
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(idleResolved).toBe(false);
expect(listenerFinished).toBe(false);
barrier.resolve();
await Promise.all([promptPromise, idlePromise]);
expect(idleResolved).toBe(true);
expect(listenerFinished).toBe(true);
});
it("runs tool_call and tool_result hooks through the direct loop", async () => {
const registration = newFaux();
registration.setResponses([
() =>
fauxAssistantMessage(fauxToolCall("calculate", { expression: "2 + 2" }, { id: "call-1" }), {
stopReason: "toolUse",
}),
]);
const session = new Session(new InMemorySessionStorage());
const toolUsage = createUsage(1, 2, 3, 4);
const patchedToolUsage = createUsage(5, 6, 7, 8);
const calculateToolWithUsage = createCalculateToolWithUsage(toolUsage);
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
tools: [calculateToolWithUsage],
});
const seenToolCalls: Array<{ id: string; name: string; expression: unknown }> = [];
let seenToolUsage: Usage | undefined;
harness.on("tool_call", (event) => {
seenToolCalls.push({ id: event.toolCallId, name: event.toolName, expression: event.input.expression });
return undefined;
});
harness.on("tool_result", (event) => {
expect(event.toolCallId).toBe("call-1");
expect(event.toolName).toBe("calculate");
seenToolUsage = event.usage;
return {
content: [{ type: "text", text: "patched result" }],
details: { patched: true },
usage: patchedToolUsage,
terminate: true,
};
});
await harness.prompt("hello");
const toolResult = (await session.getEntries()).find(
(entry) => entry.type === "message" && entry.message.role === "toolResult",
);
expect(seenToolCalls).toEqual([{ id: "call-1", name: "calculate", expression: "2 + 2" }]);
expect(seenToolUsage).toEqual(toolUsage);
expect(toolResult).toMatchObject({
type: "message",
message: {
role: "toolResult",
content: [{ type: "text", text: "patched result" }],
details: { patched: true },
usage: patchedToolUsage,
},
});
});
it("passes a static application context to harness tools", async () => {
const registration = newFaux();
registration.setResponses([
() =>
fauxAssistantMessage(fauxToolCall("context", { expression: "2 + 2" }, { id: "call-1" }), {
stopReason: "toolUse",
}),
]);
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const toolContext = { env };
let receivedContext: typeof toolContext | undefined;
const contextTool: AgentHarnessTool<typeof toolContext, typeof calculateTool.parameters, undefined> = {
...calculateTool,
name: "context",
execute: async (toolCallId, params, signal, onUpdate, context) => {
receivedContext = context;
return { ...(await calculateTool.execute(toolCallId, params, signal, onUpdate)), terminate: true };
},
};
const harness = new AgentHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
tools: [contextTool],
toolContext,
});
await harness.prompt("hello");
expect(receivedContext).toBe(toolContext);
});
it("resolves async tool context providers for each turn snapshot", async () => {
const registration = newFaux();
registration.setResponses([
() =>
fauxAssistantMessage(fauxToolCall("context", { expression: "1 + 1" }, { id: "call-1" }), {
stopReason: "toolUse",
}),
() =>
fauxAssistantMessage(fauxToolCall("context", { expression: "2 + 2" }, { id: "call-2" }), {
stopReason: "toolUse",
}),
() => fauxAssistantMessage("done"),
]);
type ToolContext = { generation: number };
const generations: number[] = [];
const contextTool: AgentHarnessTool<ToolContext, typeof calculateTool.parameters, undefined> = {
...calculateTool,
name: "context",
execute: async (toolCallId, params, signal, onUpdate, context) => {
generations.push(context.generation);
return await calculateTool.execute(toolCallId, params, signal, onUpdate);
},
};
let generation = 0;
const harness = new AgentHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
tools: [contextTool],
toolContext: async (): Promise<ToolContext> => ({ generation: ++generation }),
});
await harness.prompt("hello");
expect(generations).toEqual([1, 2]);
});
it("persists generated compaction usage", async () => {
const registration = newFaux();
registration.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
const session = new Session(new InMemorySessionStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
});
const result = await harness.compact();
const compaction = (await session.getEntries()).find((entry) => entry.type === "compaction");
expect(result.usage?.totalTokens).toBeGreaterThan(0);
expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(result.usage);
});
it("persists hook-provided compaction usage", async () => {
const registration = newFaux();
const usage = createUsage(5, 6, 7, 8);
const session = new Session(new InMemorySessionStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
});
harness.on("session_before_compact", (event) => ({
compaction: {
summary: "hook summary",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
usage,
},
}));
const result = await harness.compact();
const compaction = (await session.getEntries()).find((entry) => entry.type === "compaction");
expect(result.usage).toEqual(usage);
expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(usage);
});
describe("summarization retries", () => {
it("retries transient compaction errors and emits retry events", async () => {
const registration = newFaux();
let calls = 0;
registration.setResponses([
() => {
calls++;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" });
},
() => {
calls++;
return fauxAssistantMessage("## Goal\nRecovered summary");
},
]);
const session = new Session(new InMemorySessionStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
});
const retryEvents: string[] = [];
harness.subscribe((event) => {
if (
event.type === "retry_scheduled" ||
event.type === "retry_attempt_start" ||
event.type === "retry_finished"
) {
retryEvents.push(`${event.type}:${event.operation}`);
}
});
const result = await harness.compact();
expect(result.summary).toContain("Recovered summary");
expect(calls).toBe(2);
expect(retryEvents).toEqual([
"retry_scheduled:compaction",
"retry_attempt_start:compaction",
"retry_finished:compaction",
]);
});
it("does not retry non-retryable compaction errors", async () => {
const registration = newFaux();
let calls = 0;
registration.setResponses([
() => {
calls++;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" });
},
]);
const session = new Session(new InMemorySessionStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
});
const retryEvents: string[] = [];
harness.subscribe((event) => {
if (
event.type === "retry_scheduled" ||
event.type === "retry_attempt_start" ||
event.type === "retry_finished"
) {
retryEvents.push(event.type);
}
});
await expect(harness.compact()).rejects.toThrow("insufficient_quota");
expect(calls).toBe(1);
expect(retryEvents).toEqual([]);
});
it("exhausts transient compaction retries after maxRetries failures", async () => {
const registration = newFaux();
let calls = 0;
registration.setResponses(
Array.from({ length: 4 }, () => () => {
calls++;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" });
}),
);
const session = new Session(new InMemorySessionStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 },
});
const retryEvents: string[] = [];
harness.subscribe((event) => {
if (
event.type === "retry_scheduled" ||
event.type === "retry_attempt_start" ||
event.type === "retry_finished"
) {
retryEvents.push(`${event.type}:${event.operation}`);
}
});
await expect(harness.compact()).rejects.toThrow("terminated");
expect(calls).toBe(4);
expect(retryEvents).toEqual([
"retry_scheduled:compaction",
"retry_attempt_start:compaction",
"retry_scheduled:compaction",
"retry_attempt_start:compaction",
"retry_scheduled:compaction",
"retry_attempt_start:compaction",
"retry_finished:compaction",
]);
});
it("retries transient branch summary errors and emits retry events", async () => {
const registration = newFaux();
let calls = 0;
registration.setResponses([
() => {
calls++;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" });
},
() => {
calls++;
return fauxAssistantMessage("## Goal\nRecovered branch summary");
},
]);
const session = new Session(new InMemorySessionStorage());
const targetId = await session.appendMessage(createUserMessage("first branch"));
await session.appendMessage(createAssistantMessage("first reply"));
await session.appendMessage(createUserMessage("abandoned work"));
await session.appendMessage(createAssistantMessage("abandoned reply"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
});
const retryEvents: string[] = [];
harness.subscribe((event) => {
if (
event.type === "retry_scheduled" ||
event.type === "retry_attempt_start" ||
event.type === "retry_finished"
) {
retryEvents.push(`${event.type}:${event.operation}`);
}
});
const result = await harness.navigateTree(targetId, { summarize: true });
expect(result.summaryEntry?.summary).toContain("Recovered branch summary");
expect(calls).toBe(2);
expect(retryEvents).toEqual([
"retry_scheduled:branch_summary",
"retry_attempt_start:branch_summary",
"retry_finished:branch_summary",
]);
});
});
it("persists generated branch summary usage", async () => {
const registration = newFaux();
registration.setResponses([fauxAssistantMessage("## Goal\nBranch summary")]);
const session = new Session(new InMemorySessionStorage());
const targetId = await session.appendMessage(createUserMessage("first branch"));
await session.appendMessage(createAssistantMessage("first reply"));
await session.appendMessage(createUserMessage("abandoned work"));
await session.appendMessage(createAssistantMessage("abandoned reply"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
});
const result = await harness.navigateTree(targetId, { summarize: true });
expect(result.summaryEntry?.usage?.totalTokens).toBeGreaterThan(0);
});
it("persists hook-provided branch summary usage", async () => {
const registration = newFaux();
const usage = createUsage(13, 14, 15, 16);
const session = new Session(new InMemorySessionStorage());
const targetId = await session.appendMessage(createUserMessage("first branch"));
await session.appendMessage(createAssistantMessage("first reply"));
await session.appendMessage(createUserMessage("abandoned work"));
await session.appendMessage(createAssistantMessage("abandoned reply"));
const harness = new AgentHarness({
models,
session,
model: registration.getModel(),
});
harness.on("session_before_tree", () => ({
summary: { summary: "hook branch summary", usage },
}));
const result = await harness.navigateTree(targetId, { summarize: true });
expect(result.summaryEntry?.usage).toEqual(usage);
});
it("preserves app tool types for getters and update events", async () => {
const session = new Session(new InMemorySessionStorage());
const model = getModel("anthropic", "claude-sonnet-4-5");
type AppTool = AgentTool<typeof calculateTool.parameters, undefined> & { source: "builtin" | "extension" };
const inspectTool: AppTool = { ...calculateTool, name: "inspect", source: "builtin" };
const searchTool: AppTool = { ...calculateTool, name: "search", source: "extension" };
const harness = new AgentHarness<undefined, AppSkill, AppPromptTemplate, AppTool>({
models,
session,
model,
tools: [inspectTool, searchTool],
activeToolNames: ["inspect"],
});
const updates: Array<{
toolNames: string[];
previousToolNames: string[];
activeToolNames: string[];
previousActiveToolNames: string[];
source: "set" | "restore";
}> = [];
harness.subscribe((event) => {
if (event.type === "tools_update") {
updates.push({
toolNames: event.toolNames,
previousToolNames: event.previousToolNames,
activeToolNames: event.activeToolNames,
previousActiveToolNames: event.previousActiveToolNames,
source: event.source,
});
expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(event.activeToolNames);
}
});
const tools = harness.getTools();
const activeTools = harness.getActiveTools();
tools.pop();
activeTools.pop();
expect(harness.getTools().map((tool) => tool.name)).toEqual(["inspect", "search"]);
expect(harness.getActiveTools().map((tool) => tool.source)).toEqual(["builtin"]);
await harness.setActiveTools(["search"]);
await harness.setTools([searchTool], ["search"]);
await expect(harness.setActiveTools(["missing"])).rejects.toMatchObject({ code: "invalid_argument" });
await expect(harness.setActiveTools(["search", "search"])).rejects.toMatchObject({ code: "invalid_argument" });
await expect(harness.setTools([inspectTool])).rejects.toMatchObject({ code: "invalid_argument" });
await expect(harness.setTools([inspectTool, inspectTool], ["inspect"])).rejects.toMatchObject({
code: "invalid_argument",
});
expect(updates).toEqual([
{
toolNames: ["inspect", "search"],
previousToolNames: ["inspect", "search"],
activeToolNames: ["search"],
previousActiveToolNames: ["inspect"],
source: "set",
},
{
toolNames: ["search"],
previousToolNames: ["inspect", "search"],
activeToolNames: ["search"],
previousActiveToolNames: ["search"],
source: "set",
},
]);
expect(harness.getTools().map((tool) => tool.source)).toEqual(["extension"]);
expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(["search"]);
expect((await session.buildContext()).activeToolNames).toEqual(["search"]);
});
it("validates constructor tool names", () => {
const session = new Session(new InMemorySessionStorage());
const model = getModel("anthropic", "claude-sonnet-4-5");
expect(
() => new AgentHarness({ session, models, model, tools: [calculateTool], activeToolNames: ["missing"] }),
).toThrow(/Unknown tool/);
expect(
() =>
new AgentHarness({
models,
session,
model,
tools: [calculateTool, calculateTool],
activeToolNames: [calculateTool.name],
}),
).toThrow(/Duplicate tool/);
expect(
() =>
new AgentHarness({
models,
session,
model,
tools: [calculateTool],
activeToolNames: [calculateTool.name, calculateTool.name],
}),
).toThrow(/Duplicate active tool/);
});
it("preserves app resource types for getters and update events", async () => {
const session = new Session(new InMemorySessionStorage());
const model = getModel("anthropic", "claude-sonnet-4-5");
const harness = new AgentHarness<undefined, AppSkill, AppPromptTemplate, AgentTool>({
session,
models,
model,
});
const skill: AppSkill = {
name: "inspect",
description: "Inspect things",
content: "Use inspection tools.",
filePath: "/skills/inspect/SKILL.md",
source: "project",
};
const promptTemplate: AppPromptTemplate = { name: "review", content: "Review $1", source: "user" };
const resources = { skills: [skill], promptTemplates: [promptTemplate] };
const updates: Array<{ resourcesSource?: string; previousSource?: string }> = [];
harness.subscribe((event) => {
if (event.type === "resources_update") {
updates.push({
resourcesSource: event.resources.skills?.[0]?.source,
previousSource: event.previousResources.skills?.[0]?.source,
});
}
});
await harness.setResources(resources);
await harness.setResources(resources);
const resolved = harness.getResources();
expect(updates).toEqual([
{ resourcesSource: "project", previousSource: undefined },
{ resourcesSource: "project", previousSource: "project" },
]);
expect(resolved.skills?.[0]?.source).toBe("project");
expect(resolved.promptTemplates?.[0]?.source).toBe("user");
expect(resolved.skills).not.toBe(resources.skills);
expect(resolved.promptTemplates).not.toBe(resources.promptTemplates);
});
});
@@ -0,0 +1,744 @@
import {
type AssistantMessage,
createModels,
type FauxProviderHandle,
fauxAssistantMessage,
fauxProvider,
type Message,
type Model,
type Models,
type Usage,
} from "@earendil-works/pi-ai";
import { beforeEach, describe, expect, it } from "vitest";
import {
type CompactionPreparation,
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
estimateTokens,
findCutPoint,
findTurnStartIndex,
generateSummary,
generateSummaryWithUsage,
getLastAssistantUsage,
prepareCompaction,
serializeConversation,
shouldCompact,
} from "../../src/harness/compaction/compaction.ts";
import { buildSessionContext } from "../../src/harness/session/session.ts";
import type {
BranchSummaryEntry,
CompactionEntry,
CompactionSettings,
CustomMessageEntry,
MessageEntry,
ModelChangeEntry,
SessionTreeEntry,
ThinkingLevelChangeEntry,
} from "../../src/harness/types.ts";
import { getOrThrow } from "../../src/harness/types.ts";
import type { AgentMessage } from "../../src/types.ts";
let nextId = 0;
function createId(): string {
return `entry-${nextId++}`;
}
function createMockUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage {
return {
input,
output,
cacheRead,
cacheWrite,
totalTokens: input + output + cacheRead + cacheWrite,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function createUserMessage(text: string): AgentMessage {
return {
role: "user",
content: [{ type: "text", text }],
timestamp: Date.now(),
};
}
function createAssistantMessage(text: string, usage = createMockUsage(100, 50)): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage,
stopReason: "stop",
timestamp: Date.now(),
};
}
function createMessageEntry(message: AgentMessage, parentId: string | null = null): MessageEntry {
return {
type: "message",
id: createId(),
parentId,
timestamp: new Date().toISOString(),
message,
};
}
function createCompactionEntry(
summary: string,
firstKeptEntryId: string,
parentId: string | null = null,
retainedTail?: AgentMessage[],
): CompactionEntry {
return {
type: "compaction",
id: createId(),
parentId,
timestamp: new Date().toISOString(),
summary,
firstKeptEntryId,
tokensBefore: 1234,
retainedTail,
};
}
function createThinkingLevelEntry(level: string, parentId: string | null = null): ThinkingLevelChangeEntry {
return {
type: "thinking_level_change",
id: createId(),
parentId,
timestamp: new Date().toISOString(),
thinkingLevel: level,
};
}
function createModelChangeEntry(provider: string, modelId: string, parentId: string | null = null): ModelChangeEntry {
return {
type: "model_change",
id: createId(),
parentId,
timestamp: new Date().toISOString(),
provider,
modelId,
};
}
/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */
const models = createModels();
let fauxCount = 0;
function createFauxModel(reasoning: boolean, maxTokens = 8192): { faux: FauxProviderHandle; model: Model<string> } {
const faux = fauxProvider({
provider: `faux-${++fauxCount}`,
models: [
{
id: reasoning ? "reasoning-model" : "non-reasoning-model",
reasoning,
contextWindow: 200000,
maxTokens,
},
],
});
models.setProvider(faux.provider);
return { faux, model: faux.getModel() };
}
function createModelsWithSimpleResponses(responses: AssistantMessage[]): Models {
const remaining = [...responses];
const stub = Object.create(models) as Models;
stub.completeSimple = async () => {
const response = remaining.shift();
if (!response) throw new Error("No faux completeSimple response queued");
return response;
};
return stub;
}
describe("harness compaction", () => {
beforeEach(() => {
nextId = 0;
});
it("calculates total context tokens from usage", () => {
expect(calculateContextTokens(createMockUsage(1000, 500, 200, 100))).toBe(1800);
expect(calculateContextTokens(createMockUsage(0, 0, 0, 0))).toBe(0);
});
it("checks compaction threshold", () => {
const settings: CompactionSettings = {
enabled: true,
reserveTokens: 10000,
keepRecentTokens: 20000,
};
expect(shouldCompact(95000, 100000, settings)).toBe(true);
expect(shouldCompact(89000, 100000, settings)).toBe(false);
expect(shouldCompact(95000, 100000, { ...settings, enabled: false })).toBe(false);
});
it("finds a cut point based on token differences", () => {
const entries: SessionTreeEntry[] = [];
let parentId: string | null = null;
for (let i = 0; i < 10; i++) {
const user = createMessageEntry(createUserMessage(`User ${i}`), parentId);
entries.push(user);
const assistant = createMessageEntry(
createAssistantMessage(`Assistant ${i}`, createMockUsage(0, 100, (i + 1) * 1000, 0)),
user.id,
);
entries.push(assistant);
parentId = assistant.id;
}
const result = findCutPoint(entries, 0, entries.length, 2500);
expect(entries[result.firstKeptEntryIndex]?.type).toBe("message");
});
it("covers cut-point and turn-start edge cases", () => {
const thinking = createThinkingLevelEntry("high");
const modelChange = createModelChangeEntry("openai", "gpt-4", thinking.id);
expect(findCutPoint([thinking, modelChange], 0, 2, 1)).toEqual({
firstKeptEntryIndex: 0,
turnStartIndex: -1,
isSplitTurn: false,
});
const branchSummary: BranchSummaryEntry = {
type: "branch_summary",
id: createId(),
parentId: modelChange.id,
timestamp: new Date().toISOString(),
fromId: "branch",
summary: "branch summary",
};
const customMessage: CustomMessageEntry = {
type: "custom_message",
id: createId(),
parentId: branchSummary.id,
timestamp: new Date().toISOString(),
customType: "note",
content: "custom content",
display: true,
};
expect(findTurnStartIndex([thinking, branchSummary], 1, 0)).toBe(1);
expect(findTurnStartIndex([thinking, customMessage], 1, 0)).toBe(1);
expect(findTurnStartIndex([thinking, modelChange], 1, 0)).toBe(-1);
const result = findCutPoint([thinking, branchSummary, customMessage], 0, 3, 1);
expect(result.firstKeptEntryIndex).toBe(0);
const toolResult = createMessageEntry({
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "tool output" }],
isError: false,
timestamp: Date.now(),
});
expect(findCutPoint([toolResult], 0, 1, 1)).toEqual({
firstKeptEntryIndex: 0,
turnStartIndex: -1,
isSplitTurn: false,
});
const user = createMessageEntry(createUserMessage("user"));
const compaction = createCompactionEntry("summary", user.id, user.id);
const assistant = createMessageEntry(createAssistantMessage("assistant"), compaction.id);
expect(findCutPoint([user, compaction, assistant], 0, 3, 1).firstKeptEntryIndex).toBe(2);
});
it("estimates tokens and context usage across supported message roles", () => {
const usage = createMockUsage(10, 5, 3, 2);
const assistant = createAssistantMessage("assistant", usage);
const assistantWithThinkingAndTool: AssistantMessage = {
...assistant,
content: [
{ type: "thinking", thinking: "thinking" },
{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "file.ts" } },
],
};
const customString: AgentMessage = {
role: "custom",
customType: "note",
content: "custom text",
display: true,
timestamp: Date.now(),
};
const toolResultWithImage: AgentMessage = {
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [
{ type: "text", text: "tool text" },
{ type: "image", mimeType: "image/png", data: "abc" },
],
isError: false,
timestamp: Date.now(),
};
const bashExecution: AgentMessage = {
role: "bashExecution",
command: "npm run check",
output: "ok",
exitCode: 0,
cancelled: false,
truncated: false,
timestamp: Date.now(),
};
const branchSummaryMessage: AgentMessage = {
role: "branchSummary",
summary: "branch",
fromId: "x",
timestamp: Date.now(),
};
const compactionSummaryMessage: AgentMessage = {
role: "compactionSummary",
summary: "compact",
tokensBefore: 123,
timestamp: Date.now(),
};
expect(estimateTokens({ role: "user", content: "plain user", timestamp: Date.now() })).toBeGreaterThan(0);
expect(estimateTokens(assistantWithThinkingAndTool)).toBeGreaterThan(0);
expect(estimateTokens(customString)).toBeGreaterThan(0);
expect(estimateTokens(toolResultWithImage)).toBeGreaterThan(1000);
expect(estimateTokens(bashExecution)).toBeGreaterThan(0);
expect(estimateTokens(branchSummaryMessage)).toBeGreaterThan(0);
expect(estimateTokens(compactionSummaryMessage)).toBeGreaterThan(0);
expect(estimateTokens({ role: "unknown", timestamp: Date.now() } as unknown as AgentMessage)).toBe(0);
expect(
getLastAssistantUsage([createMessageEntry(createUserMessage("user")), createMessageEntry(assistant)]),
).toBe(usage);
expect(
getLastAssistantUsage([
createMessageEntry({ ...assistant, stopReason: "aborted" }),
createMessageEntry({ ...assistant, stopReason: "error" }),
]),
).toBeUndefined();
expect(
getLastAssistantUsage([
createMessageEntry(createUserMessage("user")),
createMessageEntry(assistant),
createMessageEntry(createAssistantMessage("partial", createMockUsage(0, 0))),
]),
).toBe(usage);
expect(estimateContextTokens([createUserMessage("no usage")]).lastUsageIndex).toBeNull();
expect(estimateContextTokens([assistant, createUserMessage("tail")])).toMatchObject({
usageTokens: 20,
lastUsageIndex: 0,
});
const estimate = estimateContextTokens([
createUserMessage("Hello"),
assistant,
createUserMessage("continue"),
createAssistantMessage("Partial thinking", createMockUsage(0, 0)),
]);
expect(estimate.usageTokens).toBe(20);
expect(estimate.lastUsageIndex).toBe(1);
expect(estimate.trailingTokens).toBeGreaterThan(0);
expect(estimate.tokens).toBe(20 + estimate.trailingTokens);
});
it("builds session context with a compaction entry", () => {
const u1 = createMessageEntry(createUserMessage("1"));
const a1 = createMessageEntry(createAssistantMessage("a"), u1.id);
const u2 = createMessageEntry(createUserMessage("2"), a1.id);
const a2 = createMessageEntry(createAssistantMessage("b"), u2.id);
const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id, [
createUserMessage("2"),
createAssistantMessage("b"),
]);
const u3 = createMessageEntry(createUserMessage("3"), compaction.id);
const a3 = createMessageEntry(createAssistantMessage("c"), u3.id);
const loaded = buildSessionContext([u1, a1, u2, a2, compaction, u3, a3]);
expect(loaded.messages).toHaveLength(5);
expect(loaded.messages[0]?.role).toBe("compactionSummary");
expect(loaded.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"user",
"assistant",
"user",
"assistant",
]);
});
it("falls back to firstKeptEntryId when a compaction has no retained tail", () => {
const u1 = createMessageEntry(createUserMessage("1"));
const a1 = createMessageEntry(createAssistantMessage("a"), u1.id);
const u2 = createMessageEntry(createUserMessage("2"), a1.id);
const a2 = createMessageEntry(createAssistantMessage("b"), u2.id);
const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id);
const u3 = createMessageEntry(createUserMessage("3"), compaction.id);
const loaded = buildSessionContext([u1, a1, u2, a2, compaction, u3]);
expect(loaded.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"user",
"assistant",
"user",
]);
});
it("tracks model and thinking level changes in built context", () => {
const user = createMessageEntry(createUserMessage("1"));
const modelChange = createModelChangeEntry("openai", "gpt-4", user.id);
const assistant = createMessageEntry(createAssistantMessage("a"), modelChange.id);
const thinkingChange = createThinkingLevelEntry("high", assistant.id);
const loaded = buildSessionContext([user, modelChange, assistant, thinkingChange]);
expect(loaded.model).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
expect(loaded.thinkingLevel).toBe("high");
});
it("prepares compaction using the latest compaction summary as previousSummary", () => {
const u1 = createMessageEntry(createUserMessage("user msg 1"));
const a1 = createMessageEntry(createAssistantMessage("assistant msg 1"), u1.id);
const u2 = createMessageEntry(createUserMessage("user msg 2"), a1.id);
const a2 = createMessageEntry(createAssistantMessage("assistant msg 2", createMockUsage(5000, 1000)), u2.id);
const compaction1 = createCompactionEntry("First summary", u2.id, a2.id);
const u3 = createMessageEntry(createUserMessage("user msg 3"), compaction1.id);
const a3 = createMessageEntry(createAssistantMessage("assistant msg 3", createMockUsage(8000, 2000)), u3.id);
const pathEntries = [u1, a1, u2, a2, compaction1, u3, a3];
const preparation = getOrThrow(prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS));
expect(preparation).toBeDefined();
expect(preparation?.previousSummary).toBe("First summary");
expect(preparation?.firstKeptEntryId).toBeTruthy();
expect(preparation?.retainedTail.length).toBeGreaterThan(0);
expect(preparation?.tokensBefore).toBe(estimateContextTokens(buildSessionContext(pathEntries).messages).tokens);
});
it("prepares split-turn compaction with prior file-operation details", () => {
const u1 = createMessageEntry(createUserMessage("user msg 1"));
const assistantMessage: AssistantMessage = {
...createAssistantMessage("assistant msg 1"),
content: [{ type: "toolCall", id: "tool-1", name: "write", arguments: { path: "written.ts" } }],
};
const a1 = createMessageEntry(assistantMessage, u1.id);
const compaction1: CompactionEntry = {
...createCompactionEntry("First summary", u1.id, a1.id),
details: { readFiles: ["old-read.ts"], modifiedFiles: ["old-edit.ts"] },
};
const u2 = createMessageEntry(createUserMessage("large turn"), compaction1.id);
const a2 = createMessageEntry(createAssistantMessage("large assistant message"), u2.id);
const preparation = getOrThrow(
prepareCompaction([u1, a1, compaction1, u2, a2], {
enabled: true,
reserveTokens: 100,
keepRecentTokens: 1,
}),
);
expect(preparation).toMatchObject({ previousSummary: "First summary", isSplitTurn: true });
expect(preparation?.turnPrefixMessages.map((message) => message.role)).toEqual(["user"]);
expect([...preparation!.fileOps.read]).toContain("old-read.ts");
expect([...preparation!.fileOps.edited]).toContain("old-edit.ts");
expect([...preparation!.fileOps.written]).toContain("written.ts");
});
it("prepares custom and branch summary entries for summarization", () => {
const branchSummary: BranchSummaryEntry = {
type: "branch_summary",
id: createId(),
parentId: null,
timestamp: new Date().toISOString(),
fromId: "branch",
summary: "branch summary",
};
const customMessage: CustomMessageEntry = {
type: "custom_message",
id: createId(),
parentId: branchSummary.id,
timestamp: new Date().toISOString(),
customType: "note",
content: "custom content",
display: true,
};
const user = createMessageEntry(createUserMessage("keep"), customMessage.id);
const assistant = createMessageEntry(createAssistantMessage("assistant"), user.id);
const preparation = getOrThrow(
prepareCompaction([branchSummary, customMessage, user, assistant], {
enabled: true,
reserveTokens: 100,
keepRecentTokens: 1,
}),
);
expect(preparation?.messagesToSummarize.map((message) => message.role)).toEqual(["branchSummary", "custom"]);
});
it("does not prepare compaction when there is nothing valid to compact", () => {
const compaction = createCompactionEntry("already compacted", "entry-keep");
expect(getOrThrow(prepareCompaction([compaction], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined();
expect(getOrThrow(prepareCompaction([], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined();
});
it("serializes conversation with truncated tool results", () => {
const longContent = "x".repeat(5000);
const messages = convertMessages([
{
role: "toolResult",
toolCallId: "tc1",
toolName: "read",
content: [{ type: "text", text: longContent }],
isError: false,
timestamp: Date.now(),
},
]);
const result = serializeConversation(messages);
expect(result).toContain("[Tool result]:");
expect(result).toContain("[... 3000 more characters truncated]");
});
it("passes reasoning through generateSummary only for reasoning models with thinking enabled", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const seenOptions: Array<Record<string, unknown> | undefined> = [];
const { faux: fauxReasoning, model: reasoningModel } = createFauxModel(true);
fauxReasoning.setResponses([
(_context, options) => {
seenOptions.push(options as Record<string, unknown> | undefined);
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
getOrThrow(
await generateSummary(messages, models, reasoningModel, 2000, undefined, undefined, undefined, "medium"),
);
expect(seenOptions[0]).toMatchObject({ reasoning: "medium" });
const { faux: fauxOff, model: offModel } = createFauxModel(true);
fauxOff.setResponses([
(_context, options) => {
seenOptions.push(options as Record<string, unknown> | undefined);
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
getOrThrow(await generateSummary(messages, models, offModel, 2000, undefined, undefined, undefined, "off"));
expect(seenOptions[1]).not.toHaveProperty("reasoning");
const { faux: fauxNonReasoning, model: nonReasoningModel } = createFauxModel(false);
fauxNonReasoning.setResponses([
(_context, options) => {
seenOptions.push(options as Record<string, unknown> | undefined);
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
getOrThrow(
await generateSummary(messages, models, nonReasoningModel, 2000, undefined, undefined, undefined, "medium"),
);
expect(seenOptions[2]).not.toHaveProperty("reasoning");
});
it("includes previous summaries and custom instructions in generateSummary prompts", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
let promptText = "";
const { faux, model } = createFauxModel(false);
faux.setResponses([
(context) => {
const message = context.messages[0];
const content = message?.role === "user" ? message.content : [];
promptText = Array.isArray(content) && content[0]?.type === "text" ? content[0].text : "";
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
const summary = getOrThrow(
await generateSummaryWithUsage(messages, models, model, 2000, undefined, "focus", "old summary"),
);
expect(summary.text).toContain("Test summary");
expect(summary.usage.input).toBeGreaterThan(0);
expect(summary.usage.output).toBeGreaterThan(0);
expect(summary.usage.totalTokens).toBe(
summary.usage.input + summary.usage.output + summary.usage.cacheRead + summary.usage.cacheWrite,
);
expect(promptText).toContain("<previous-summary>\nold summary\n</previous-summary>");
expect(promptText).toContain("Additional focus: focus");
});
it("preserves the string result from generateSummary", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const { faux, model } = createFauxModel(false);
faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
expect(getOrThrow(await generateSummary(messages, models, model, 2000))).toBe("## Goal\nTest summary");
});
it("returns error results for failed or aborted summary generations", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const { faux: errorFaux, model: errorModel } = createFauxModel(false);
errorFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "boom" })]);
const errorResult = await generateSummary(messages, models, errorModel, 2000);
expect(errorResult).toMatchObject({
ok: false,
error: { code: "summarization_failed", message: "Summarization failed: boom" },
});
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "stopped" })]);
const abortedResult = await generateSummary(messages, models, abortedModel, 2000);
expect(abortedResult).toMatchObject({ ok: false, error: { code: "aborted", message: "stopped" } });
});
it("clamps compaction summary maxTokens to the model output cap", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const seenOptions: Array<Record<string, unknown> | undefined> = [];
const { faux, model } = createFauxModel(false, 128000);
faux.setResponses([
(_context, options) => {
seenOptions.push(options as Record<string, unknown> | undefined);
return fauxAssistantMessage("## Goal\nTest summary");
},
(_context, options) => {
seenOptions.push(options as Record<string, unknown> | undefined);
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: messages,
turnPrefixMessages: messages,
retainedTail: messages,
isSplitTurn: true,
tokensBefore: 600000,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 },
};
getOrThrow(await compact(preparation, models, model));
expect(seenOptions.map((options) => options?.maxTokens)).toEqual([128000, 128000]);
expect(seenOptions.map((options) => options?.cacheRetention)).toEqual(["none", "none"]);
const sessionIds = seenOptions.map((options) => options?.sessionId);
expect(sessionIds[0]).not.toBe(sessionIds[1]);
});
it("returns compaction error results without throwing", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: messages,
turnPrefixMessages: [],
retainedTail: messages,
isSplitTurn: false,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
const { faux: historyFaux, model: historyModel } = createFauxModel(false);
historyFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "history failed" })]);
expect(await compact(preparation, models, historyModel)).toMatchObject({
ok: false,
error: { code: "summarization_failed", message: "Summarization failed: history failed" },
});
const { model: invalidModel } = createFauxModel(false);
const invalidResult = await compact(
{ ...preparation, messagesToSummarize: [], firstKeptEntryId: "" },
models,
invalidModel,
);
expect(invalidResult).toMatchObject({ ok: false, error: { code: "invalid_session" } });
});
it("combines usage for split-turn compaction summaries", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const { model } = createFauxModel(false);
const historyUsage = createMockUsage(1, 2, 3, 4);
const turnPrefixUsage = createMockUsage(5, 6, 7, 8);
const usageModels = createModelsWithSimpleResponses([
{ ...fauxAssistantMessage("history summary"), usage: historyUsage },
{ ...fauxAssistantMessage("turn prefix summary"), usage: turnPrefixUsage },
]);
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: messages,
turnPrefixMessages: messages,
isSplitTurn: true,
tokensBefore: 100,
retainedTail: messages,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
const result = getOrThrow(await compact(preparation, usageModels, model));
expect(result.usage).toEqual(createMockUsage(6, 8, 10, 12));
});
it("passes reasoning through turn-prefix summaries when enabled", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const seenOptions: Array<Record<string, unknown> | undefined> = [];
const { faux, model } = createFauxModel(true);
faux.setResponses([
(_context, options) => {
seenOptions.push(options as Record<string, unknown> | undefined);
return fauxAssistantMessage("## Original Request\nTest summary");
},
]);
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: [],
turnPrefixMessages: messages,
retainedTail: messages,
isSplitTurn: true,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
getOrThrow(await compact(preparation, models, model, undefined, undefined, "high"));
expect(seenOptions[0]).toMatchObject({ reasoning: "high" });
});
it("returns turn-prefix compaction errors without throwing", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: [],
turnPrefixMessages: messages,
retainedTail: messages,
isSplitTurn: true,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
const { faux, model } = createFauxModel(false);
faux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "prefix failed" })]);
expect(await compact(preparation, models, model)).toMatchObject({
ok: false,
error: { code: "summarization_failed", message: "Turn prefix summarization failed: prefix failed" },
});
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "prefix stopped" })]);
expect(await compact(preparation, models, abortedModel)).toMatchObject({
ok: false,
error: { code: "aborted", message: "prefix stopped" },
});
});
it("returns a compaction result with file details", async () => {
const u1 = createMessageEntry(createUserMessage("read a file"));
const assistantMessage: AssistantMessage = {
...createAssistantMessage("calling tool", createMockUsage(1000, 200)),
content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "src/index.ts" } }],
};
const a1 = createMessageEntry(assistantMessage, u1.id);
const u2 = createMessageEntry(createUserMessage("continue"), a1.id);
const a2 = createMessageEntry(createAssistantMessage("done", createMockUsage(4000, 500)), u2.id);
const preparation = getOrThrow(prepareCompaction([u1, a1, u2, a2], DEFAULT_COMPACTION_SETTINGS));
expect(preparation).toBeDefined();
const { faux, model } = createFauxModel(false);
faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
const result = getOrThrow(await compact(preparation!, models, model));
expect(result.summary.length).toBeGreaterThan(0);
expect(result.firstKeptEntryId).toBeTruthy();
expect(result.usage?.totalTokens).toBeGreaterThan(0);
expect(result.retainedTail?.length).toBeGreaterThan(0);
expect(result.details).toBeDefined();
});
});
function convertMessages(messages: Message[]): Message[] {
return messages;
}
@@ -0,0 +1,448 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { access, chmod, realpath, symlink } from "node:fs/promises";
import { homedir } from "node:os";
import { delimiter, join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { FileError, getOrThrow } from "../../src/harness/types.ts";
import { executeShellWithCapture } from "../../src/harness/utils/shell-output.ts";
import { createTempDir } from "./session-test-utils.ts";
const chmodRestorePaths: string[] = [];
function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout?: () => void): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
onTimeout?.();
reject(new Error(`Timed out after ${ms}ms`));
}, ms);
promise.then(
(value) => {
clearTimeout(timeoutId);
resolve(value);
},
(error: unknown) => {
clearTimeout(timeoutId);
reject(error);
},
);
});
}
function toBashSingleQuotedArg(value: string): string {
return `'${value.replace(/\\/g, "/").replace(/'/g, `'"'"'`)}'`;
}
function createInheritedStdioCommand(pidFile: string): string {
return (
'node -e "' +
"const fs=require('fs');" +
"const {spawn}=require('child_process');" +
"const child=spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{stdio:'inherit',detached:true});" +
"fs.writeFileSync(process.argv[1], String(child.pid));" +
"child.unref();" +
"console.log('child-exiting');" +
'" ' +
toBashSingleQuotedArg(pidFile)
);
}
function cleanupDetachedChild(pidFile: string): void {
if (!existsSync(pidFile)) return;
const pid = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10);
if (!Number.isFinite(pid) || pid <= 0) return;
try {
execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
} catch {}
}
afterEach(async () => {
for (const path of chmodRestorePaths.splice(0)) {
try {
await access(path);
await chmod(path, 0o700);
} catch {}
}
});
describe("NodeExecutionEnv", () => {
it("reads, writes, lists, and removes files and directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
expect(getOrThrow(await env.absolutePath("nested/child"))).toBe(join(root, "nested/child"));
expect(getOrThrow(await env.joinPath([root, "nested", "child"]))).toBe(join(root, "nested", "child"));
getOrThrow(await env.createDir("nested/child"));
getOrThrow(await env.writeFile("nested/child/file.txt", "hel"));
getOrThrow(await env.appendFile("nested/child/file.txt", "lo"));
expect(getOrThrow(await env.readTextFile("nested/child/file.txt"))).toBe("hello");
expect(getOrThrow(await env.readTextLines("nested/child/file.txt", { maxLines: 1 }))).toEqual(["hello"]);
expect(Buffer.from(getOrThrow(await env.readBinaryFile("nested/child/file.txt"))).toString("utf8")).toBe("hello");
const entries = getOrThrow(await env.listDir("nested/child"));
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
name: "file.txt",
path: join(root, "nested/child/file.txt"),
kind: "file",
size: 5,
});
expect(typeof entries[0]!.mtimeMs).toBe("number");
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(true);
getOrThrow(await env.remove("nested/child/file.txt"));
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(false);
});
it("expands home-relative paths and file URLs", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
expect(getOrThrow(await env.absolutePath("~/pi-node-env-test"))).toBe(join(homedir(), "pi-node-env-test"));
const filePath = join(root, "file with spaces.txt");
expect(getOrThrow(await env.absolutePath(pathToFileURL(filePath).href))).toBe(filePath);
});
it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.createDir("dir", { recursive: true }));
getOrThrow(await env.writeFile("dir/file.txt", "hello"));
await symlink(join(root, "dir/file.txt"), join(root, "file-link"));
await symlink(join(root, "dir"), join(root, "dir-link"));
expect(getOrThrow(await env.fileInfo("dir"))).toMatchObject({
name: "dir",
path: join(root, "dir"),
kind: "directory",
});
expect(getOrThrow(await env.fileInfo("dir/file.txt"))).toMatchObject({
name: "file.txt",
path: join(root, "dir/file.txt"),
kind: "file",
size: 5,
});
expect(getOrThrow(await env.fileInfo("file-link"))).toMatchObject({
name: "file-link",
path: join(root, "file-link"),
kind: "symlink",
});
expect(getOrThrow(await env.fileInfo("dir-link"))).toMatchObject({
name: "dir-link",
path: join(root, "dir-link"),
kind: "symlink",
});
expect(getOrThrow(await env.canonicalPath("file-link"))).toBe(await realpath(join(root, "dir/file.txt")));
});
it("lists symlinks as symlinks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile("target.txt", "hello"));
await symlink(join(root, "target.txt"), join(root, "link.txt"));
const entries = getOrThrow(await env.listDir("."));
expect(
entries.map((entry) => ({ name: entry.name, kind: entry.kind })).sort((a, b) => a.name.localeCompare(b.name)),
).toEqual([
{ name: "link.txt", kind: "symlink" },
{ name: "target.txt", kind: "file" },
]);
});
it("stops reading text lines at the requested limit", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile("file.txt", "one\ntwo\nthree"));
expect(getOrThrow(await env.readTextLines("file.txt", { maxLines: 1 }))).toEqual(["one"]);
});
it("returns FileError for missing paths and keeps exists false for missing paths", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const info = await env.fileInfo("missing.txt");
expect(info.ok).toBe(false);
if (!info.ok) {
expect(info.error).toBeInstanceOf(FileError);
expect(info.error).toMatchObject({
name: "FileError",
code: "not_found",
path: join(root, "missing.txt"),
});
}
expect(getOrThrow(await env.exists("missing.txt"))).toBe(false);
});
it("returns FileError for listing non-directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile("file.txt", "hello"));
const result = await env.listDir("file.txt");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(FileError);
expect(result.error).toMatchObject({ code: "not_directory" });
}
});
it("appends to new files and creates parent directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.appendFile("new/nested/file.txt", "a"));
getOrThrow(await env.appendFile("new/nested/file.txt", "b"));
expect(getOrThrow(await env.readTextFile("new/nested/file.txt"))).toBe("ab");
});
it("creates temporary directories and files", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const tempDir = getOrThrow(await env.createTempDir("node-env-test-"));
await expect(access(tempDir)).resolves.toBeUndefined();
const tempFile = getOrThrow(await env.createTempFile({ prefix: "prefix-", suffix: ".txt" }));
await expect(access(tempFile)).resolves.toBeUndefined();
expect(tempFile.endsWith(".txt")).toBe(true);
});
it("honors createDir recursive false and remove recursive/force options", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const createResult = await env.createDir("missing/child", { recursive: false });
expect(createResult.ok).toBe(false);
if (!createResult.ok) expect(createResult.error).toMatchObject({ code: "not_found" });
getOrThrow(await env.writeFile("dir/child/file.txt", "hello"));
const removeDirectory = await env.remove("dir", { recursive: false });
expect(removeDirectory.ok).toBe(false);
getOrThrow(await env.remove("dir", { recursive: true }));
expect(getOrThrow(await env.exists("dir"))).toBe(false);
const removeMissing = await env.remove("missing", { force: false });
expect(removeMissing.ok).toBe(false);
getOrThrow(await env.remove("missing", { force: true }));
});
it("returns aborted results for pre-aborted cancellable file operations", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile("file.txt", "hello"));
const controller = new AbortController();
controller.abort();
const signal = controller.signal;
const results = await Promise.all([
env.readTextFile("file.txt", signal),
env.readTextLines("file.txt", { abortSignal: signal }),
env.readBinaryFile("file.txt", signal),
env.writeFile("other.txt", "hello", signal),
env.listDir(".", signal),
]);
for (const result of results) {
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" });
}
});
it("cleanup is best-effort", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await expect(env.cleanup()).resolves.toBeUndefined();
});
it("executes commands in cwd with env overrides", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = getOrThrow(
await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', {
env: { NODE_ENV_TEST: "ok" },
}),
);
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
});
it("can replace rather than inherit the default shell environment", async () => {
const root = createTempDir();
const inheritedKey = "PI_NODE_ENV_INHERITED_TEST";
const configuredKey = "PI_NODE_ENV_CONFIGURED_TEST";
const explicitKey = "PI_NODE_ENV_EXPLICIT_TEST";
const previousInherited = process.env[inheritedKey];
process.env[inheritedKey] = "host";
try {
const env = new NodeExecutionEnv({ cwd: root, shellEnv: { [configuredKey]: "configured" } });
const result = getOrThrow(
await env.exec(`printf '%s:%s:%s' "\${${inheritedKey}-}" "\${${configuredKey}-}" "\${${explicitKey}-}"`, {
inheritEnv: false,
env: { [explicitKey]: "explicit" },
}),
);
expect(result.stdout).toBe("::explicit");
} finally {
if (previousInherited === undefined) delete process.env[inheritedKey];
else process.env[inheritedKey] = previousInherited;
}
});
it("uses stdin command transport for legacy WSL bash paths", async () => {
if (process.platform === "win32") return;
const root = createTempDir();
const shellPath = "C:\\Windows\\System32\\bash.exe";
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile(shellPath, '#!/bin/sh\nprintf \'args:%s\\n\' "$*" >&2\nexec /bin/bash "$@"\n'));
await chmod(join(root, shellPath), 0o755);
const originalCwd = process.cwd();
const originalPath = process.env.PATH;
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
try {
process.chdir(root);
process.env.PATH = `${root}${delimiter}${originalPath ?? ""}`;
Object.defineProperty(process, "platform", {
configurable: true,
value: "win32",
});
const wslEnv = new NodeExecutionEnv({ cwd: root, shellPath });
const nameExpansion = "$" + "{name}";
const result = getOrThrow(await wslEnv.exec(`name='World'; echo "Hello, ${nameExpansion}!"`));
expect(result).toEqual({ stdout: "Hello, World!\n", stderr: "args:-s\n", exitCode: 0 });
} finally {
process.chdir(originalCwd);
process.env.PATH = originalPath;
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
}
});
it.skipIf(process.platform !== "win32")(
"settles after the shell exits when a detached descendant retains inherited stdio",
async () => {
const root = createTempDir();
const pidFile = join(root, "grandchild.pid");
const env = new NodeExecutionEnv({ cwd: root });
const controller = new AbortController();
try {
const result = getOrThrow(
await withTimeout(
env.exec(createInheritedStdioCommand(pidFile), { abortSignal: controller.signal }),
3000,
() => controller.abort(),
),
);
expect(result.stdout).toContain("child-exiting");
} finally {
controller.abort();
cleanupDetachedChild(pidFile);
}
},
);
it("cleanup terminates active shell processes", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const execution = env.exec("touch started; sleep 60");
for (let attempt = 0; attempt < 100 && !getOrThrow(await env.exists("started")); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(getOrThrow(await env.exists("started"))).toBe(true);
await env.cleanup();
await expect(withTimeout(execution, 3000)).resolves.toMatchObject({ ok: true });
});
it("streams stdout and stderr chunks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
let stdout = "";
let stderr = "";
const result = getOrThrow(
await env.exec("printf out; printf err >&2", {
onStdout: (chunk) => {
stdout += chunk;
},
onStderr: (chunk) => {
stderr += chunk;
},
}),
);
expect(result).toEqual({ stdout: "out", stderr: "err", exitCode: 0 });
expect(stdout).toBe("out");
expect(stderr).toBe("err");
});
it("reports a missing working directory before spawning", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: join(root, "missing") });
const result = await env.exec("printf ok");
expect(result).toMatchObject({
ok: false,
error: { code: "spawn_error", message: expect.stringContaining("Working directory does not exist") },
});
});
it("returns non-zero command exit codes as successful execution results", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = getOrThrow(await env.exec("exit 7"));
expect(result).toEqual({ stdout: "", stderr: "", exitCode: 7 });
});
it("returns timeout errors for commands exceeding the timeout", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = await env.exec("sleep 5", { timeout: 0.01 });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "timeout" });
});
it("returns callback errors from exec stream handlers", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = await env.exec("printf out", {
onStdout: () => {
throw new Error("callback failed");
},
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "callback_error", message: "callback failed" });
});
it("returns shell unavailable and spawn errors", async () => {
const root = createTempDir();
const missingShellEnv = new NodeExecutionEnv({ cwd: root, shellPath: join(root, "missing-shell") });
const missingShell = await missingShellEnv.exec("printf ok");
expect(missingShell.ok).toBe(false);
if (!missingShell.ok) expect(missingShell.error).toMatchObject({ code: "shell_unavailable" });
const shellPath = join(root, "not-executable-shell");
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile(shellPath, "not executable"));
const spawnErrorEnv = new NodeExecutionEnv({ cwd: root, shellPath });
const spawnError = await spawnErrorEnv.exec("printf ok");
expect(spawnError.ok).toBe(false);
if (!spawnError.ok) expect(spawnError.error).toMatchObject({ code: "spawn_error" });
});
it("returns an aborted result for aborted commands", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const controller = new AbortController();
const promise = env.exec("sleep 5", { abortSignal: controller.signal });
controller.abort();
const result = await promise;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" });
});
it("captures large shell output to a full output file through the execution env", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = getOrThrow(await executeShellWithCapture(env, "yes line | head -n 15000"));
expect(result.truncated).toBe(true);
expect(result.fullOutputPath).toBeDefined();
const fullOutput = getOrThrow(await env.readTextFile(result.fullOutputPath!));
expect(fullOutput.split("\n").length).toBeGreaterThan(10000);
expect(result.output.length).toBeLessThan(fullOutput.length);
});
});
@@ -0,0 +1,90 @@
import { symlink } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import {
formatPromptTemplateInvocation,
loadPromptTemplates,
loadSourcedPromptTemplates,
} from "../../src/harness/prompt-templates.ts";
import { createTempDir } from "./session-test-utils.ts";
describe("loadPromptTemplates", () => {
it("loads markdown templates non-recursively from one or more dirs", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("a/nested", { recursive: true });
await env.createDir("b", { recursive: true });
await env.writeFile("a/one.md", "---\ndescription: One template\n---\nHello $1");
await env.writeFile("a/nested/ignored.md", "Ignored");
await env.writeFile("b/two.md", "First line description\nBody");
const { promptTemplates, diagnostics } = await loadPromptTemplates(env, ["a", "b"]);
expect(diagnostics).toEqual([]);
expect(promptTemplates).toEqual([
{ name: "one", description: "One template", content: "Hello $1" },
{ name: "two", description: "First line description", content: "First line description\nBody" },
]);
});
it("preserves source info for sourced prompt templates", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("prompts", { recursive: true });
await env.writeFile("prompts/example.md", "---\ndescription: Example\n---\nExample body");
const { promptTemplates, diagnostics } = await loadSourcedPromptTemplates(env, [
{ path: "prompts", source: { type: "project" as const } },
]);
expect(diagnostics).toEqual([]);
expect(promptTemplates).toEqual([
{
promptTemplate: { name: "example", description: "Example", content: "Example body" },
source: { type: "project" },
},
]);
});
it("attaches source info to diagnostics", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.writeFile("broken.md", "---\ndescription: [unterminated\n---\nBody");
const { promptTemplates, diagnostics } = await loadSourcedPromptTemplates(env, [
{ path: "broken.md", source: { type: "user" as const } },
]);
expect(promptTemplates).toEqual([]);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0]).toMatchObject({
type: "warning",
path: join(root, "broken.md"),
source: { type: "user" },
});
});
it("loads explicit markdown files and symlinked files", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.writeFile("target.md", "---\ndescription: Target\n---\nTarget body");
await symlink(join(root, "target.md"), join(root, "link.md"));
const { promptTemplates } = await loadPromptTemplates(env, ["target.md", "link.md"]);
expect(promptTemplates).toEqual([
{ name: "target", description: "Target", content: "Target body" },
{ name: "link", description: "Target", content: "Target body" },
]);
});
});
describe("formatPromptTemplateInvocation", () => {
it("substitutes command arguments", () => {
const content = "$1 $" + "{@:2} $ARGUMENTS";
expect(formatPromptTemplateInvocation({ name: "one", content }, ["hello world", "test"])).toBe(
"hello world test hello world test",
);
});
});
@@ -0,0 +1,92 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionRepo } from "../../src/harness/session/jsonl-repo.ts";
import { InMemorySessionRepo } from "../../src/harness/session/memory-repo.ts";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
describe("InMemorySessionRepo", () => {
it("opens, deletes, and forks by metadata", async () => {
const repo = new InMemorySessionRepo();
const session = await repo.create({ id: "session-1" });
const metadata = await session.getMetadata();
const user1 = await session.appendMessage(createUserMessage("one"));
const assistant1 = await session.appendMessage(createAssistantMessage("two"));
const user2 = await session.appendMessage(createUserMessage("three"));
expect(await repo.open(metadata)).toBe(session);
expect((await repo.list()).map((info) => info.id)).toEqual(["session-1"]);
const fork = await repo.fork(metadata, { entryId: user2, id: "session-2" });
expect((await fork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1]);
const fullFork = await repo.fork(metadata, { id: "session-3" });
expect((await fullFork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1, user2]);
await repo.delete(metadata);
await expect(repo.open(metadata)).rejects.toThrow("Session not found: session-1");
});
});
describe("JsonlSessionRepo", () => {
it("stores sessions below encoded cwd directories and lists by cwd", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const cwd = "/tmp/my-project";
const otherCwd = "/tmp/other-project";
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const session = await repo.create({ cwd, id: "019de8c2-de29-73e9-ae0c-e134db34c447" });
const otherSession = await repo.create({ cwd: otherCwd, id: "other-session" });
const metadata = await session.getMetadata();
const otherMetadata = await otherSession.getMetadata();
expect(metadata.path).toContain("--tmp-my-project--");
expect(otherMetadata.path).toContain("--tmp-other-project--");
expect(existsSync(metadata.path)).toBe(true);
expect((await repo.list({ cwd })).map((sessionMetadata) => sessionMetadata.id)).toEqual([metadata.id]);
expect((await repo.list()).map((sessionMetadata) => sessionMetadata.id).sort()).toEqual(
[metadata.id, otherMetadata.id].sort(),
);
});
it("opens, deletes, and forks by metadata", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const source = await repo.create({ cwd: "/tmp/source", id: "source-session" });
const sourceMetadata = await source.getMetadata();
const user1 = await source.appendMessage(createUserMessage("one"));
const assistant1 = await source.appendMessage(createAssistantMessage("two"));
const user2 = await source.appendMessage(createUserMessage("three"));
await expect((await repo.open(sourceMetadata)).getMetadata()).resolves.toEqual(sourceMetadata);
const fork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session", entryId: user2 });
const forkMetadata = await fork.getMetadata();
expect(forkMetadata.cwd).toBe("/tmp/target");
expect(forkMetadata.parentSessionPath).toBe(sourceMetadata.path);
expect((await fork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1]);
const fullFork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "full-fork-session" });
expect((await fullFork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1, user2]);
await repo.delete(sourceMetadata);
expect(existsSync(sourceMetadata.path)).toBe(false);
await expect(repo.open(sourceMetadata)).rejects.toThrow("Session not found");
});
it("persists header metadata through create, list, and fork", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const source = await repo.create({
cwd: "/tmp/source",
id: "source-session",
metadata: { profile: "reviewer" },
});
const sourceMetadata = await source.getMetadata();
expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" });
expect((await repo.list({ cwd: "/tmp/source" })).map((listed) => listed.metadata)).toEqual([
{ profile: "reviewer" },
]);
const fork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session" });
expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" });
const overridden = await repo.fork(sourceMetadata, {
cwd: "/tmp/target",
id: "overridden-session",
metadata: { profile: "writer" },
});
expect((await overridden.getMetadata()).metadata).toEqual({ profile: "writer" });
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { formatPromptTemplateInvocation } from "../../src/harness/prompt-templates.ts";
import { formatSkillInvocation } from "../../src/harness/skills.ts";
describe("resource formatting helpers", () => {
it("formats skill invocations with additional instructions", () => {
const skill = {
name: "inspect",
description: "Inspect things",
content: "Use inspection tools.",
filePath: "/project/.pi/skills/inspect/SKILL.md",
};
expect(formatSkillInvocation(skill, "Check errors.")).toBe(
'<skill name="inspect" location="/project/.pi/skills/inspect/SKILL.md">\nReferences are relative to /project/.pi/skills/inspect.\n\nUse inspection tools.\n</skill>\n\nCheck errors.',
);
});
it("formats prompt template invocations with positional arguments", () => {
expect(
formatPromptTemplateInvocation({ name: "review", content: "Review $1 with $ARGUMENTS" }, ["a.ts", "care"]),
).toBe("Review a.ts with a.ts care");
});
});
@@ -0,0 +1,55 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import { afterEach } from "vitest";
export function createUserMessage(text: string): AgentMessage {
return {
role: "user",
content: [{ type: "text", text }],
timestamp: Date.now(),
};
}
export function createAssistantMessage(text: string): AgentMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
const tempDirs: string[] = [];
export function createTempDir(): string {
const dir = join(tmpdir(), `pi-agent-session-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
tempDirs.push(dir);
return dir;
}
export function getLatestTempDir(): string {
return tempDirs[tempDirs.length - 1]!;
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop()!;
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true });
}
}
});
@@ -0,0 +1,255 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionStorage } from "../../src/harness/session/jsonl-storage.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { type ContextEntryTransform, Session } from "../../src/harness/session/session.ts";
import type { SessionStorage } from "../../src/harness/types.ts";
import { createAssistantMessage, createTempDir, createUserMessage, getLatestTempDir } from "./session-test-utils.ts";
function getTextData(data: unknown): string {
if (typeof data !== "object" || data === null || !("text" in data)) {
return "";
}
const value = (data as { text?: unknown }).text;
return typeof value === "string" ? value : "";
}
async function runSessionSuite(
name: string,
createStorage: () => SessionStorage | Promise<SessionStorage>,
inspect?: () => void,
) {
describe(name, () => {
it("appends messages and builds context in order", async () => {
const session = new Session(await createStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const context = await session.buildContext();
expect(context.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
});
it("tracks model and thinking level changes", async () => {
const session = new Session(await createStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendModelChange("openai", "gpt-4.1");
await session.appendThinkingLevelChange("high");
const context = await session.buildContext();
expect(context.thinkingLevel).toBe("high");
expect(context.model).toEqual({ provider: "openai", modelId: "gpt-4.1" });
});
it("supports branching by moving the leaf and appending a new branch", async () => {
const session = new Session(await createStorage());
const user1 = await session.appendMessage(createUserMessage("one"));
const assistant1 = await session.appendMessage(createAssistantMessage("two"));
await session.appendMessage(createUserMessage("three"));
await session.moveTo(user1);
await session.appendMessage(createAssistantMessage("branched"));
const branch = await session.getBranch();
expect(branch.map((entry) => entry.id)).toContain(user1);
expect(branch.map((entry) => entry.id)).not.toContain(assistant1);
const context = await session.buildContext();
expect(context.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
});
it("supports moving the leaf to root", async () => {
const session = new Session(await createStorage());
await session.appendMessage(createUserMessage("one"));
await session.moveTo(null);
expect(await session.getLeafId()).toBeNull();
expect((await session.buildContext()).messages).toEqual([]);
});
it("reconstructs compaction summaries in context", async () => {
const session = new Session(await createStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
const user2 = await session.appendMessage(createUserMessage("three"));
await session.appendMessage(createAssistantMessage("four"));
await session.appendCompaction("summary", user2, 1234, undefined, undefined, undefined, [
createUserMessage("three"),
createAssistantMessage("four"),
]);
await session.appendMessage(createUserMessage("five"));
const context = await session.buildContext();
expect(context.messages[0]?.role).toBe("compactionSummary");
expect(context.messages).toHaveLength(4);
expect(context.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"user",
"assistant",
"user",
]);
});
it("supports moving with branch summary entries in context", async () => {
const session = new Session(await createStorage());
const user1 = await session.appendMessage(createUserMessage("one"));
const summaryId = await session.moveTo(user1, { summary: "summary text" });
expect(summaryId).toBeTruthy();
const summaryEntry = await session.getEntry(summaryId!);
expect(summaryEntry).toMatchObject({ type: "branch_summary", parentId: user1, fromId: user1 });
const context = await session.buildContext();
expect(context.messages[1]?.role).toBe("branchSummary");
});
it("persists compaction usage", async () => {
const session = new Session(await createStorage());
const firstKeptEntryId = await session.appendMessage(createUserMessage("one"));
const usage = {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
};
const compactionId = await session.appendCompaction(
"summary",
firstKeptEntryId,
1234,
undefined,
false,
usage,
);
const compactionEntry = await session.getEntry(compactionId);
expect(compactionEntry?.type === "compaction" ? compactionEntry.usage : undefined).toEqual(usage);
});
it("persists branch summary usage", async () => {
const session = new Session(await createStorage());
const user1 = await session.appendMessage(createUserMessage("one"));
const usage = {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
};
const summaryId = await session.moveTo(user1, { summary: "summary text", usage });
const summaryEntry = await session.getEntry(summaryId!);
expect(summaryEntry?.type === "branch_summary" ? summaryEntry.usage : undefined).toEqual(usage);
});
it("supports custom message entries in context", async () => {
const session = new Session(await createStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendCustomMessageEntry("custom", "hello", true, { ok: true });
const context = await session.buildContext();
expect(context.messages[1]?.role).toBe("custom");
});
it("keeps custom entries in context entries but omits them from messages by default", async () => {
const session = new Session(await createStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendCustomEntry("chat_message", { text: "hello" });
const contextEntries = await session.buildContextEntries();
const context = await session.buildContext();
expect(contextEntries.map((entry) => entry.type)).toEqual(["message", "custom"]);
expect(context.messages).toHaveLength(1);
});
it("projects custom entries with configured custom-entry projectors", async () => {
const session = new Session(await createStorage(), {
entryProjectors: {
chat_message: (entry) => [createUserMessage(`chat: ${getTextData(entry.data)}`)],
},
});
await session.appendMessage(createUserMessage("one"));
await session.appendCustomEntry("chat_message", { text: "hello" });
const context = await session.buildContext();
expect(context.messages.map((message) => message.role)).toEqual(["user", "user"]);
expect(context.messages[1]).toMatchObject({ content: [{ type: "text", text: "chat: hello" }] });
});
it("applies context entry transforms after default compaction selection", async () => {
let observedFirstEntryType: string | undefined;
const dropCompaction: ContextEntryTransform = (entries) => {
observedFirstEntryType = entries[0]?.type;
return entries.filter((entry) => entry.type !== "compaction");
};
const session = new Session(await createStorage(), { entryTransforms: [dropCompaction] });
await session.appendMessage(createUserMessage("one"));
const kept = await session.appendMessage(createUserMessage("two"));
await session.appendCompaction("summary", kept, 1234);
await session.appendMessage(createUserMessage("three"));
const context = await session.buildContext();
expect(observedFirstEntryType).toBe("compaction");
expect(context.messages.map((message) => message.role)).toEqual(["user", "user"]);
});
it("normalizes session names", async () => {
const session = new Session(await createStorage());
await session.appendSessionName(" hello\nworld\r\nagain ");
expect(await session.getSessionName()).toBe("hello world again");
});
it("supports labels and session info entries without affecting context", async () => {
const session = new Session(await createStorage());
const user1 = await session.appendMessage(createUserMessage("one"));
await session.appendLabel(user1, "checkpoint");
await session.appendSessionName("name");
const entries = await session.getEntries();
expect(entries.some((entry) => entry.type === "label")).toBe(true);
expect(entries.some((entry) => entry.type === "session_info")).toBe(true);
expect(await session.getLabel(user1)).toBe("checkpoint");
expect(await session.getSessionName()).toBe("name");
expect((await session.buildContext()).messages).toHaveLength(1);
});
it("rejects labels for missing entries", async () => {
const session = new Session(await createStorage());
await expect(session.appendLabel("missing", "checkpoint")).rejects.toThrow("Entry missing not found");
});
it("persists leaf changes and appended entries via storage", async () => {
const storage = await createStorage();
const session = new Session(storage);
const user1 = await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
await session.appendLabel(user1, "checkpoint");
await session.appendSessionName("name");
await session.moveTo(user1);
await session.appendMessage(createAssistantMessage("branched"));
const session2 = new Session(storage);
const context = await session2.buildContext();
expect(context.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
expect(await session2.getLabel(user1)).toBe("checkpoint");
expect(await session2.getSessionName()).toBe("name");
inspect?.();
});
});
}
runSessionSuite("Session with in-memory storage", () => new InMemorySessionStorage());
runSessionSuite(
"Session with JSONL storage",
async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
return await JsonlSessionStorage.create(env, join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" });
},
() => {
const dir = getLatestTempDir();
const filePath = join(dir, "session.jsonl");
const lines = readFileSync(filePath, "utf8").trim().split("\n");
expect(lines.length).toBeGreaterThan(1);
const header = JSON.parse(lines[0]!);
expect(header.type).toBe("session");
expect(header.version).toBe(3);
const entries = lines.slice(1).map((line) => JSON.parse(line));
expect(entries.some((entry) => entry.type === "leaf")).toBe(true);
for (const entry of entries) {
expect(entry.type).not.toBe("entry");
expect(typeof entry.id).toBe("string");
}
},
);
@@ -0,0 +1,116 @@
import { symlink } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { loadSkills, loadSourcedSkills } from "../../src/harness/skills.ts";
import { createTempDir } from "./session-test-utils.ts";
describe("loadSkills", () => {
it("loads SKILL.md files through the execution environment", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir(".agents/skills/example", { recursive: true });
await env.writeFile(
".agents/skills/example/SKILL.md",
`---
name: example
description: Example skill
disable-model-invocation: true
---
Use this skill.
`,
);
const { skills, diagnostics } = await loadSkills(env, ".agents/skills");
expect(diagnostics).toEqual([]);
expect(skills).toEqual([
{
name: "example",
description: "Example skill",
content: "Use this skill.",
filePath: join(root, ".agents/skills/example/SKILL.md"),
disableModelInvocation: true,
},
]);
});
it("loads skills through symlinked directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("actual/example", { recursive: true });
await env.writeFile(
"actual/example/SKILL.md",
"---\nname: example\ndescription: Example skill\n---\nUse this skill.",
);
await symlink(join(root, "actual"), join(root, "skills-link"));
const { skills } = await loadSkills(env, "skills-link");
expect(skills.map((skill) => skill.name)).toEqual(["example"]);
expect(skills[0]?.filePath).toBe(join(root, "skills-link/example/SKILL.md"));
});
it("preserves source info for sourced skills", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("user/example", { recursive: true });
await env.writeFile(
"user/example/SKILL.md",
"---\nname: example\ndescription: Example skill\n---\nUse this skill.",
);
const { skills, diagnostics } = await loadSourcedSkills(env, [
{ path: "user", source: { type: "user" as const } },
]);
expect(diagnostics).toEqual([]);
expect(skills).toEqual([
{
skill: {
name: "example",
description: "Example skill",
content: "Use this skill.",
filePath: join(root, "user/example/SKILL.md"),
disableModelInvocation: false,
},
source: { type: "user" },
},
]);
});
it("attaches source info to diagnostics", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("user/broken", { recursive: true });
await env.writeFile("user/broken/SKILL.md", "---\nname: broken\n---\nMissing description.");
const { skills, diagnostics } = await loadSourcedSkills(env, [
{ path: "user", source: { type: "user" as const } },
]);
expect(skills).toEqual([]);
expect(diagnostics).toEqual([
{
type: "warning",
code: "invalid_metadata",
message: "description is required",
path: join(root, "user/broken/SKILL.md"),
source: { type: "user" },
},
]);
});
it("loads direct markdown children only from the root directory", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("skills/nested", { recursive: true });
await env.writeFile("skills/root.md", "---\ndescription: Root skill\n---\nRoot content");
await env.writeFile("skills/nested/ignored.md", "---\ndescription: Ignored\n---\nIgnored content");
const { skills } = await loadSkills(env, "skills");
expect(skills.map((skill) => skill.name)).toEqual(["skills"]);
expect(skills[0]?.content).toBe("Root content");
});
});
@@ -0,0 +1,441 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
applyMigrations,
createNodeSqliteFactory,
type SqliteDatabase,
type SqliteDatabaseFactory,
type SqliteRunResult,
type SqliteSessionMetadata,
SqliteSessionRepo,
SqliteSessionStorage,
type SqliteStatement,
} from "../../../storage/sqlite-node/src/index.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { createAssistantMessage, createUserMessage } from "./session-test-utils.ts";
function createTempDir(): string {
return mkdtempSync(join(tmpdir(), "pi-agent-sqlite-"));
}
class ThrowingStatement implements SqliteStatement {
private readonly onRun: () => Promise<SqliteRunResult>;
constructor(onRun: () => Promise<SqliteRunResult>) {
this.onRun = onRun;
}
async run(..._params: unknown[]): Promise<SqliteRunResult> {
return this.onRun();
}
async get<TRow extends object>(..._params: unknown[]): Promise<TRow | undefined> {
return undefined;
}
async all<TRow extends object>(..._params: unknown[]): Promise<TRow[]> {
return [];
}
}
class CountingDatabase implements SqliteDatabase {
closeCount = 0;
private readonly statementFactory: (sql: string) => SqliteStatement;
constructor(statementFactory: (sql: string) => SqliteStatement) {
this.statementFactory = statementFactory;
}
async exec(_sql: string): Promise<void> {}
prepare(sql: string): SqliteStatement {
return this.statementFactory(sql);
}
async transaction<T>(fn: () => Promise<T>): Promise<T> {
return fn();
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
describe("SQLite migrations", () => {
it("applies file-based migrations and records them", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const sqlite = createNodeSqliteFactory();
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
await repo.create({ cwd: root, id: "session-1" });
const db = await sqlite.open(databasePath);
try {
const rows = await db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>();
expect(rows.map((row) => row.id)).toEqual(["001_initial.sql"]);
const tables = await db
.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name")
.all<{ name: string; sql: string | null }>();
expect(tables.map((row) => row.name)).toEqual(
expect.arrayContaining([
"migrations",
"sessions",
"session_entries",
"session_sequences",
"branch_entries",
"session_materialized",
"entry_materialized",
]),
);
const sessionColumns = await db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>();
expect(sessionColumns.map((column) => column.name)).toContain("active_leaf_id");
for (const tableName of [
"sessions",
"session_sequences",
"branch_entries",
"session_materialized",
"entry_materialized",
]) {
const table = tables.find((row) => row.name === tableName);
expect(table?.sql).toContain("WITHOUT ROWID");
}
} finally {
await db.close();
}
});
it("persists session metadata through create, list, open, and fork", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
const source = await repo.create({
cwd: root,
id: "session-1",
metadata: { profile: "reviewer" },
});
const sourceMetadata = await source.getMetadata();
expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" });
expect((await repo.list({ cwd: root })).map((listed) => listed.metadata)).toEqual([{ profile: "reviewer" }]);
expect((await (await repo.open(sourceMetadata)).getMetadata()).metadata).toEqual({ profile: "reviewer" });
const fork = await repo.fork(sourceMetadata, { cwd: root, id: "session-2" });
expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" });
const overridden = await repo.fork(sourceMetadata, {
cwd: root,
id: "session-3",
metadata: { profile: "writer" },
});
expect((await overridden.getMetadata()).metadata).toEqual({ profile: "writer" });
});
it("materializes active leaf id in sessions transactionally", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const sqlite = createNodeSqliteFactory();
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
const session = await repo.create({ cwd: root, id: "session-1" });
const rootId = await session.appendMessage(createUserMessage("root"));
const childId = await session.appendMessage(createAssistantMessage("child"));
await session.getStorage().setLeafId(rootId);
const db = await sqlite.open(databasePath);
try {
const row = await db
.prepare("SELECT active_leaf_id FROM sessions WHERE id = ?")
.get<{ active_leaf_id: string | null }>("session-1");
expect(row?.active_leaf_id).toBe(rootId);
const latestBranchRow = await db
.prepare(
"SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1",
)
.get<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1");
const latestSessionEntry = await db
.prepare("SELECT id, type FROM session_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1")
.get<{ id: string; type: string }>("session-1");
expect(latestSessionEntry?.type).toBe("leaf");
expect(latestBranchRow?.entry_id).toBe(latestSessionEntry?.id);
} finally {
await db.close();
}
const reopened = await repo.open(await session.getMetadata());
expect(await reopened.getLeafId()).toBe(rootId);
expect(childId).not.toBe(rootId);
});
it("materializes a new branch when appending from a parent with an existing child", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const sqlite = createNodeSqliteFactory();
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
const session = await repo.create({ cwd: root, id: "session-1" });
const rootId = await session.appendMessage(createUserMessage("root"));
const firstChildId = await session.appendMessage(createAssistantMessage("first child"));
await session.getStorage().setLeafId(rootId);
const secondChildId = await session.appendMessage(createAssistantMessage("second child"));
const db = await sqlite.open(databasePath);
try {
const branchRows = await db
.prepare(
"SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY branch_id, entry_seq",
)
.all<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1");
const branchIds = [...new Set(branchRows.map((row) => row.branch_id))];
expect(branchIds).toHaveLength(3);
expect(branchRows.filter((row) => row.entry_id === rootId)).toHaveLength(3);
expect(branchRows.filter((row) => row.entry_id === firstChildId)).toHaveLength(1);
expect(branchRows.filter((row) => row.entry_id === secondChildId)).toHaveLength(1);
} finally {
await db.close();
}
});
it("reopens using branch materialization and session summary state", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
const session = await repo.create({ cwd: root, id: "session-1" });
const rootId = await session.appendMessage(createUserMessage("root"));
await session.appendMessage(createAssistantMessage("first child"));
await session.appendSessionName(" Reopened Session ");
await session.getStorage().setLeafId(rootId);
await session.appendMessage(createAssistantMessage("branched child"));
const reopened = await repo.open(await session.getMetadata());
expect(await reopened.getSessionName()).toBe("Reopened Session");
expect((await reopened.buildContext()).messages.map((message) => message.role)).toEqual(["user", "assistant"]);
expect((await reopened.buildContext()).messages.at(-1)).toMatchObject({
content: [{ type: "text", text: "branched child" }],
});
});
it("pages entries by entry_seq cursor", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
const session = await repo.create({ cwd: root, id: "session-1" });
await session.appendMessage(createUserMessage("one"));
await session.appendMessage(createAssistantMessage("two"));
await session.appendMessage(createUserMessage("three"));
expect((await session.getEntries({ limit: 2 })).map((entry) => entry.type)).toEqual(["message", "message"]);
expect((await session.getEntries({ afterEntrySeq: 2, limit: 2 })).map((entry) => entry.type)).toEqual([
"message",
"message",
]);
});
it("closes the database when create fails after openDatabase succeeds", async () => {
const root = createTempDir();
const db = new CountingDatabase((sql) => {
if (sql.startsWith("INSERT INTO sessions")) {
return new ThrowingStatement(async () => {
throw new Error("insert failed");
});
}
return new ThrowingStatement(async () => ({ changes: 1 }));
});
const sqlite: SqliteDatabaseFactory = {
open: async () => db,
};
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
await expect(repo.create({ cwd: root, id: "session-1" })).rejects.toThrow("insert failed");
expect(db.closeCount).toBe(1);
});
it("closes the database when open fails after openDatabase succeeds", async () => {
const root = createTempDir();
const db = new CountingDatabase((sql) => {
if (sql.includes("FROM sessions WHERE id = ?")) {
return new ThrowingStatement(async () => ({ changes: 0 }));
}
return new ThrowingStatement(async () => ({ changes: 1 }));
});
const sqlite: SqliteDatabaseFactory = {
open: async () => db,
};
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
const metadata: SqliteSessionMetadata = {
id: "missing",
createdAt: new Date().toISOString(),
cwd: root,
path: join(root, "sessions.sqlite"),
};
writeFileSync(metadata.path, "");
await expect(repo.open(metadata)).rejects.toThrow("Session not found: missing");
expect(db.closeCount).toBe(1);
});
it("closes the source storage after fork reads its entries", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
let cleanupCount = 0;
const sourceStorage = {
async getEntries() {
return [];
},
async getPathToRootOrCompaction() {
return [];
},
async cleanup() {
cleanupCount += 1;
},
} as const;
const originalOpen = repo.open.bind(repo);
repo.open = async () =>
({
getStorage() {
return sourceStorage;
},
}) as never;
try {
await repo.fork(
{
id: "session-1",
createdAt: new Date().toISOString(),
cwd: root,
path: databasePath,
},
{ cwd: root, id: "session-2" },
);
} finally {
repo.open = originalOpen;
}
expect(cleanupCount).toBe(1);
});
it("restores in-memory state when appendEntry fails after mutating caches", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const sqlite = createNodeSqliteFactory();
const db = await sqlite.open(databasePath);
await applyMigrations(db);
const storage = await SqliteSessionStorage.create(db, databasePath, {
cwd: root,
sessionId: "session-1",
});
const originalPrepare = db.prepare.bind(db);
db.prepare = (sql: string) => {
if (sql.startsWith("UPDATE sessions SET active_leaf_id = ?")) {
return new ThrowingStatement(async () => {
throw new Error("active leaf update failed");
});
}
return originalPrepare(sql);
};
await expect(
storage.appendEntry({
type: "message",
id: "root",
parentId: null,
timestamp: new Date().toISOString(),
message: createUserMessage("root"),
}),
).rejects.toMatchObject({ code: "storage" });
expect(await storage.getLeafId()).toBeNull();
expect(await storage.getEntry("root")).toBeUndefined();
expect(await storage.getEntries()).toEqual([]);
await db.close();
});
it("materializes session summary fields transactionally", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
const sqlite = createNodeSqliteFactory();
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
const session = await repo.create({ cwd: root, id: "session-1" });
const userId = await session.appendMessage(createUserMessage("one"));
await session.appendThinkingLevelChange("high");
await session.appendModelChange("anthropic", "claude-sonnet-4-5");
const assistant = {
...createAssistantMessage("two"),
provider: "anthropic",
model: "claude-sonnet-4-5",
usage: {
input: 100,
output: 25,
cacheRead: 40,
cacheWrite: 10,
totalTokens: 175,
cost: { input: 0.1, output: 0.2, cacheRead: 0.03, cacheWrite: 0.04, total: 0.37 },
},
};
await session.appendMessage(assistant);
await session.appendCompaction("summary", userId, 200, undefined, false, {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 },
});
await session.moveTo(userId, {
summary: "branch summary",
usage: {
input: 5,
output: 6,
cacheRead: 7,
cacheWrite: 8,
totalTokens: 26,
cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 },
},
});
await session.appendSessionName(" My Session ");
await session.appendLabel(userId, "checkpoint");
const db = await sqlite.open(databasePath);
try {
const row = await db.prepare("SELECT session_id, payload FROM session_materialized WHERE session_id = ?").get<{
session_id: string;
payload: string;
}>("session-1");
expect(row).toBeDefined();
expect(row?.session_id).toBe("session-1");
expect(JSON.parse(row?.payload ?? "null")).toMatchObject({
name: "My Session",
messageCount: 2,
cachedTokens: 50,
uncachedTokens: 128,
totalTokens: 211,
costTotal: 0.73,
currentModel: { provider: "anthropic", modelId: "claude-sonnet-4-5" },
currentThinkingLevel: "high",
});
const entryRows = await db
.prepare(
"SELECT session_id, entry_seq, type, payload FROM entry_materialized WHERE session_id = ? ORDER BY entry_seq, type",
)
.all<{
session_id: string;
entry_seq: number;
type: string;
payload: string;
}>("session-1");
expect(
entryRows.some((entryRow) => entryRow.type === "label" && JSON.parse(entryRow.payload).targetId === userId),
).toBe(true);
expect(entryRows.some((entryRow) => entryRow.type === "thinking")).toBe(false);
expect(entryRows.some((entryRow) => entryRow.type === "model")).toBe(false);
} finally {
await db.close();
}
});
});
@@ -0,0 +1,21 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createNodeSqliteFactory } from "../../../storage/sqlite-node/src/index.ts";
import { createTempDir } from "./session-test-utils.ts";
describe("sqlite-node adapter", () => {
it("supports node:sqlite-style named parameters", async () => {
const root = createTempDir();
const databasePath = join(root, "adapter.sqlite");
const sqlite = createNodeSqliteFactory();
const db = await sqlite.open(databasePath);
try {
await db.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, text TEXT NOT NULL)");
await db.prepare("INSERT INTO items (id, text) VALUES ($id, $text)").run({ $id: 1, $text: "hello" });
const row = await db.prepare("SELECT text FROM items WHERE id = $id").get<{ text: string }>({ $id: 1 });
expect(row).toEqual({ text: "hello" });
} finally {
await db.close();
}
});
});
@@ -0,0 +1,504 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/jsonl-storage.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import {
type BranchSummaryEntry,
type CompactionEntry,
type MessageEntry,
ok,
type SessionMetadata,
} from "../../src/harness/types.ts";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
describe("InMemorySessionStorage", () => {
it("returns configured session metadata", async () => {
const metadata: SessionMetadata = { id: "session-1", createdAt: "2026-01-01T00:00:00.000Z" };
const storage = new InMemorySessionStorage({ metadata });
expect(await storage.getMetadata()).toEqual(metadata);
});
it("copies initial entries and persists leaf changes", async () => {
const entry: MessageEntry = {
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("one"),
};
const initialEntries = [entry];
const storage = new InMemorySessionStorage({ entries: initialEntries });
initialEntries.push({ ...entry, id: "entry-2" });
expect((await storage.getEntries()).map((storedEntry) => storedEntry.id)).toEqual(["entry-1"]);
expect(await storage.getLeafId()).toBe("entry-1");
await storage.setLeafId(null);
expect(await storage.getLeafId()).toBeNull();
expect((await storage.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: null });
});
it("rejects invalid leaf ids", async () => {
const storage = new InMemorySessionStorage();
await expect(storage.setLeafId("missing")).rejects.toThrow("Entry missing not found");
});
it("finds entries by type", async () => {
const entry: MessageEntry = {
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("one"),
};
const storage = new InMemorySessionStorage({ entries: [entry] });
expect((await storage.findEntries("message")).map((found) => found.id)).toEqual(["entry-1"]);
expect(await storage.findEntries("session_info")).toEqual([]);
});
it("maintains label lookup", async () => {
const entry: MessageEntry = {
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("one"),
};
const storage = new InMemorySessionStorage({ entries: [entry] });
expect(await storage.getLabel("entry-1")).toBeUndefined();
await storage.appendEntry({
type: "label",
id: "label-1",
parentId: "entry-1",
timestamp: "2026-01-01T00:00:01.000Z",
targetId: "entry-1",
label: "checkpoint",
});
expect(await storage.getLabel("entry-1")).toBe("checkpoint");
await storage.appendEntry({
type: "label",
id: "label-2",
parentId: "label-1",
timestamp: "2026-01-01T00:00:02.000Z",
targetId: "entry-1",
label: undefined,
});
expect(await storage.getLabel("entry-1")).toBeUndefined();
});
it("includes summary-entry usage in session stats", async () => {
const assistant: MessageEntry = {
type: "message",
id: "assistant",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: "reply" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage: {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
},
stopReason: "stop",
timestamp: 0,
},
};
const compaction: CompactionEntry = {
type: "compaction",
id: "compaction",
parentId: "assistant",
timestamp: "2026-01-01T00:00:01.000Z",
summary: "summary",
firstKeptEntryId: "assistant",
tokensBefore: 1234,
usage: {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 },
},
};
const branchSummary: BranchSummaryEntry = {
type: "branch_summary",
id: "branch-summary",
parentId: "compaction",
timestamp: "2026-01-01T00:00:02.000Z",
fromId: "assistant",
summary: "branch",
usage: {
input: 5,
output: 6,
cacheRead: 7,
cacheWrite: 8,
totalTokens: 26,
cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 },
},
};
const storage = new InMemorySessionStorage({ entries: [assistant, compaction, branchSummary] });
expect(await storage.getSessionStats()).toEqual({
messageCount: 1,
cachedTokens: 40,
uncachedTokens: 68,
totalTokens: 136,
costTotal: 1.36,
});
});
it("walks paths to root or retained-tail compaction", async () => {
const root: MessageEntry = {
type: "message",
id: "root",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("root"),
};
const child: MessageEntry = {
...root,
id: "child",
parentId: "root",
message: createAssistantMessage("child"),
};
const compaction: CompactionEntry = {
type: "compaction",
id: "compaction",
parentId: "child",
timestamp: "2026-01-01T00:00:01.000Z",
summary: "summary",
firstKeptEntryId: "child",
tokensBefore: 1234,
retainedTail: [createAssistantMessage("child")],
};
const afterCompaction: MessageEntry = {
...root,
id: "after-compaction",
parentId: "compaction",
message: createUserMessage("after"),
};
const storage = new InMemorySessionStorage({ entries: [root, child, compaction, afterCompaction] });
expect((await storage.getPathToRootOrCompaction("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
expect((await storage.getPathToRootOrCompaction("after-compaction")).map((entry) => entry.id)).toEqual([
"compaction",
"after-compaction",
]);
expect(await storage.getPathToRootOrCompaction(null)).toEqual([]);
});
});
describe("JsonlSessionStorage", () => {
it("throws for missing files when opening", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toMatchObject({ code: "not_found" });
});
it("writes the header on create", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf8").trim().split("\n")).toHaveLength(1);
expect(await storage.getLeafId()).toBeNull();
expect(await storage.getEntries()).toEqual([]);
await storage.appendEntry({
type: "message",
id: "user-1",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("one"),
});
const lines = readFileSync(filePath, "utf8").trim().split("\n");
expect(JSON.parse(lines[0]!).type).toBe("session");
expect(JSON.parse(lines[1]!).id).toBe("user-1");
expect(lines).toHaveLength(2);
});
it("throws for malformed session headers", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
writeFileSync(filePath, "not json\n");
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toThrow("first line is not a valid session header");
});
it("throws for malformed entry lines", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const header = {
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: dir,
};
const entry: MessageEntry = {
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("one"),
};
writeFileSync(filePath, `${JSON.stringify(header)}\nnot json\n${JSON.stringify(entry)}\n`);
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toMatchObject({ code: "invalid_entry" });
});
it("creates and reads session metadata from the header", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(env, filePath, {
cwd: dir,
sessionId: "session-1",
parentSessionPath: "/tmp/parent.jsonl",
});
const metadata = await storage.getMetadata();
expect(metadata).toMatchObject({
id: "session-1",
cwd: dir,
path: filePath,
parentSessionPath: "/tmp/parent.jsonl",
});
await storage.appendEntry({
type: "message",
id: "user-1",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("one"),
});
expect(await loadJsonlSessionMetadata(env, filePath)).toEqual(metadata);
});
it("round-trips custom header metadata", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(env, filePath, {
cwd: dir,
sessionId: "session-1",
metadata: { profile: "reviewer" },
});
expect((await storage.getMetadata()).metadata).toEqual({ profile: "reviewer" });
const loaded = await JsonlSessionStorage.open(env, filePath);
expect((await loaded.getMetadata()).metadata).toEqual({ profile: "reviewer" });
expect((await loadJsonlSessionMetadata(env, filePath)).metadata).toEqual({ profile: "reviewer" });
});
it("omits header metadata when not provided", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
expect(JSON.parse(readFileSync(filePath, "utf8").trim())).not.toHaveProperty("metadata");
expect((await loadJsonlSessionMetadata(env, filePath)).metadata).toBeUndefined();
});
it("throws for non-object header metadata", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const header = {
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: dir,
metadata: "profile",
};
writeFileSync(filePath, `${JSON.stringify(header)}\n`);
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toThrow(
"session header metadata must be an object",
);
});
it("loads existing entries and reconstructs leaf", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
const root: MessageEntry = {
type: "message",
id: "root",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("root"),
};
const child: MessageEntry = {
...root,
id: "child",
parentId: "root",
message: createAssistantMessage("child"),
};
await storage.appendEntry(root);
await storage.appendEntry(child);
const loaded = await JsonlSessionStorage.open(env, filePath);
expect(await loaded.getLeafId()).toBe("child");
expect((await loaded.getEntries()).map((entry) => entry.id)).toEqual(["root", "child"]);
await loaded.setLeafId("root");
const reloaded = await JsonlSessionStorage.open(env, filePath);
expect(await reloaded.getLeafId()).toBe("root");
expect((await reloaded.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: "root" });
expect((await loaded.getPathToRootOrCompaction("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
});
it("finds entries by type", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
await storage.appendEntry({
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("one"),
});
expect((await storage.findEntries("message")).map((found) => found.id)).toEqual(["entry-1"]);
expect(await storage.findEntries("session_info")).toEqual([]);
});
it("maintains label lookup", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
await storage.appendEntry({
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("one"),
});
expect(await storage.getLabel("entry-1")).toBeUndefined();
await storage.appendEntry({
type: "label",
id: "label-1",
parentId: "entry-1",
timestamp: "2026-01-01T00:00:01.000Z",
targetId: "entry-1",
label: "checkpoint",
});
expect(await storage.getLabel("entry-1")).toBe("checkpoint");
await storage.appendEntry({
type: "label",
id: "label-2",
parentId: "label-1",
timestamp: "2026-01-01T00:00:02.000Z",
targetId: "entry-1",
label: undefined,
});
expect(await storage.getLabel("entry-1")).toBeUndefined();
const loaded = await JsonlSessionStorage.open(env, filePath);
expect(await loaded.getLabel("entry-1")).toBeUndefined();
});
it("includes summary-entry usage in session stats", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
await storage.appendEntry({
type: "message",
id: "assistant",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: "reply" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage: {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
},
stopReason: "stop",
timestamp: 0,
},
});
await storage.appendEntry({
type: "compaction",
id: "compaction",
parentId: "assistant",
timestamp: "2026-01-01T00:00:01.000Z",
summary: "summary",
firstKeptEntryId: "assistant",
tokensBefore: 1234,
usage: {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 },
},
});
await storage.appendEntry({
type: "branch_summary",
id: "branch-summary",
parentId: "compaction",
timestamp: "2026-01-01T00:00:02.000Z",
fromId: "assistant",
summary: "branch",
usage: {
input: 5,
output: 6,
cacheRead: 7,
cacheWrite: 8,
totalTokens: 26,
cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 },
},
});
expect(await storage.getSessionStats()).toEqual({
messageCount: 1,
cachedTokens: 40,
uncachedTokens: 68,
totalTokens: 136,
costTotal: 1.36,
});
});
it("reads session metadata through the line-reading filesystem operation", async () => {
const dir = createTempDir();
const filePath = join(dir, "session.jsonl");
const header = {
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: dir,
};
const metadata = await loadJsonlSessionMetadata(
{
readTextLines: async () => ok([JSON.stringify(header)]),
readTextFile: async () => {
throw new Error("readTextFile should not be called for metadata");
},
writeFile: async () => ok(undefined),
appendFile: async () => ok(undefined),
},
filePath,
);
expect(metadata).toEqual({
id: "session-1",
createdAt: "2026-01-01T00:00:00.000Z",
cwd: dir,
path: filePath,
parentSessionPath: undefined,
});
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.ts";
const visibleSkill = {
name: "visible",
description: "Use <this> & that",
content: "visible content",
filePath: "/skills/visible/SKILL.md",
};
const secondSkill = {
name: "second",
description: "Second skill",
content: "second content",
filePath: "/skills/second/SKILL.md",
};
const disabledSkill = {
name: "hidden",
description: "Hidden",
content: "hidden content",
filePath: "/skills/hidden/SKILL.md",
disableModelInvocation: true,
};
describe("formatSkillsForSystemPrompt", () => {
it("formats visible skills in order and skips model-disabled skills", () => {
expect(formatSkillsForSystemPrompt([visibleSkill, disabledSkill, secondSkill])).toBe(
`The following skills provide specialized instructions for specific tasks.
Read the full skill file when the task matches its description.
When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.
<available_skills>
<skill>
<name>visible</name>
<description>Use &lt;this&gt; &amp; that</description>
<location>/skills/visible/SKILL.md</location>
</skill>
<skill>
<name>second</name>
<description>Second skill</description>
<location>/skills/second/SKILL.md</location>
</skill>
</available_skills>`,
);
});
it("returns an empty string when no skills are model-visible", () => {
expect(formatSkillsForSystemPrompt([disabledSkill])).toBe("");
});
it("escapes XML in all model-visible skill fields", () => {
expect(
formatSkillsForSystemPrompt([
{
name: "a&b",
description: `Quote "double" and 'single'`,
content: "content",
filePath: '/skills/<bad>&"quote"/SKILL.md',
},
]),
).toContain(
"<name>a&amp;b</name>\n <description>Quote &quot;double&quot; and &apos;single&apos;</description>\n <location>/skills/&lt;bad&gt;&amp;&quot;quote&quot;/SKILL.md</location>",
);
});
});
@@ -0,0 +1,17 @@
import type { Api, Model, Models } from "@earendil-works/pi-ai";
import { AgentHarness } from "../../src/harness/agent-harness.ts";
import { createReadTool } from "../../src/harness/tools/read.ts";
import type { ExecutionToolContext } from "../../src/harness/tools/tool-context.ts";
import type { Session } from "../../src/harness/types.ts";
declare const models: Models;
declare const model: Model<Api>;
declare const session: Session;
declare const toolContext: ExecutionToolContext;
const readTool = createReadTool();
new AgentHarness({ models, model, session, tools: [readTool], toolContext });
// @ts-expect-error Context-requiring tools must be paired with toolContext.
new AgentHarness({ models, model, session, tools: [readTool] });
@@ -0,0 +1,610 @@
import { symlink } from "node:fs/promises";
import { applyPatch } from "diff";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { type BashToolDetails, createBashTool } from "../../src/harness/tools/bash.ts";
import { createEditTool } from "../../src/harness/tools/edit.ts";
import { createReadTool } from "../../src/harness/tools/read.ts";
import { createWriteTool } from "../../src/harness/tools/write.ts";
import {
type ExecutionError,
type FileError,
getOrThrow,
ok,
type Result,
type ShellExecOptions,
} from "../../src/harness/types.ts";
import { createTempDir } from "./session-test-utils.ts";
function textOutput(result: { content: Array<{ type: string; text?: string }> }): string {
return result.content.flatMap((part) => (part.type === "text" ? [part.text ?? ""] : [])).join("\n");
}
function createContext() {
const env = new NodeExecutionEnv({ cwd: createTempDir() });
return { env };
}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve = () => {};
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
class SlowReadExecutionEnv extends NodeExecutionEnv {
override async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
await delay(20);
return super.readTextFile(path, abortSignal);
}
}
class BlockingWriteExecutionEnv extends NodeExecutionEnv {
readonly firstWriteStarted = deferred();
readonly finishFirstWrite = deferred();
secondWriteStarted = false;
override async writeFile(
path: string,
content: string | Uint8Array,
abortSignal?: AbortSignal,
): Promise<Result<void, FileError>> {
if (content === "first\n") {
this.firstWriteStarted.resolve();
await this.finishFirstWrite.promise;
} else if (content === "second\n") {
this.secondWriteStarted = true;
}
return super.writeFile(path, content, abortSignal);
}
}
class BlockingEditExecutionEnv extends NodeExecutionEnv {
readonly firstEditWriteStarted = deferred();
readonly finishFirstEditWrite = deferred();
firstEditWriteSettled = false;
secondEditWriteStarted = false;
override async writeFile(
path: string,
content: string | Uint8Array,
abortSignal?: AbortSignal,
): Promise<Result<void, FileError>> {
if (content === "ALPHA\nbeta\n") {
this.firstEditWriteStarted.resolve();
await this.finishFirstEditWrite.promise;
const result = await super.writeFile(path, content);
this.firstEditWriteSettled = true;
return result;
}
if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") {
this.secondEditWriteStarted = true;
}
return super.writeFile(path, content, abortSignal);
}
}
class LateOutputExecutionEnv extends NodeExecutionEnv {
override async exec(
_command: string,
options?: ShellExecOptions,
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
options?.onStdout?.("before\n");
setTimeout(() => options?.onStdout?.("late\n"), 0);
return ok({ stdout: "before\n", stderr: "", exitCode: 0 });
}
}
function createTinyBmp(): Uint8Array {
const bytes = new Uint8Array(58);
const view = new DataView(bytes.buffer);
bytes[0] = 0x42;
bytes[1] = 0x4d;
view.setUint32(2, bytes.length, true);
view.setUint32(10, 54, true);
view.setUint32(14, 40, true);
view.setInt32(18, 1, true);
view.setInt32(22, 1, true);
view.setUint16(26, 1, true);
view.setUint16(28, 24, true);
view.setUint32(34, 4, true);
return bytes;
}
describe("AgentHarness tools", () => {
describe("read", () => {
it("reads text with offsets, limits, and continuation notices", async () => {
const context = createContext();
getOrThrow(
await context.env.writeFile(
"test.txt",
Array.from({ length: 100 }, (_, index) => `Line ${index + 1}`).join("\n"),
),
);
const result = await createReadTool().execute(
"read-1",
{ path: "test.txt", offset: 41, limit: 20 },
undefined,
undefined,
context,
);
const output = textOutput(result);
expect(output).not.toContain("Line 40");
expect(output).toContain("Line 41");
expect(output).toContain("Line 60");
expect(output).not.toContain("Line 61");
expect(output).toContain("[40 more lines in file. Use offset=61 to continue.]");
});
it("truncates large text by line count", async () => {
const context = createContext();
getOrThrow(
await context.env.writeFile(
"large.txt",
Array.from({ length: 2500 }, (_, index) => `Line ${index + 1}`).join("\n"),
),
);
const result = await createReadTool().execute("read-2", { path: "large.txt" }, undefined, undefined, context);
expect(textOutput(result)).toContain("[Showing lines 1-2000 of 2500. Use offset=2001 to continue.]");
expect(result.details?.truncation).toMatchObject({
truncated: true,
truncatedBy: "lines",
totalLines: 2500,
outputLines: 2000,
});
});
it("does not count a trailing newline as an extra line at the truncation limit", async () => {
const context = createContext();
getOrThrow(
await context.env.writeFile("exact.txt", `${Array.from({ length: 2000 }, () => "x").join("\n")}\n`),
);
const result = await createReadTool().execute(
"read-exact",
{ path: "exact.txt" },
undefined,
undefined,
context,
);
expect(result.details).toBeUndefined();
expect(textOutput(result)).not.toContain("Use offset=");
});
it("rejects offsets beyond the file", async () => {
const context = createContext();
getOrThrow(await context.env.writeFile("short.txt", "one\ntwo\nthree"));
await expect(
createReadTool().execute("read-3", { path: "short.txt", offset: 100 }, undefined, undefined, context),
).rejects.toThrow("Offset 100 is beyond end of file (3 lines total)");
});
it("detects supported images by content", async () => {
const context = createContext();
const png = Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAX+XDSwAAAABJRU5ErkJggg==",
"base64",
),
);
getOrThrow(await context.env.writeFile("image.txt", png));
const result = await createReadTool().execute("read-4", { path: "image.txt" }, undefined, undefined, context);
expect(textOutput(result)).toContain("Read image file [image/png]");
expect(result.content).toContainEqual({
type: "image",
data: Buffer.from(png).toString("base64"),
mimeType: "image/png",
});
});
it("delegates image conversion and resizing to an injected processor", async () => {
const context = createContext();
const bmp = createTinyBmp();
getOrThrow(await context.env.writeFile("image.bmp", bmp));
let received: { bytes: Uint8Array; mimeType: string; autoResizeImages: boolean } | undefined;
const tool = createReadTool({
autoResizeImages: false,
imageProcessor: async (bytes, mimeType, options) => {
received = { bytes, mimeType, autoResizeImages: options.autoResizeImages };
return {
ok: true,
data: "converted",
mimeType: "image/png",
hints: ["[Image converted from image/bmp to image/png.]"],
};
},
});
const result = await tool.execute("read-bmp", { path: "image.bmp" }, undefined, undefined, context);
expect(received).toMatchObject({ mimeType: "image/bmp", autoResizeImages: false });
expect(Array.from(received?.bytes ?? [])).toEqual(Array.from(bmp));
expect(textOutput(result)).toContain("[Image converted from image/bmp to image/png.]");
expect(result.content).toContainEqual({ type: "image", data: "converted", mimeType: "image/png" });
});
});
describe("write", () => {
it("writes files and creates parent directories", async () => {
const context = createContext();
const result = await createWriteTool().execute(
"write-1",
{ path: "nested/dir/file.txt", content: "hello" },
undefined,
undefined,
context,
);
expect(textOutput(result)).toBe("Successfully wrote 5 bytes to nested/dir/file.txt");
expect(getOrThrow(await context.env.readTextFile("nested/dir/file.txt"))).toBe("hello");
});
it("keeps the mutation queue locked until an aborted write settles", async () => {
const env = new BlockingWriteExecutionEnv({ cwd: createTempDir() });
const tool = createWriteTool();
const controller = new AbortController();
const firstWrite = tool.execute(
"write-first",
{ path: "file.txt", content: "first\n" },
controller.signal,
undefined,
{
env,
},
);
await env.firstWriteStarted.promise;
controller.abort();
const secondWrite = tool.execute(
"write-second",
{ path: "file.txt", content: "second\n" },
undefined,
undefined,
{ env },
);
await delay(20);
expect(env.secondWriteStarted).toBe(false);
env.finishFirstWrite.resolve();
await expect(firstWrite).rejects.toThrow();
await secondWrite;
expect(getOrThrow(await env.readTextFile("file.txt"))).toBe("second\n");
});
});
describe("edit", () => {
it("applies disjoint edits and returns both diff formats", async () => {
const context = createContext();
const original = "alpha\nbeta\ngamma\ndelta\n";
getOrThrow(await context.env.writeFile("edit.txt", original));
const result = await createEditTool().execute(
"edit-1",
{
path: "edit.txt",
edits: [
{ oldText: "alpha\n", newText: "ALPHA\n" },
{ oldText: "gamma\n", newText: "GAMMA\n" },
],
},
undefined,
undefined,
context,
);
expect(textOutput(result)).toBe("Successfully replaced 2 block(s) in edit.txt.");
expect(result.details?.diff).toContain("ALPHA");
expect(result.details?.diff).toContain("GAMMA");
expect(applyPatch(original, result.details?.patch ?? "")).toBe("ALPHA\nbeta\nGAMMA\ndelta\n");
expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("ALPHA\nbeta\nGAMMA\ndelta\n");
});
it("matches all edits against the original and rejects overlaps", async () => {
const context = createContext();
getOrThrow(await context.env.writeFile("edit.txt", "one\ntwo\nthree\n"));
await expect(
createEditTool().execute(
"edit-2",
{
path: "edit.txt",
edits: [
{ oldText: "one\ntwo\n", newText: "ONE\nTWO\n" },
{ oldText: "two\nthree\n", newText: "TWO\nTHREE\n" },
],
},
undefined,
undefined,
context,
),
).rejects.toThrow(/overlap/);
expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("one\ntwo\nthree\n");
});
it("rejects missing and duplicate target text", async () => {
const context = createContext();
getOrThrow(await context.env.writeFile("edit.txt", "foo foo foo"));
const tool = createEditTool();
await expect(
tool.execute(
"edit-3",
{ path: "edit.txt", edits: [{ oldText: "bar", newText: "baz" }] },
undefined,
undefined,
context,
),
).rejects.toThrow(/Could not find the exact text/);
await expect(
tool.execute(
"edit-4",
{ path: "edit.txt", edits: [{ oldText: "foo", newText: "bar" }] },
undefined,
undefined,
context,
),
).rejects.toThrow(/Found 3 occurrences/);
});
it("keeps the mutation queue locked until an aborted edit write settles", async () => {
const env = new BlockingEditExecutionEnv({ cwd: createTempDir() });
getOrThrow(await env.writeFile("file.txt", "alpha\nbeta\n"));
const tool = createEditTool();
const controller = new AbortController();
const firstEdit = tool.execute(
"edit-first",
{ path: "file.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] },
controller.signal,
undefined,
{ env },
);
await env.firstEditWriteStarted.promise;
controller.abort();
const secondEdit = tool.execute(
"edit-second",
{ path: "file.txt", edits: [{ oldText: "beta", newText: "BETA" }] },
undefined,
undefined,
{ env },
);
await delay(20);
expect(env.secondEditWriteStarted).toBe(false);
env.finishFirstEditWrite.resolve();
await expect(firstEdit).rejects.toThrow("Operation aborted");
await secondEdit;
expect(env.firstEditWriteSettled).toBe(true);
expect(getOrThrow(await env.readTextFile("file.txt"))).toBe("ALPHA\nBETA\n");
});
it("serializes concurrent edits through canonical and symlink paths", async () => {
const env = new SlowReadExecutionEnv({ cwd: createTempDir() });
getOrThrow(await env.writeFile("target.txt", "alpha\nbeta\ngamma\n"));
await symlink("target.txt", `${env.cwd}/link.txt`);
const tool = createEditTool();
await Promise.all([
tool.execute(
"edit-target",
{ path: "target.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] },
undefined,
undefined,
{ env },
),
tool.execute(
"edit-link",
{ path: "link.txt", edits: [{ oldText: "beta", newText: "BETA" }] },
undefined,
undefined,
{ env },
),
]);
expect(getOrThrow(await env.readTextFile("target.txt"))).toBe("ALPHA\nBETA\ngamma\n");
});
it("edits regular files through symlinks", async () => {
const context = createContext();
getOrThrow(await context.env.writeFile("target.txt", "before\n"));
await symlink("target.txt", `${context.env.cwd}/link.txt`);
await createEditTool().execute(
"edit-symlink",
{ path: "link.txt", edits: [{ oldText: "before", newText: "after" }] },
undefined,
undefined,
context,
);
expect(getOrThrow(await context.env.readTextFile("target.txt"))).toBe("after\n");
});
it("preserves BOM and CRLF line endings", async () => {
const context = createContext();
getOrThrow(await context.env.writeFile("edit.txt", "\uFEFFone\r\ntwo\r\n"));
await createEditTool().execute(
"edit-5",
{ path: "edit.txt", edits: [{ oldText: "two", newText: "TWO" }] },
undefined,
undefined,
context,
);
expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("\uFEFFone\r\nTWO\r\n");
});
});
describe("bash", () => {
it("executes commands and combines stdout and stderr", async () => {
const context = createContext();
const result = await createBashTool().execute(
"bash-1",
{ command: "printf out; printf err >&2" },
undefined,
undefined,
context,
);
expect(textOutput(result)).toContain("out");
expect(textOutput(result)).toContain("err");
});
it("reports nonzero exits and timeouts", async () => {
const context = createContext();
const tool = createBashTool();
await expect(
tool.execute("bash-2", { command: "printf failed; exit 7" }, undefined, undefined, context),
).rejects.toThrow(/failed[\s\S]*Command exited with code 7/);
await expect(
tool.execute("bash-3", { command: "sleep 2", timeout: 0.01 }, undefined, undefined, context),
).rejects.toThrow(/Command timed out after 0.01 seconds/);
});
it("preserves truncated output when a command times out", async () => {
const context = createContext();
let error: unknown;
try {
await createBashTool().execute(
"bash-timeout-output",
{
command: "i=1; while [ $i -le 3000 ]; do echo line-$i; i=$((i + 1)); done; sleep 2",
timeout: 0.05,
},
undefined,
undefined,
context,
);
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toContain("Command timed out after 0.05 seconds");
const fullOutputPath = message.match(/Full output: ([^\]\n]+)/)?.[1];
expect(fullOutputPath).toBeDefined();
const fullOutput = getOrThrow(await context.env.readTextFile(fullOutputPath!));
expect(fullOutput).toContain("line-1\nline-2");
expect(fullOutput).toContain("line-2999\nline-3000");
});
it("ignores output callbacks after execution settles", async () => {
const env = new LateOutputExecutionEnv({ cwd: createTempDir() });
const updates: string[] = [];
const result = await createBashTool().execute(
"bash-late",
{ command: "late" },
undefined,
(update) => updates.push(textOutput(update)),
{ env },
);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(textOutput(result)).toBe("before\n");
expect(updates.some((update) => update.includes("late"))).toBe(false);
});
it("reports the total size of an oversized final line", async () => {
const context = createContext();
const result = await createBashTool().execute(
"bash-long-line",
{ command: "printf '%060000d' 0" },
undefined,
undefined,
context,
);
expect(textOutput(result)).toMatch(/Showing last 50\.0KB of line 1 \(line is 58\.6KB\)\. Full output:/);
});
it("prepares command, cwd, and an explicit environment with the turn context", async () => {
const env = new NodeExecutionEnv({
cwd: createTempDir(),
shellEnv: { PI_BASH_PREPARE_INHERITED: "inherited" },
});
getOrThrow(await env.createDir("workspace"));
const context = { env, workspace: `${env.cwd}/workspace` };
const controller = new AbortController();
let receivedContext: typeof context | undefined;
let receivedSignal: AbortSignal | undefined;
const tool = createBashTool<typeof context>({
commandPrefix: "prefix=ready",
prepare: async (execution, turnContext, signal) => {
receivedContext = turnContext;
receivedSignal = signal;
execution.cwd = turnContext.workspace;
execution.env = { PI_BASH_PREPARE_EXPLICIT: "explicit" };
execution.inheritEnv = false;
execution.command += `\nprintf '%s:%s:%s:%s' "$prefix" "\${PI_BASH_PREPARE_INHERITED-}" "$PI_BASH_PREPARE_EXPLICIT" "$PWD"`;
},
});
const result = await tool.execute("bash-prepare", { command: ":" }, controller.signal, undefined, context);
expect(receivedContext).toBe(context);
expect(receivedSignal).toBe(controller.signal);
expect(textOutput(result)).toBe(`ready::explicit:${getOrThrow(await env.canonicalPath(context.workspace))}`);
});
it("supports command prefixes", async () => {
const context = createContext();
const result = await createBashTool({ commandPrefix: "value=hello" }).execute(
"bash-4",
{ command: "printf $value" },
undefined,
undefined,
context,
);
expect(textOutput(result)).toBe("hello");
});
it("coalesces updates and persists truncated full output", async () => {
const context = createContext();
const updates: Array<{
content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
details?: BashToolDetails;
}> = [];
const result = await createBashTool().execute(
"bash-5",
{ command: "i=1; while [ $i -le 3000 ]; do echo line-$i; i=$((i + 1)); done" },
undefined,
(update) => updates.push(update),
context,
);
expect(updates.length).toBeLessThan(25);
expect(result.details?.truncation).toMatchObject({
truncated: true,
truncatedBy: "lines",
totalLines: 3000,
outputLines: 2000,
});
expect(textOutput(result)).toContain("line-3000");
expect(result.details?.fullOutputPath).toBeDefined();
const finalUpdate = updates.at(-1);
expect(finalUpdate ? textOutput(finalUpdate) : "").toContain("line-3000");
expect(finalUpdate?.details).toMatchObject({
truncation: { totalLines: 3000, totalBytes: expect.any(Number) },
fullOutputPath: result.details?.fullOutputPath,
});
const fullOutput = getOrThrow(await context.env.readTextFile(result.details!.fullOutputPath!));
expect(fullOutput).toContain("line-1\nline-2");
expect(fullOutput).toContain("line-2999\nline-3000");
});
});
});
@@ -0,0 +1,178 @@
import { describe, expect, it } from "vitest";
import { truncateHead, truncateTail } from "../../src/harness/utils/truncate.ts";
const encoder = new TextEncoder();
function byteLength(content: string): number {
return encoder.encode(content).length;
}
function bufferTail(content: string, maxBytes: number): string {
const bytes = Buffer.from(content, "utf8");
if (bytes.length <= maxBytes) return content;
let start = bytes.length - maxBytes;
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
return bytes.subarray(start).toString("utf8");
}
function assertMatchesBufferTail(input: string, maxByteValues?: readonly number[]): void {
const totalBytes = Buffer.byteLength(input, "utf8");
const values = maxByteValues ?? Array.from({ length: totalBytes + 5 }, (_, maxBytes) => maxBytes);
for (const maxBytes of values) {
const result = truncateTail(input, { maxBytes, maxLines: 10 });
const expected = bufferTail(input, maxBytes);
if (result.content !== expected) {
throw new Error(
`tail mismatch input=${JSON.stringify(input)} maxBytes=${maxBytes} expected=${JSON.stringify(expected)} actual=${JSON.stringify(result.content)}`,
);
}
const outputBytes = Buffer.byteLength(result.content, "utf8");
if (outputBytes > maxBytes) {
throw new Error(
`tail output exceeded byte limit input=${JSON.stringify(input)} maxBytes=${maxBytes} outputBytes=${outputBytes}`,
);
}
}
}
function sampledByteLimits(input: string): number[] {
const totalBytes = Buffer.byteLength(input, "utf8");
const candidates = [
0,
1,
2,
3,
4,
5,
8,
Math.floor(totalBytes / 2) - 1,
Math.floor(totalBytes / 2),
Math.floor(totalBytes / 2) + 1,
totalBytes - 8,
totalBytes - 5,
totalBytes - 4,
totalBytes - 3,
totalBytes - 2,
totalBytes - 1,
totalBytes,
totalBytes + 1,
totalBytes + 4,
];
return [...new Set(candidates.filter((value) => value >= 0))].sort((a, b) => a - b);
}
describe("truncate utilities", () => {
it("counts UTF-8 bytes without Node Buffer", () => {
const content = "aé🙂\nb";
const result = truncateHead(content, { maxBytes: 100, maxLines: 10 });
expect(result.truncated).toBe(false);
expect(result.totalBytes).toBe(byteLength(content));
expect(result.outputBytes).toBe(byteLength(content));
expect(result.totalBytes).toBe(9);
});
it("does not count a trailing newline as an extra line", () => {
const content = `${Array.from({ length: 3 }, () => "line").join("\n")}\n`;
const head = truncateHead(content, { maxBytes: 100, maxLines: 3 });
const tail = truncateTail(content, { maxBytes: 100, maxLines: 3 });
expect(head).toMatchObject({ truncated: false, totalLines: 3, outputLines: 3 });
expect(tail).toMatchObject({ truncated: false, totalLines: 3, outputLines: 3 });
});
it("truncates head on UTF-8 byte limits without partial lines", () => {
const content = "éé\nabc";
const result = truncateHead(content, { maxBytes: 4, maxLines: 10 });
expect(result.content).toBe("éé");
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe("bytes");
expect(result.outputBytes).toBe(4);
expect(result.firstLineExceedsLimit).toBe(false);
});
it("reports head truncation when the first line exceeds the byte limit", () => {
const result = truncateHead("éé\nabc", { maxBytes: 3, maxLines: 10 });
expect(result.content).toBe("");
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe("bytes");
expect(result.firstLineExceedsLimit).toBe(true);
});
it("truncates tail on UTF-8 boundaries when only a partial last line fits", () => {
const result = truncateTail("aé🙂b", { maxBytes: 5, maxLines: 10 });
expect(result.content).toBe("🙂b");
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe("bytes");
expect(result.lastLinePartial).toBe(true);
expect(result.outputBytes).toBe(5);
});
it("truncates an oversized single line with a trailing newline", () => {
const input = `${"X".repeat(300_000)}\n`;
const result = truncateTail(input, { maxBytes: 1024, maxLines: 100 });
expect(result.content).toBe("X".repeat(1024));
expect(result.outputBytes).toBe(1024);
expect(result.outputLines).toBe(1);
expect(result.lastLinePartial).toBe(true);
expect(result.truncatedBy).toBe("bytes");
});
it("drops an oversized trailing character when it cannot fit in tail byte limit", () => {
const result = truncateTail("abc🙂", { maxBytes: 3, maxLines: 10 });
expect(result.content).toBe("");
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe("bytes");
expect(result.lastLinePartial).toBe(true);
expect(result.outputBytes).toBe(0);
});
it("matches Buffer tail truncation semantics for surrogate edge cases", () => {
const inputs = ["a\ud83d", "\ude42b", "a\ude42b", "\ud83d\ud83d\ude42", "\ud83d\ude42\ude42", "👩‍💻"];
for (const input of inputs) assertMatchesBufferTail(input);
});
it("matches Buffer tail truncation semantics across deterministic fuzz cases", () => {
const alphabet = [
"a",
"\u007f",
"\u0080",
"é",
"\u07ff",
"\u0800",
"中",
"\ud7ff",
"\ud800",
"\ud83d",
"\udc00",
"\ude42",
"🙂",
"\ue000",
"\uffff",
];
function checkExhaustive(prefix: string, depth: number): void {
assertMatchesBufferTail(prefix, sampledByteLimits(prefix));
if (depth === 0) return;
for (const character of alphabet) checkExhaustive(prefix + character, depth - 1);
}
checkExhaustive("", 3);
let seed = 0x12345678;
function random(): number {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 0x100000000;
}
for (let i = 0; i < 1_000; i++) {
let input = "";
const length = Math.floor(random() * 80);
for (let j = 0; j < length; j++) input += alphabet[Math.floor(random() * alphabet.length)];
assertMatchesBufferTail(input, sampledByteLimits(input));
}
});
});