mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-04 11:39:36 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
@@ -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 <this> & 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&b</name>\n <description>Quote "double" and 'single'</description>\n <location>/skills/<bad>&"quote"/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));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user