This commit is contained in:
ZacharyZhang-NY committed 2026-04-18 22:45:41 -04:00
1 parent 1261c2431f
commit dac59f56c2
680 files changed
+192574

No files matched your search

@@ -0,0 +1,242 @@
import { Buffer } from "node:buffer";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { afterEach, describe, expect, it } from "vitest";
import type { BashOperations } from "../../src/core/tools/bash.js";
import { createHarness, type Harness } from "./harness.js";
function getEntryTypes(harness: Harness): string[] {
return harness.sessionManager.getEntries().map((entry) => entry.type);
}
describe("AgentSession bash and persistence characterization", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("records bash results immediately while idle", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.session.recordBashResult("echo hi", {
output: "hi",
exitCode: 0,
cancelled: false,
truncated: false,
});
expect(harness.session.hasPendingBashMessages).toBe(false);
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution");
expect(getEntryTypes(harness)).toContain("message");
});
it("defers bash results while streaming and flushes them before the next prompt", async () => {
let releaseToolExecution: (() => void) | undefined;
const toolRelease = new Promise<void>((resolve) => {
releaseToolExecution = resolve;
});
const waitTool: AgentTool = {
name: "wait",
label: "Wait",
description: "Wait for release",
parameters: Type.Object({}),
execute: async () => {
await toolRelease;
return {
content: [{ type: "text", text: "released" }],
details: {},
};
},
};
const harness = await createHarness({ tools: [waitTool] });
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
fauxAssistantMessage("after flush"),
]);
const sawToolStart = new Promise<void>((resolve) => {
const unsubscribe = harness.session.subscribe((event) => {
if (event.type === "tool_execution_start") {
unsubscribe();
resolve();
}
});
});
const firstPrompt = harness.session.prompt("start");
await sawToolStart;
harness.session.recordBashResult("echo hi", {
output: "hi",
exitCode: 0,
cancelled: false,
truncated: false,
});
expect(harness.session.hasPendingBashMessages).toBe(true);
expect(harness.session.messages.some((message) => message.role === "bashExecution")).toBe(false);
releaseToolExecution?.();
await firstPrompt;
expect(harness.session.hasPendingBashMessages).toBe(true);
expect(harness.session.messages.some((message) => message.role === "bashExecution")).toBe(false);
await harness.session.prompt("next turn");
expect(harness.session.hasPendingBashMessages).toBe(false);
expect(harness.session.messages.some((message) => message.role === "bashExecution")).toBe(true);
expect(getEntryTypes(harness).filter((type) => type === "message").length).toBeGreaterThan(0);
});
it("executes bash commands and records the result", async () => {
const harness = await createHarness();
harnesses.push(harness);
const result = await harness.session.executeBash("printf 'hello'");
expect(result.output).toContain("hello");
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution");
});
it("cancels running bash commands with abortBash", async () => {
const harness = await createHarness();
harnesses.push(harness);
const operations: BashOperations = {
exec: async (_command, _cwd, options) => {
return await new Promise<{ exitCode: number | null }>((_resolve, reject) => {
options.signal?.addEventListener(
"abort",
() => {
reject(new Error("aborted"));
},
{ once: true },
);
});
},
};
const bashPromise = harness.session.executeBash("sleep", undefined, { operations });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(harness.session.isBashRunning).toBe(true);
harness.session.abortBash();
const result = await bashPromise;
expect(result.cancelled).toBe(true);
expect(harness.session.isBashRunning).toBe(false);
});
it("persists user, assistant, toolResult, and custom messages in order", async () => {
const echoTool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo text back",
parameters: Type.Object({ text: Type.String() }),
execute: async (_toolCallId, params) => {
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
return { content: [{ type: "text", text: `echo:${text}` }], details: { text } };
},
};
const harness = await createHarness({ tools: [echoTool] });
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
]);
await harness.session.sendCustomMessage({
customType: "note",
content: "hello",
display: true,
details: { a: 1 },
});
await harness.session.prompt("start");
const entries = harness.sessionManager.getEntries();
expect(entries.map((entry) => entry.type)).toEqual([
"custom_message",
"message",
"message",
"message",
"message",
]);
expect(harness.session.messages.map((message) => message.role)).toEqual([
"custom",
"user",
"assistant",
"toolResult",
"assistant",
]);
});
it("does not emit message_end for bash execution messages", async () => {
const harness = await createHarness();
harnesses.push(harness);
const messageEndRoles: string[] = [];
harness.session.subscribe((event) => {
if (event.type === "message_end") {
messageEndRoles.push(event.message.role);
}
});
harness.session.recordBashResult("echo hi", {
output: "hi",
exitCode: 0,
cancelled: false,
truncated: false,
});
expect(messageEndRoles).toEqual([]);
});
it("persists aborted assistant messages", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("x".repeat(20_000))]);
const sawMessageUpdate = new Promise<void>((resolve) => {
const unsubscribe = harness.session.subscribe((event) => {
if (event.type === "message_update") {
unsubscribe();
resolve();
}
});
});
const promptPromise = harness.session.prompt("hi");
await sawMessageUpdate;
await harness.session.abort();
await promptPromise;
const lastEntry = harness.sessionManager.getEntries()[harness.sessionManager.getEntries().length - 1];
expect(lastEntry?.type).toBe("message");
if (lastEntry?.type === "message") {
expect(lastEntry.message.role).toBe("assistant");
if (lastEntry.message.role === "assistant") {
expect(lastEntry.message.stopReason).toBe("aborted");
}
}
});
it("records bash output through custom operations", async () => {
const harness = await createHarness();
harnesses.push(harness);
const operations: BashOperations = {
exec: async (_command, _cwd, options) => {
options.onData(Buffer.from("hello from custom ops"));
return { exitCode: 0 };
},
};
const result = await harness.session.executeBash("custom", undefined, { operations });
expect(result.output).toContain("hello from custom ops");
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution");
});
});
@@ -0,0 +1,343 @@
import { type AssistantMessage, fauxAssistantMessage, type Model } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createHarness, type Harness } from "./harness.js";
type SessionWithCompactionInternals = {
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
};
function createUsage(totalTokens: number) {
return {
input: totalTokens,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function createAssistant(
harness: Harness,
options: {
stopReason?: AssistantMessage["stopReason"];
errorMessage?: string;
totalTokens?: number;
timestamp?: number;
},
): AssistantMessage {
const model = harness.getModel();
return {
...fauxAssistantMessage("", {
stopReason: options.stopReason,
errorMessage: options.errorMessage,
timestamp: options.timestamp,
}),
api: model.api,
provider: model.provider,
model: model.id,
usage: createUsage(options.totalTokens ?? 0),
};
}
describe("AgentSession compaction characterization", () => {
const harnesses: Harness[] = [];
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("manually compacts using an extension-provided summary", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "summary from extension",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: { source: "extension" },
},
}));
},
],
});
harnesses.push(harness);
await harness.session.prompt("one");
await harness.session.prompt("two");
const result = await harness.session.compact();
const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction");
expect(result.summary).toBe("summary from extension");
expect(compactionEntries).toHaveLength(1);
expect(harness.session.messages[0]?.role).toBe("compactionSummary");
});
it("throws when compacting without a model", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.session.agent.state.model = undefined as unknown as Model<any>;
await expect(harness.session.compact()).rejects.toThrow("No model selected");
});
it("throws when compacting without configured auth", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
await expect(harness.session.compact()).rejects.toThrow(`No API key found for ${harness.getModel().provider}.`);
});
it("cancels in-progress manual compaction when abortCompaction is called", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => {
return await new Promise<{ cancel: true }>((resolve) => {
event.signal.addEventListener("abort", () => resolve({ cancel: true }), { once: true });
});
});
},
],
});
harnesses.push(harness);
await harness.session.prompt("one");
await harness.session.prompt("two");
const compactPromise = harness.session.compact();
await new Promise((resolve) => setTimeout(resolve, 0));
harness.session.abortCompaction();
await expect(compactPromise).rejects.toThrow("Compaction cancelled");
});
it("resumes after threshold compaction when only agent-level queued messages exist", async () => {
vi.useFakeTimers();
const harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "auto compacted",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
}));
},
],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]);
await harness.session.prompt("first");
await harness.session.prompt("second");
harness.session.agent.followUp({
role: "custom",
customType: "test",
content: [{ type: "text", text: "queued custom" }],
display: false,
timestamp: Date.now(),
});
const continueSpy = vi.spyOn(harness.session.agent, "continue").mockResolvedValue();
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
await sessionInternals._runAutoCompaction("threshold", false);
await vi.advanceTimersByTimeAsync(100);
expect(continueSpy).toHaveBeenCalledTimes(1);
});
it("does not retry overflow recovery more than once", async () => {
const harness = await createHarness();
harnesses.push(harness);
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
const overflowMessage = createAssistant(harness, {
stopReason: "error",
errorMessage: "prompt is too long",
timestamp: Date.now(),
});
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
const compactionErrors: string[] = [];
harness.session.subscribe((event) => {
if (event.type === "compaction_end" && event.errorMessage) {
compactionErrors.push(event.errorMessage);
}
});
await sessionInternals._checkCompaction(overflowMessage);
await sessionInternals._checkCompaction({ ...overflowMessage, timestamp: Date.now() + 1 });
expect(runAutoCompactionSpy).toHaveBeenCalledTimes(1);
expect(compactionErrors).toContain(
"Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
);
});
it("ignores stale pre-compaction assistant usage on pre-prompt checks", async () => {
const harness = await createHarness();
harnesses.push(harness);
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
const staleTimestamp = Date.now() - 10_000;
const staleAssistant = createAssistant(harness, {
stopReason: "stop",
totalTokens: 610_000,
timestamp: staleTimestamp,
});
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "before compaction" }],
timestamp: staleTimestamp - 1000,
});
harness.sessionManager.appendMessage(staleAssistant);
const firstKeptEntryId = harness.sessionManager.getEntries()[0]!.id;
harness.sessionManager.appendCompaction(
"summary",
firstKeptEntryId,
staleAssistant.usage.totalTokens,
undefined,
false,
);
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "after compaction" }],
timestamp: Date.now(),
});
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
await sessionInternals._checkCompaction(staleAssistant, false);
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
});
it("triggers threshold compaction for error messages using the last successful usage", async () => {
const harness = await createHarness();
harnesses.push(harness);
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
const successfulAssistant = createAssistant(harness, {
stopReason: "stop",
totalTokens: 190_000,
timestamp: Date.now(),
});
const errorAssistant = createAssistant(harness, {
stopReason: "error",
errorMessage: "529 overloaded",
timestamp: Date.now() + 1000,
});
harness.session.agent.state.messages = [
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
successfulAssistant,
{ role: "user", content: [{ type: "text", text: "retry" }], timestamp: Date.now() + 500 },
errorAssistant,
];
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
await sessionInternals._checkCompaction(errorAssistant);
expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false);
});
it("does not trigger threshold compaction for error messages when no prior usage exists", async () => {
const harness = await createHarness();
harnesses.push(harness);
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
const errorAssistant = createAssistant(harness, {
stopReason: "error",
errorMessage: "529 overloaded",
timestamp: Date.now(),
});
harness.session.agent.state.messages = [
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
errorAssistant,
];
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
await sessionInternals._checkCompaction(errorAssistant);
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
});
it("does not trigger threshold compaction when only kept pre-compaction usage exists", async () => {
const harness = await createHarness();
harnesses.push(harness);
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
const preCompactionTimestamp = Date.now() - 10_000;
const keptAssistant = createAssistant(harness, {
stopReason: "stop",
totalTokens: 190_000,
timestamp: preCompactionTimestamp,
});
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "before compaction" }],
timestamp: preCompactionTimestamp - 1000,
});
harness.sessionManager.appendMessage(keptAssistant);
const firstKeptEntryId = harness.sessionManager.getEntries()[0]!.id;
harness.sessionManager.appendCompaction(
"summary",
firstKeptEntryId,
keptAssistant.usage.totalTokens,
undefined,
false,
);
const errorAssistant = createAssistant(harness, {
stopReason: "error",
errorMessage: "529 overloaded",
timestamp: Date.now(),
});
harness.session.agent.state.messages = [
{ role: "user", content: [{ type: "text", text: "kept user" }], timestamp: preCompactionTimestamp - 1000 },
keptAssistant,
{ role: "user", content: [{ type: "text", text: "new prompt" }], timestamp: Date.now() - 500 },
errorAssistant,
];
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
await sessionInternals._checkCompaction(errorAssistant);
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
});
it("does not trigger threshold compaction below the threshold or when disabled", async () => {
const belowThresholdHarness = await createHarness({
settings: { compaction: { enabled: true, reserveTokens: 1000 } },
models: [{ id: "faux-1", contextWindow: 200_000 }],
});
harnesses.push(belowThresholdHarness);
const disabledHarness = await createHarness({ settings: { compaction: { enabled: false } } });
harnesses.push(disabledHarness);
const belowThresholdInternals = belowThresholdHarness.session as unknown as SessionWithCompactionInternals;
const disabledInternals = disabledHarness.session as unknown as SessionWithCompactionInternals;
const belowThresholdSpy = vi.spyOn(belowThresholdInternals, "_runAutoCompaction").mockResolvedValue();
const disabledSpy = vi.spyOn(disabledInternals, "_runAutoCompaction").mockResolvedValue();
await belowThresholdInternals._checkCompaction(
createAssistant(belowThresholdHarness, { stopReason: "stop", totalTokens: 1_000, timestamp: Date.now() }),
);
await disabledInternals._checkCompaction(
createAssistant(disabledHarness, { stopReason: "stop", totalTokens: 1_000_000, timestamp: Date.now() }),
);
expect(belowThresholdSpy).not.toHaveBeenCalled();
expect(disabledSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,325 @@
import type { AgentTool, ThinkingLevel } from "@mariozechner/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall, type Model } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { afterEach, describe, expect, it } from "vitest";
import type { ExtensionAPI } from "../../src/index.js";
import { createHarness, getAssistantTexts, type Harness } from "./harness.js";
describe("AgentSession model and extension characterization", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("setModel saves the model and emits model_select", async () => {
const modelEvents: string[] = [];
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
extensionFactories: [
(pi) => {
pi.on("model_select", async (event) => {
modelEvents.push(`${event.previousModel?.id ?? "none"}->${event.model.id}:${event.source}`);
});
},
],
});
harnesses.push(harness);
const nextModel = harness.getModel("faux-2")!;
await harness.session.setModel(nextModel);
expect(harness.session.model?.id).toBe("faux-2");
expect(modelEvents).toEqual(["faux-1->faux-2:set"]);
expect(
harness.sessionManager
.getEntries()
.filter((entry) => entry.type === "model_change")
.map((entry) => `${entry.provider}/${entry.modelId}`),
).toEqual([`${nextModel.provider}/${nextModel.id}`]);
});
it("cycles through scoped models and preserves the scoped thinking preference", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: false },
],
});
harnesses.push(harness);
const modelOne = harness.getModel("faux-1")!;
const modelTwo = harness.getModel("faux-2")!;
harness.session.setScopedModels([{ model: modelOne, thinkingLevel: "high" }, { model: modelTwo }] as Array<{
model: Model<string>;
thinkingLevel?: ThinkingLevel;
}>);
harness.session.setThinkingLevel("high");
await harness.session.cycleModel();
expect(harness.session.model?.id).toBe("faux-2");
expect(harness.session.thinkingLevel).toBe("off");
await harness.session.cycleModel();
expect(harness.session.model?.id).toBe("faux-1");
expect(harness.session.thinkingLevel).toBe("high");
});
it("clamps thinking levels to model capabilities and cycles available levels", async () => {
const harness = await createHarness({ models: [{ id: "faux-1", reasoning: false }] });
harnesses.push(harness);
harness.session.setThinkingLevel("high");
expect(harness.session.thinkingLevel).toBe("off");
expect(harness.session.cycleThinkingLevel()).toBeUndefined();
});
it("throws when setModel is called without configured auth", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
withConfiguredAuth: false,
});
harnesses.push(harness);
await expect(harness.session.setModel(harness.getModel("faux-2")!)).rejects.toThrow(
`No API key for ${harness.getModel().provider}/faux-2`,
);
});
it("allows extension tool_call handlers to block tool execution", async () => {
const echoTool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo text back",
parameters: Type.Object({ text: Type.String() }),
execute: async () => {
throw new Error("tool should have been blocked");
},
};
const harness = await createHarness({
tools: [echoTool],
extensionFactories: [
(pi) => {
pi.on("tool_call", async () => ({ block: true, reason: "Blocked by test" }));
},
],
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
(context) => {
const toolResult = context.messages.find((message) => message.role === "toolResult");
const errorText =
toolResult?.role === "toolResult"
? toolResult.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("\n")
: "";
return fauxAssistantMessage(errorText);
},
]);
await harness.session.prompt("hi");
expect(getAssistantTexts(harness)).toContain("Blocked by test");
expect(
harness.session.messages.find((message) => message.role === "toolResult" && message.isError),
).toBeDefined();
});
it("allows extension tool_result handlers to modify tool results", async () => {
const echoTool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo text back",
parameters: Type.Object({ text: Type.String() }),
execute: async (_toolCallId, params) => {
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
return { content: [{ type: "text", text }], details: { text } };
},
};
const harness = await createHarness({
tools: [echoTool],
extensionFactories: [
(pi) => {
pi.on("tool_result", async () => ({
content: [{ type: "text", text: "patched result" }],
details: { patched: true },
}));
},
],
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
(context) => {
const toolResult = context.messages.find((message) => message.role === "toolResult");
const text =
toolResult?.role === "toolResult"
? toolResult.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("\n")
: "";
return fauxAssistantMessage(text);
},
]);
await harness.session.prompt("hi");
expect(getAssistantTexts(harness)).toContain("patched result");
expect(
harness.session.messages.find((message) => message.role === "toolResult" && message.details?.patched === true),
).toBeDefined();
});
it("allows extension context handlers to modify messages before the LLM call", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("context", async (event) => ({
messages: event.messages.map((message) =>
message.role === "user"
? { ...message, content: [{ type: "text", text: "rewritten" }], timestamp: message.timestamp }
: message,
),
}));
},
],
});
harnesses.push(harness);
let providerUserText = "";
harness.setResponses([
(context) => {
const user = context.messages.find((message) => message.role === "user");
providerUserText =
user && typeof user.content !== "string"
? user.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("\n")
: "";
return fauxAssistantMessage("done");
},
]);
await harness.session.prompt("original");
expect(providerUserText).toBe("rewritten");
const storedUserMessage = harness.session.messages.find((message) => message.role === "user");
expect(storedUserMessage?.role).toBe("user");
if (storedUserMessage?.role === "user") {
expect(storedUserMessage.content).toEqual([{ type: "text", text: "original" }]);
}
});
it("allows extension input handlers to transform or handle input", async () => {
let extensionApi: ExtensionAPI | undefined;
const transformedHarness = await createHarness({
extensionFactories: [
(pi) => {
extensionApi = pi;
pi.on("input", async (event) => {
if (event.text === "ping") {
return { action: "handled" };
}
return { action: "transform", text: `transformed:${event.text}` };
});
},
],
});
harnesses.push(transformedHarness);
let providerUserText = "";
transformedHarness.setResponses([
(context) => {
const user = context.messages.find((message) => message.role === "user");
providerUserText =
user && typeof user.content !== "string"
? user.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("\n")
: "";
return fauxAssistantMessage("done");
},
]);
await transformedHarness.session.prompt("hello");
await transformedHarness.session.prompt("ping");
expect(providerUserText).toBe("transformed:hello");
expect(transformedHarness.session.messages.filter((message) => message.role === "user")).toHaveLength(1);
expect(extensionApi).toBeDefined();
});
it("allows before_agent_start handlers to inject custom messages and modify the system prompt", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("before_agent_start", async (event) => ({
message: {
customType: "before-start",
content: "injected",
display: true,
details: { injected: true },
},
systemPrompt: `${event.systemPrompt}\n\nextra instructions`,
}));
},
],
});
harnesses.push(harness);
let providerSystemPrompt = "";
let sawInjectedUserMessage = false;
harness.setResponses([
(context) => {
providerSystemPrompt = context.systemPrompt ?? "";
sawInjectedUserMessage = context.messages.some(
(message) =>
message.role === "user" &&
typeof message.content !== "string" &&
message.content.some((part) => part.type === "text" && part.text === "injected"),
);
return fauxAssistantMessage("done");
},
]);
await harness.session.prompt("hello");
expect(providerSystemPrompt).toContain("extra instructions");
expect(sawInjectedUserMessage).toBe(true);
expect(
harness.session.messages.some((message) => message.role === "custom" && message.customType === "before-start"),
).toBe(true);
});
it("bindExtensions emits session_start and reload emits session_shutdown then session_start", async () => {
const lifecycleEvents: string[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_start", async (event) => {
lifecycleEvents.push(`start:${event.reason}`);
});
pi.on("session_shutdown", async () => {
lifecycleEvents.push("shutdown");
});
},
],
});
harnesses.push(harness);
await harness.session.bindExtensions({ shutdownHandler: () => {} });
await harness.session.reload();
expect(lifecycleEvents).toEqual(["start:startup", "shutdown", "start:reload"]);
});
});
@@ -0,0 +1,323 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall, type Model } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { afterEach, describe, expect, it } from "vitest";
import type { PromptTemplate } from "../../src/core/prompt-templates.js";
import { createSyntheticSourceInfo } from "../../src/core/source-info.js";
import { createTestResourceLoader } from "../utilities.js";
import { createHarness, getMessageText, type Harness } from "./harness.js";
describe("AgentSession prompt characterization", () => {
const harnesses: Harness[] = [];
const tempDirs: string[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
while (tempDirs.length > 0) {
const tempDir = tempDirs.pop();
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
}
}
});
it("prompts while idle and records a single text response", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("hello")]);
await harness.session.prompt("hi");
expect(harness.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
expect(getMessageText(harness.session.messages[0]!)).toBe("hi");
expect(harness.getPendingResponseCount()).toBe(0);
});
it("handles a tool call turn and waits for the follow-up LLM response", async () => {
const toolRuns: string[] = [];
const echoTool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo text back",
parameters: Type.Object({ text: Type.String() }),
execute: async (_toolCallId, params) => {
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
toolRuns.push(text);
return {
content: [{ type: "text", text: `echo:${text}` }],
details: { text },
};
},
};
const harness = await createHarness({ tools: [echoTool] });
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage(fauxToolCall("echo", { text: "hello" }), { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
]);
await harness.session.prompt("start");
expect(toolRuns).toEqual(["hello"]);
expect(harness.session.messages.map((message) => message.role)).toEqual([
"user",
"assistant",
"toolResult",
"assistant",
]);
expect(harness.session.messages[2]?.role).toBe("toolResult");
expect(harness.session.messages[3]?.role).toBe("assistant");
});
it("executes multiple tool calls from one response and continues with a single follow-up response", async () => {
const toolRuns: string[] = [];
const makeTool = (name: string, delayMs: number): AgentTool => ({
name,
label: name,
description: `${name} tool`,
parameters: Type.Object({ value: Type.String() }),
execute: async (_toolCallId, params) => {
const value =
typeof params === "object" && params !== null && "value" in params ? String(params.value) : "";
await new Promise((resolve) => setTimeout(resolve, delayMs));
toolRuns.push(`${name}:${value}`);
return {
content: [{ type: "text", text: `${name}:${value}` }],
details: { value },
};
},
});
const harness = await createHarness({ tools: [makeTool("slow", 25), makeTool("fast", 0)] });
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("slow", { value: "a" }), fauxToolCall("fast", { value: "b" })], {
stopReason: "toolUse",
}),
(context) => {
const toolResults = context.messages.filter((message) => message.role === "toolResult");
return fauxAssistantMessage(`tool results: ${toolResults.length}`);
},
]);
await harness.session.prompt("run tools");
expect(toolRuns.sort()).toEqual(["fast:b", "slow:a"]);
expect(harness.session.messages.filter((message) => message.role === "toolResult")).toHaveLength(2);
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("assistant");
});
it("preserves image attachments in the provider context", async () => {
const harness = await createHarness();
harnesses.push(harness);
let sawImage = false;
harness.setResponses([
(context) => {
const user = context.messages.find((message) => message.role === "user");
sawImage =
user?.role === "user" &&
typeof user.content !== "string" &&
user.content.some((part) => part.type === "image");
return fauxAssistantMessage("ok");
},
]);
await harness.session.prompt("describe", {
images: [
{
type: "image",
mimeType: "image/png",
data: "ZmFrZQ==",
},
],
});
expect(sawImage).toBe(true);
});
it("expands skill commands before sending the prompt", async () => {
const tempDir = join(tmpdir(), `pi-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
tempDirs.push(tempDir);
const skillPath = join(tempDir, "test-skill.md");
writeFileSync(skillPath, "# Test Skill\n\nUse the skill body.");
const resourceLoader = {
...createTestResourceLoader(),
getSkills: () => ({
skills: [
{
name: "test",
description: "Test skill",
filePath: skillPath,
disableModelInvocation: false,
baseDir: tempDir,
sourceInfo: createSyntheticSourceInfo(skillPath, {
source: "local",
scope: "project",
origin: "top-level",
baseDir: tempDir,
}),
},
],
diagnostics: [],
}),
};
const harness = await createHarness({ resourceLoader });
harnesses.push(harness);
let expandedPrompt = "";
harness.setResponses([
(context) => {
const user = context.messages.find((message) => message.role === "user");
expandedPrompt = user ? getMessageText(user) : "";
return fauxAssistantMessage("ok");
},
]);
await harness.session.prompt("/skill:test explain this");
expect(expandedPrompt).toContain('<skill name="test" location="');
expect(expandedPrompt).toContain("Use the skill body.");
expect(expandedPrompt).toContain("explain this");
});
it("expands prompt templates before sending the prompt", async () => {
const template: PromptTemplate = {
name: "review",
description: "Review template",
content: "Review this code: $1",
filePath: "/virtual/review.md",
sourceInfo: createSyntheticSourceInfo("/virtual/review.md", {
source: "local",
scope: "temporary",
origin: "top-level",
}),
};
const resourceLoader = {
...createTestResourceLoader(),
getPrompts: () => ({ prompts: [template], diagnostics: [] }),
};
const harness = await createHarness({ resourceLoader });
harnesses.push(harness);
let expandedPrompt = "";
harness.setResponses([
(context) => {
const user = context.messages.find((message) => message.role === "user");
expandedPrompt = user ? getMessageText(user) : "";
return fauxAssistantMessage("ok");
},
]);
await harness.session.prompt("/review src/index.ts");
expect(expandedPrompt).toBe("Review this code: src/index.ts");
});
it("dispatches extension commands without consuming a provider response", async () => {
const commandRuns: string[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.registerCommand("testcmd", {
description: "Test command",
handler: async (args) => {
commandRuns.push(args);
},
});
},
],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("should stay queued")]);
await harness.session.prompt("/testcmd hello world");
expect(commandRuns).toEqual(["hello world"]);
expect(harness.session.messages).toEqual([]);
expect(harness.getPendingResponseCount()).toBe(1);
});
it("sendUserMessage while idle triggers a turn", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("response")]);
await harness.session.sendUserMessage("from extension");
expect(harness.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
expect(getMessageText(harness.session.messages[0]!)).toBe("from extension");
});
it("throws when prompted during streaming without a streamingBehavior", async () => {
let releaseToolExecution: (() => void) | undefined;
const toolRelease = new Promise<void>((resolve) => {
releaseToolExecution = resolve;
});
const waitTool: AgentTool = {
name: "wait",
label: "Wait",
description: "Wait for release",
parameters: Type.Object({}),
execute: async () => {
await toolRelease;
return {
content: [{ type: "text", text: "released" }],
details: {},
};
},
};
const harness = await createHarness({ tools: [waitTool] });
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
]);
const sawToolStart = new Promise<void>((resolve) => {
const unsubscribe = harness.session.subscribe((event) => {
if (event.type === "tool_execution_start") {
unsubscribe();
resolve();
}
});
});
const promptPromise = harness.session.prompt("start");
await sawToolStart;
await expect(harness.session.prompt("second")).rejects.toThrow(
"Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
);
releaseToolExecution?.();
await promptPromise;
});
it("throws when prompting without a model", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.session.agent.state.model = undefined as unknown as Model<any>;
await expect(harness.session.prompt("hi")).rejects.toThrow("No model selected.");
});
it("throws when prompting without configured auth", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
await expect(harness.session.prompt("hi")).rejects.toThrow(
`No API key found for ${harness.getModel().provider}.`,
);
});
});
@@ -0,0 +1,422 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, getAssistantTexts, getMessageText, getUserTexts, type Harness } from "./harness.js";
async function createWaitingHarness(
options: {
tools?: AgentTool[];
extensionFactories?: Harness["session"]["extensionRunner"] extends never
? never
: Array<(pi: ExtensionAPI) => void>;
} = {},
): Promise<{
harness: Harness;
releaseToolExecution: () => void;
promptPromise: Promise<void>;
waitForToolStart: Promise<void>;
}> {
let releaseToolExecution: (() => void) | undefined;
const toolRelease = new Promise<void>((resolve) => {
releaseToolExecution = resolve;
});
const waitTool: AgentTool = {
name: "wait",
label: "Wait",
description: "Wait for release",
parameters: Type.Object({}),
execute: async () => {
await toolRelease;
return {
content: [{ type: "text", text: "released" }],
details: {},
};
},
};
const harness = await createHarness({
tools: [waitTool, ...(options.tools ?? [])],
extensionFactories: options.extensionFactories,
});
const waitForToolStart = new Promise<void>((resolve) => {
const unsubscribe = harness.session.subscribe((event) => {
if (event.type === "tool_execution_start" && event.toolName === "wait") {
unsubscribe();
resolve();
}
});
});
return {
harness,
releaseToolExecution: () => releaseToolExecution?.(),
promptPromise: harness.session.prompt("start"),
waitForToolStart,
};
}
describe("AgentSession queue characterization", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("dispatches extension commands immediately when prompted while idle", async () => {
const commandRuns: string[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.registerCommand("testcmd", {
description: "Test command",
handler: async (args) => {
commandRuns.push(args);
},
});
},
],
});
harnesses.push(harness);
await harness.session.prompt("/testcmd hello world");
expect(commandRuns).toEqual(["hello world"]);
expect(harness.getPendingResponseCount()).toBe(0);
expect(harness.session.messages).toEqual([]);
});
it("delivers extension-origin steering messages before the next LLM call", async () => {
let extensionApi: ExtensionAPI | undefined;
const waiting = await createWaitingHarness({
extensionFactories: [
(pi) => {
extensionApi = pi;
},
],
});
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
(context) => {
const sawSteer = context.messages.some(
(message) => message.role === "user" && getMessageText(message) === "steer now",
);
return fauxAssistantMessage(sawSteer ? "saw steer" : "missing steer");
},
]);
await waitForToolStart;
await new Promise((resolve) => setTimeout(resolve, 0));
extensionApi?.sendUserMessage("steer now", { deliverAs: "steer" });
releaseToolExecution();
await promptPromise;
expect(getUserTexts(harness)).toEqual(["start", "steer now"]);
expect(getAssistantTexts(harness)).toContain("saw steer");
});
it("delivers follow-up messages only after the current run finishes", async () => {
const waiting = await createWaitingHarness();
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
harnesses.push(harness);
const assistantSeenBeforeFollowUp: string[] = [];
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
(context) => {
assistantSeenBeforeFollowUp.push(
...context.messages
.filter((message) => message.role === "assistant")
.map((message) =>
message.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("\n"),
),
);
return fauxAssistantMessage("follow-up response");
},
]);
await waitForToolStart;
await harness.session.followUp("after current run");
releaseToolExecution();
await promptPromise;
expect(getUserTexts(harness)).toEqual(["start", "after current run"]);
expect(assistantSeenBeforeFollowUp).toContain("");
expect(getAssistantTexts(harness)).toContain("follow-up response");
});
it("delivers multiple steering messages in order in one-at-a-time mode", async () => {
const waiting = await createWaitingHarness();
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
fauxAssistantMessage("handled steer 1"),
fauxAssistantMessage("handled steer 2"),
]);
await waitForToolStart;
await harness.session.steer("steer 1");
await harness.session.steer("steer 2");
releaseToolExecution();
await promptPromise;
expect(getUserTexts(harness)).toEqual(["start", "steer 1", "steer 2"]);
expect(getAssistantTexts(harness)).toEqual(["", "handled steer 1", "handled steer 2"]);
});
it("delivers multiple follow-up messages in order in one-at-a-time mode", async () => {
const waiting = await createWaitingHarness();
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
fauxAssistantMessage("original turn complete"),
fauxAssistantMessage("handled follow-up 1"),
fauxAssistantMessage("handled follow-up 2"),
]);
await waitForToolStart;
await harness.session.followUp("follow-up 1");
await harness.session.followUp("follow-up 2");
releaseToolExecution();
await promptPromise;
expect(getUserTexts(harness)).toEqual(["start", "follow-up 1", "follow-up 2"]);
expect(getAssistantTexts(harness)).toEqual([
"",
"original turn complete",
"handled follow-up 1",
"handled follow-up 2",
]);
});
it("delivers all steering messages in one batch in all mode", async () => {
const waiting = await createWaitingHarness();
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
harnesses.push(harness);
harness.session.setSteeringMode("all");
let batchedUserMessages: string[] = [];
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
(context) => {
batchedUserMessages = context.messages
.filter((message) => message.role === "user")
.map((message) => getMessageText(message));
return fauxAssistantMessage("batched steer response");
},
]);
await waitForToolStart;
await harness.session.steer("steer 1");
await harness.session.steer("steer 2");
releaseToolExecution();
await promptPromise;
expect(batchedUserMessages).toEqual(["start", "steer 1", "steer 2"]);
expect(getAssistantTexts(harness)).toEqual(["", "batched steer response"]);
});
it("delivers all follow-up messages in one batch in all mode", async () => {
const waiting = await createWaitingHarness();
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
harnesses.push(harness);
harness.session.setFollowUpMode("all");
let batchedUserMessages: string[] = [];
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
fauxAssistantMessage("original turn complete"),
(context) => {
batchedUserMessages = context.messages
.filter((message) => message.role === "user")
.map((message) => getMessageText(message));
return fauxAssistantMessage("batched follow-up response");
},
]);
await waitForToolStart;
await harness.session.followUp("follow-up 1");
await harness.session.followUp("follow-up 2");
releaseToolExecution();
await promptPromise;
expect(batchedUserMessages).toEqual(["start", "follow-up 1", "follow-up 2"]);
expect(getAssistantTexts(harness)).toEqual(["", "original turn complete", "batched follow-up response"]);
});
it("queues custom messages with deliverAs steer while streaming", async () => {
const waiting = await createWaitingHarness();
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
harnesses.push(harness);
let sawCustomMessage = false;
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
(context) => {
sawCustomMessage = context.messages.some(
(message) =>
message.role === "user" &&
typeof message.content !== "string" &&
message.content.some((part) => part.type === "text" && part.text === "steer custom"),
);
return fauxAssistantMessage("done");
},
]);
await waitForToolStart;
await harness.session.sendCustomMessage(
{ customType: "queue-test", content: "steer custom", display: true, details: { value: 1 } },
{ deliverAs: "steer" },
);
releaseToolExecution();
await promptPromise;
expect(sawCustomMessage).toBe(true);
expect(
harness.session.messages.some((message) => message.role === "custom" && message.customType === "queue-test"),
).toBe(true);
});
it("queues custom messages with deliverAs followUp while streaming", async () => {
const waiting = await createWaitingHarness();
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
harnesses.push(harness);
let sawCustomMessage = false;
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
fauxAssistantMessage("original turn complete"),
(context) => {
sawCustomMessage = context.messages.some(
(message) =>
message.role === "user" &&
typeof message.content !== "string" &&
message.content.some((part) => part.type === "text" && part.text === "follow-up custom"),
);
return fauxAssistantMessage("done");
},
]);
await waitForToolStart;
await harness.session.sendCustomMessage(
{ customType: "queue-test", content: "follow-up custom", display: true, details: { value: 1 } },
{ deliverAs: "followUp" },
);
releaseToolExecution();
await promptPromise;
expect(sawCustomMessage).toBe(true);
expect(
harness.session.messages.some((message) => message.role === "custom" && message.customType === "queue-test"),
).toBe(true);
});
it("injects nextTurn custom messages into the next prompt", async () => {
const harness = await createHarness();
harnesses.push(harness);
let sawCustomMessage = false;
await harness.session.sendCustomMessage(
{ customType: "next-turn", content: "carry this", display: true, details: {} },
{ deliverAs: "nextTurn" },
);
harness.setResponses([
(context) => {
sawCustomMessage = context.messages.some(
(message) =>
message.role === "user" &&
typeof message.content !== "string" &&
message.content.some((part) => part.type === "text" && part.text === "carry this"),
);
return fauxAssistantMessage("done");
},
]);
await harness.session.prompt("normal prompt");
expect(sawCustomMessage).toBe(true);
expect(harness.session.messages.map((message) => message.role)).toEqual(["user", "custom", "assistant"]);
});
it("updates pendingMessageCount and removes queued text before message_start is emitted", async () => {
const waiting = await createWaitingHarness();
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
harnesses.push(harness);
const countsAtQueuedMessageStart: number[] = [];
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
]);
harness.session.subscribe((event) => {
if (
event.type === "message_start" &&
event.message.role === "user" &&
getMessageText(event.message) === "queued"
) {
countsAtQueuedMessageStart.push(harness.session.pendingMessageCount);
}
});
await waitForToolStart;
await harness.session.steer("queued");
expect(harness.session.pendingMessageCount).toBe(1);
releaseToolExecution();
await promptPromise;
expect(countsAtQueuedMessageStart).toEqual([0]);
expect(harness.session.pendingMessageCount).toBe(0);
});
it("throws when queueing an extension command with steer", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.registerCommand("testcmd", {
description: "Test command",
handler: async () => {},
});
},
],
});
harnesses.push(harness);
await expect(harness.session.steer("/testcmd queued")).rejects.toThrow(
'Extension command "/testcmd" cannot be queued. Use prompt() or execute the command when not streaming.',
);
});
it("throws when queueing an extension command with followUp", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.registerCommand("testcmd", {
description: "Test command",
handler: async () => {},
});
},
],
});
harnesses.push(harness);
await expect(harness.session.followUp("/testcmd queued")).rejects.toThrow(
'Extension command "/testcmd" cannot be queued. Use prompt() or execute the command when not streaming.',
);
});
});
@@ -0,0 +1,358 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { fauxAssistantMessage, fauxThinking, fauxToolCall } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, type Harness } from "./harness.js";
function normalizeEventOrder(events: Harness["events"]): string[] {
const normalized: string[] = [];
for (const event of events) {
const label =
event.type === "message_start" || event.type === "message_end"
? `${event.type}:${event.message.role}`
: event.type === "tool_execution_start" || event.type === "tool_execution_end"
? `${event.type}:${event.toolName}`
: event.type;
if (label === "message_update" && normalized[normalized.length - 1] === "message_update") {
continue;
}
normalized.push(label);
}
return normalized;
}
describe("AgentSession retry and event characterization", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("retries after a transient error and succeeds", async () => {
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
harnesses.push(harness);
const retryEvents: string[] = [];
harness.session.subscribe((event) => {
if (event.type === "auto_retry_start") retryEvents.push(`start:${event.attempt}`);
if (event.type === "auto_retry_end") retryEvents.push(`end:${event.success}`);
});
harness.setResponses([
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
fauxAssistantMessage("recovered"),
]);
await harness.session.prompt("test");
expect(retryEvents).toEqual(["start:1", "end:true"]);
expect(harness.faux.state.callCount).toBe(2);
expect(harness.session.isRetrying).toBe(false);
});
it("retries multiple transient failures and succeeds on the final attempt", async () => {
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
harnesses.push(harness);
const retryEvents: string[] = [];
harness.session.subscribe((event) => {
if (event.type === "auto_retry_start") retryEvents.push(`start:${event.attempt}`);
if (event.type === "auto_retry_end") retryEvents.push(`end:${event.success}`);
});
harness.setResponses([
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
fauxAssistantMessage("success"),
]);
await harness.session.prompt("test");
expect(retryEvents).toEqual(["start:1", "start:2", "end:true"]);
expect(harness.faux.state.callCount).toBe(3);
});
it("exhausts max retries and emits a failure event", async () => {
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 2, baseDelayMs: 1 } } });
harnesses.push(harness);
const retryEvents: string[] = [];
harness.session.subscribe((event) => {
if (event.type === "auto_retry_start") retryEvents.push(`start:${event.attempt}`);
if (event.type === "auto_retry_end") retryEvents.push(`end:${event.success}`);
});
harness.setResponses([
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
]);
await harness.session.prompt("test");
expect(retryEvents).toEqual(["start:1", "start:2", "end:false"]);
expect(harness.faux.state.callCount).toBe(3);
expect(harness.session.isRetrying).toBe(false);
});
it("prompt waits for retry completion even when assistant message_end handling is delayed", async () => {
const harness = await createHarness({
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
extensionFactories: [
(pi) => {
pi.on("message_end", async (event) => {
if (event.message.role === "assistant") {
await new Promise((resolve) => setTimeout(resolve, 40));
}
});
},
],
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
fauxAssistantMessage("recovered"),
]);
await harness.session.prompt("test");
expect(harness.faux.state.callCount).toBe(2);
expect(harness.session.isRetrying).toBe(false);
});
it("does not retry when retry is disabled", async () => {
const harness = await createHarness({ settings: { retry: { enabled: false } } });
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" })]);
await harness.session.prompt("test");
expect(harness.faux.state.callCount).toBe(1);
expect(harness.eventsOfType("auto_retry_start")).toEqual([]);
});
it("does not retry non-retryable errors", async () => {
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "invalid_api_key" })]);
await harness.session.prompt("test");
expect(harness.faux.state.callCount).toBe(1);
expect(harness.eventsOfType("auto_retry_start")).toEqual([]);
});
it("cancels retry sleep when abortRetry is called", async () => {
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 100 } } });
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" })]);
const sawRetryStart = new Promise<void>((resolve) => {
const unsubscribe = harness.session.subscribe((event) => {
if (event.type === "auto_retry_start") {
unsubscribe();
resolve();
}
});
});
const promptPromise = harness.session.prompt("test");
await sawRetryStart;
harness.session.abortRetry();
await promptPromise;
expect(harness.session.isRetrying).toBe(false);
expect(harness.eventsOfType("auto_retry_end").map((event) => event.finalError)).toContain("Retry cancelled");
expect(harness.faux.state.callCount).toBe(1);
});
it("waits for the full loop when retry recovery produces tool calls", async () => {
const toolRuns: string[] = [];
const echoTool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo text back",
parameters: Type.Object({ text: Type.String() }),
execute: async (_toolCallId, params) => {
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
toolRuns.push(text);
return { content: [{ type: "text", text: `echo:${text}` }], details: { text } };
},
};
const harness = await createHarness({
tools: [echoTool],
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
fauxAssistantMessage("final answer"),
]);
await harness.session.prompt("test");
expect(harness.faux.state.callCount).toBe(3);
expect(toolRuns).toEqual(["hello"]);
expect(harness.session.isStreaming).toBe(false);
await harness.session.prompt("follow-up");
expect(harness.faux.state.callCount).toBe(4);
});
it("emits extension events before public event subscribers", async () => {
const order: string[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("message_start", async (event) => {
order.push(`extension:${event.type}:${event.message.role}`);
});
pi.on("message_end", async (event) => {
order.push(`extension:${event.type}:${event.message.role}`);
});
},
],
});
harnesses.push(harness);
harness.session.subscribe((event) => {
if (event.type === "message_start" || event.type === "message_end") {
order.push(`public:${event.type}:${event.message.role}`);
}
});
harness.setResponses([fauxAssistantMessage("done")]);
await harness.session.prompt("hi");
expect(order).toEqual([
"extension:message_start:user",
"public:message_start:user",
"extension:message_end:user",
"public:message_end:user",
"extension:message_start:assistant",
"public:message_start:assistant",
"extension:message_end:assistant",
"public:message_end:assistant",
]);
});
it("emits the expected event order for a single prompt", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("hello")]);
await harness.session.prompt("hi");
expect(normalizeEventOrder(harness.events)).toEqual([
"agent_start",
"turn_start",
"message_start:user",
"message_end:user",
"message_start:assistant",
"message_update",
"message_end:assistant",
"turn_end",
"agent_end",
]);
});
it("emits the expected event order for a tool call turn", async () => {
const toolRuns: string[] = [];
const echoTool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo text back",
parameters: Type.Object({ text: Type.String() }),
execute: async (_toolCallId, params) => {
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
toolRuns.push(text);
return { content: [{ type: "text", text: `echo:${text}` }], details: { text } };
},
};
const harness = await createHarness({ tools: [echoTool] });
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
]);
await harness.session.prompt("hi");
expect(toolRuns).toEqual(["hello"]);
expect(normalizeEventOrder(harness.events)).toEqual([
"agent_start",
"turn_start",
"message_start:user",
"message_end:user",
"message_start:assistant",
"message_update",
"message_end:assistant",
"tool_execution_start:echo",
"tool_execution_end:echo",
"message_start:toolResult",
"message_end:toolResult",
"turn_end",
"turn_start",
"message_start:assistant",
"message_update",
"message_end:assistant",
"turn_end",
"agent_end",
]);
});
it("emits streaming deltas for text, thinking, and tool calls in message_update events", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage(
[fauxThinking("plan"), { type: "text", text: "answer" }, fauxToolCall("echo", { text: "hello" })],
{
stopReason: "toolUse",
},
),
]);
await harness.session.prompt("hi").catch(() => {});
const updateTypes = harness.eventsOfType("message_update").map((event) => event.assistantMessageEvent.type);
expect(updateTypes).toContain("thinking_delta");
expect(updateTypes).toContain("text_delta");
expect(updateTypes).toContain("toolcall_delta");
});
it("emits agent_end for error responses", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "broken" })]);
await harness.session.prompt("hi");
expect(harness.events[harness.events.length - 1]?.type).toBe("agent_end");
});
it("emits agent_end for aborted runs and persists the aborted assistant message", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("x".repeat(20_000))]);
const sawMessageUpdate = new Promise<void>((resolve) => {
const unsubscribe = harness.session.subscribe((event) => {
if (event.type === "message_update") {
unsubscribe();
resolve();
}
});
});
const promptPromise = harness.session.prompt("hi");
await sawMessageUpdate;
await harness.session.abort();
await promptPromise;
expect(harness.events[harness.events.length - 1]?.type).toBe("agent_end");
const lastMessage = harness.session.messages[harness.session.messages.length - 1];
expect(lastMessage?.role).toBe("assistant");
if (lastMessage?.role === "assistant") {
expect(lastMessage.stopReason).toBe("aborted");
}
});
});
@@ -0,0 +1,382 @@
import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fauxAssistantMessage, registerFauxProvider } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import {
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
} from "../../src/core/agent-session-runtime.js";
import { AuthStorage } from "../../src/core/auth-storage.js";
import { SessionManager } from "../../src/core/session-manager.js";
import type {
ExtensionAPI,
ExtensionFactory,
SessionBeforeForkEvent,
SessionBeforeSwitchEvent,
SessionStartEvent,
} from "../../src/index.js";
type RecordedSessionEvent = SessionBeforeSwitchEvent | SessionBeforeForkEvent | SessionStartEvent;
describe("AgentSessionRuntime characterization", () => {
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
while (cleanups.length > 0) {
await cleanups.pop()?.();
}
process.chdir(tmpdir());
});
async function createRuntimeForTest(
extensionFactory: ExtensionFactory,
options?: { cwd?: string; bootstrapModel?: boolean; bootstrapThinkingLevel?: boolean },
) {
const tempDir =
options?.cwd ?? join(tmpdir(), `pi-runtime-suite-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
const faux = registerFauxProvider({
models: [
{ id: "faux-1", reasoning: true },
{ id: "faux-2", reasoning: false },
],
});
faux.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two"), fauxAssistantMessage("three")]);
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const runtimeOptions = {
agentDir: tempDir,
authStorage,
model: options?.bootstrapModel === false ? undefined : faux.getModel(),
thinkingLevel: options?.bootstrapThinkingLevel === false ? undefined : undefined,
resourceLoaderOptions: {
extensionFactories: [
(pi: ExtensionAPI) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
extensionFactory(pi);
},
],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
};
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
...runtimeOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model: runtimeOptions.model,
thinkingLevel: runtimeOptions.thinkingLevel,
})),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: tempDir,
agentDir: tempDir,
sessionManager: SessionManager.create(tempDir),
});
await runtime.session.bindExtensions({});
cleanups.push(async () => {
await runtime.dispose();
faux.unregister();
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
return { runtime, faux, tempDir };
}
it("emits session_before_switch and session_start for new and resume flows", async () => {
const events: RecordedSessionEvent[] = [];
const { runtime } = await createRuntimeForTest((pi: ExtensionAPI) => {
pi.on("session_before_switch", (event) => {
events.push(event);
});
pi.on("session_start", (event) => {
events.push(event);
});
});
expect(events).toEqual([{ type: "session_start", reason: "startup" }]);
events.length = 0;
await runtime.session.prompt("hello");
const originalSessionFile = runtime.session.sessionFile;
const originalSession = runtime.session;
const newSessionResult = await runtime.newSession();
expect(newSessionResult.cancelled).toBe(false);
await runtime.session.bindExtensions({});
expect(runtime.session).not.toBe(originalSession);
expect(runtime.session.messages).toEqual([]);
expect(events).toEqual([
{ type: "session_before_switch", reason: "new", targetSessionFile: undefined },
{ type: "session_start", reason: "new", previousSessionFile: originalSessionFile },
]);
events.length = 0;
const secondSessionFile = runtime.session.sessionFile;
const switchResult = await runtime.switchSession(originalSessionFile!);
expect(switchResult.cancelled).toBe(false);
await runtime.session.bindExtensions({});
expect(events).toEqual([
{ type: "session_before_switch", reason: "resume", targetSessionFile: originalSessionFile },
{ type: "session_start", reason: "resume", previousSessionFile: secondSessionFile },
]);
});
it("honors session_before_switch cancellation for new and resume", async () => {
const events: RecordedSessionEvent[] = [];
let cancelReason: "new" | "resume" | undefined;
const { runtime } = await createRuntimeForTest((pi: ExtensionAPI) => {
pi.on("session_before_switch", (event) => {
events.push(event);
if (event.reason === cancelReason) {
return { cancel: true };
}
});
pi.on("session_start", (event) => {
events.push(event);
});
});
await runtime.session.prompt("hello");
const originalSessionFile = runtime.session.sessionFile;
cancelReason = "new";
const newResult = await runtime.newSession();
expect(newResult.cancelled).toBe(true);
expect(runtime.session.sessionFile).toBe(originalSessionFile);
events.length = 0;
const otherDir = join(tmpdir(), `pi-runtime-other-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(otherDir, { recursive: true });
const otherSession = SessionManager.create(otherDir);
otherSession.appendMessage({ role: "user", content: [{ type: "text", text: "other" }], timestamp: Date.now() });
const otherSessionFile = otherSession.getSessionFile();
cancelReason = "resume";
const resumeResult = await runtime.switchSession(otherSessionFile!);
expect(resumeResult.cancelled).toBe(true);
expect(runtime.session.sessionFile).toBe(originalSessionFile);
});
it("emits session_before_fork and session_start and honors cancellation", async () => {
const events: RecordedSessionEvent[] = [];
let cancelNextFork = false;
const { runtime } = await createRuntimeForTest((pi: ExtensionAPI) => {
pi.on("session_before_fork", (event) => {
events.push(event);
if (cancelNextFork) {
cancelNextFork = false;
return { cancel: true };
}
});
pi.on("session_start", (event) => {
events.push(event);
});
});
events.length = 0;
await runtime.session.prompt("hello");
const userMessage = runtime.session.getUserMessagesForForking()[0]!;
const previousSessionFile = runtime.session.sessionFile;
const successResult = await runtime.fork(userMessage.entryId);
expect(successResult.cancelled).toBe(false);
expect(successResult.selectedText).toBe("hello");
await runtime.session.bindExtensions({});
expect(events).toEqual([
{ type: "session_before_fork", entryId: userMessage.entryId },
{ type: "session_start", reason: "fork", previousSessionFile },
]);
events.length = 0;
cancelNextFork = true;
const cancelResult = await runtime.fork(userMessage.entryId);
expect(cancelResult).toEqual({ cancelled: true });
expect(events).toEqual([{ type: "session_before_fork", entryId: userMessage.entryId }]);
});
it("throws when forking with an invalid entry id", async () => {
const { runtime } = await createRuntimeForTest(() => {});
await expect(runtime.fork("missing-entry")).rejects.toThrow("Invalid entry ID for forking");
});
it("updates process.cwd() on cross-cwd session replacement", async () => {
const firstDir = join(tmpdir(), `pi-runtime-cwd-a-${Date.now()}-${Math.random().toString(36).slice(2)}`);
const secondDir = join(tmpdir(), `pi-runtime-cwd-b-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(firstDir, { recursive: true });
mkdirSync(secondDir, { recursive: true });
const { runtime, faux, tempDir } = await createRuntimeForTest(() => {}, { cwd: firstDir });
const otherAuthStorage = AuthStorage.inMemory();
otherAuthStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const otherRuntimeOptions = {
agentDir: tempDir,
authStorage: otherAuthStorage,
resourceLoaderOptions: {
extensionFactories: [
(pi: ExtensionAPI) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
};
const createOtherRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd,
sessionManager,
sessionStartEvent,
}) => {
const services = await createAgentSessionServices({
...otherRuntimeOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
})),
services,
diagnostics: services.diagnostics,
};
};
const otherRuntime = await createAgentSessionRuntime(createOtherRuntime, {
cwd: secondDir,
agentDir: tempDir,
sessionManager: SessionManager.create(secondDir),
});
cleanups.push(async () => {
await otherRuntime.dispose();
});
await otherRuntime.session.prompt("other");
const otherSessionFile = otherRuntime.session.sessionFile!;
await runtime.switchSession(otherSessionFile);
expect(realpathSync(process.cwd())).toBe(realpathSync(secondDir));
expect(realpathSync(runtime.session.sessionManager.getCwd())).toBe(realpathSync(secondDir));
});
it("restores model and thinking state from the destination session", async () => {
const { runtime, faux, tempDir } = await createRuntimeForTest(() => {}, {
bootstrapModel: false,
bootstrapThinkingLevel: false,
});
const otherDir = join(tempDir, "other");
mkdirSync(otherDir, { recursive: true });
const otherAuthStorage = AuthStorage.inMemory();
otherAuthStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const otherRuntimeOptions = {
agentDir: tempDir,
authStorage: otherAuthStorage,
resourceLoaderOptions: {
extensionFactories: [
(pi: ExtensionAPI) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
};
const createOtherRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd,
sessionManager,
sessionStartEvent,
}) => {
const services = await createAgentSessionServices({
...otherRuntimeOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
})),
services,
diagnostics: services.diagnostics,
};
};
const otherRuntime = await createAgentSessionRuntime(createOtherRuntime, {
cwd: otherDir,
agentDir: tempDir,
sessionManager: SessionManager.create(otherDir),
});
cleanups.push(async () => {
await otherRuntime.dispose();
});
await otherRuntime.session.setModel(faux.getModel("faux-2")!);
otherRuntime.session.setThinkingLevel("off");
await otherRuntime.session.prompt("hello");
const targetSessionFile = otherRuntime.session.sessionFile!;
await runtime.switchSession(targetSessionFile);
expect(runtime.session.model?.id).toBe("faux-2");
expect(runtime.session.thinkingLevel).toBe("off");
});
});
@@ -0,0 +1,208 @@
/**
* Local test harness for the new coding-agent test suite.
*/
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
import { Agent } from "@mariozechner/pi-agent-core";
import type { FauxModelDefinition, FauxProviderRegistration, FauxResponseStep, Model } from "@mariozechner/pi-ai";
import { registerFauxProvider } from "@mariozechner/pi-ai";
import { AgentSession, type AgentSessionEvent } from "../../src/core/agent-session.js";
import { AuthStorage } from "../../src/core/auth-storage.js";
import type { ExtensionRunner } from "../../src/core/extensions/index.js";
import { convertToLlm } from "../../src/core/messages.js";
import { ModelRegistry } from "../../src/core/model-registry.js";
import { SessionManager } from "../../src/core/session-manager.js";
import type { Settings } from "../../src/core/settings-manager.js";
import { SettingsManager } from "../../src/core/settings-manager.js";
import type { ExtensionFactory, ResourceLoader } from "../../src/index.js";
import {
type CreateTestExtensionsResultInput,
createTestExtensionsResult,
createTestResourceLoader,
} from "../utilities.js";
type MessageTextPart = { type: "text"; text: string };
export function getMessageText(message: unknown): string {
if (!message || typeof message !== "object" || !("content" in message)) {
return "";
}
const content = (message as { content?: string | Array<{ type: string; text?: string }> }).content;
if (content === undefined) {
return "";
}
if (typeof content === "string") {
return content;
}
return content
.filter((part): part is MessageTextPart => part.type === "text")
.map((part) => part.text)
.join("\n");
}
export function getUserTexts(harness: Harness): string[] {
return harness.session.messages
.filter((message) => message.role === "user")
.map((message) => getMessageText(message));
}
export function getAssistantTexts(harness: Harness): string[] {
return harness.session.messages
.filter((message) => message.role === "assistant")
.map((message) => getMessageText(message));
}
export interface HarnessOptions {
models?: FauxModelDefinition[];
settings?: Partial<Settings>;
systemPrompt?: string;
tools?: AgentTool[];
resourceLoader?: ResourceLoader;
extensionFactories?: Array<ExtensionFactory | CreateTestExtensionsResultInput>;
withConfiguredAuth?: boolean;
}
export interface Harness {
session: AgentSession;
sessionManager: SessionManager;
settingsManager: SettingsManager;
authStorage: AuthStorage;
faux: FauxProviderRegistration;
models: [Model<string>, ...Model<string>[]];
getModel(): Model<string>;
getModel(modelId: string): Model<string> | undefined;
setResponses: (responses: FauxResponseStep[]) => void;
appendResponses: (responses: FauxResponseStep[]) => void;
getPendingResponseCount: () => number;
events: AgentSessionEvent[];
eventsOfType<T extends AgentSessionEvent["type"]>(type: T): Extract<AgentSessionEvent, { type: T }>[];
tempDir: string;
cleanup: () => void;
}
function createTempDir(): string {
const tempDir = join(tmpdir(), `pi-suite-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
return tempDir;
}
export async function createHarness(options: HarnessOptions = {}): Promise<Harness> {
const tempDir = createTempDir();
const fauxProvider: FauxProviderRegistration = registerFauxProvider({
models: options.models,
});
fauxProvider.setResponses([]);
const model = fauxProvider.getModel();
const toolMap = options.tools ? Object.fromEntries(options.tools.map((tool) => [tool.name, tool])) : undefined;
const withConfiguredAuth = options.withConfiguredAuth ?? true;
const extensionRunnerRef: { current?: ExtensionRunner } = {};
const sessionManager = SessionManager.inMemory();
const settingsManager = SettingsManager.inMemory(options.settings);
const authStorage = AuthStorage.inMemory();
if (withConfiguredAuth) {
authStorage.setRuntimeApiKey(model.provider, "faux-key");
}
const modelRegistry = ModelRegistry.inMemory(authStorage);
if (withConfiguredAuth) {
modelRegistry.registerProvider(model.provider, {
baseUrl: model.baseUrl,
apiKey: "faux-key",
api: fauxProvider.api,
models: fauxProvider.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
baseUrl: registeredModel.baseUrl,
})),
});
}
const agent = new Agent({
getApiKey: () => (withConfiguredAuth ? "faux-key" : undefined),
initialState: {
model,
systemPrompt: options.systemPrompt ?? "You are a test assistant.",
tools: [],
},
convertToLlm,
onPayload: async (payload) => {
const runner = extensionRunnerRef.current;
if (!runner?.hasHandlers("before_provider_request")) {
return payload;
}
return runner.emitBeforeProviderRequest(payload);
},
onResponse: async (response) => {
const runner = extensionRunnerRef.current;
if (!runner?.hasHandlers("after_provider_response")) {
return;
}
await runner.emit({
type: "after_provider_response",
status: response.status,
headers: response.headers,
});
},
transformContext: async (messages: AgentMessage[]) => {
const runner = extensionRunnerRef.current;
if (!runner) return messages;
return runner.emitContext(messages);
},
});
const extensionsResult = options.extensionFactories
? await createTestExtensionsResult(options.extensionFactories, tempDir)
: undefined;
const resourceLoader =
options.resourceLoader ?? createTestResourceLoader(extensionsResult ? { extensionsResult } : undefined);
const session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd: tempDir,
modelRegistry,
resourceLoader,
baseToolsOverride: toolMap,
extensionRunnerRef,
});
const events: AgentSessionEvent[] = [];
session.subscribe((event) => {
events.push(event);
});
return {
session,
sessionManager,
settingsManager,
authStorage,
faux: fauxProvider,
models: fauxProvider.models,
getModel: fauxProvider.getModel,
setResponses: fauxProvider.setResponses,
appendResponses: fauxProvider.appendResponses,
getPendingResponseCount: fauxProvider.getPendingResponseCount,
events,
eventsOfType<T extends AgentSessionEvent["type"]>(type: T) {
return events.filter((event): event is Extract<AgentSessionEvent, { type: T }> => event.type === type);
},
tempDir,
cleanup() {
session.dispose();
fauxProvider.unregister();
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true });
}
},
};
}
@@ -0,0 +1,80 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, getAssistantTexts, getUserTexts, type Harness } from "../harness.js";
describe("issue #2023 queued slash-command follow-up", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("treats extension-origin queued slash-command follow-ups as raw user text instead of dispatching the command", async () => {
let extensionApi: ExtensionAPI | undefined;
const commandRuns: string[] = [];
let releaseToolExecution: (() => void) | undefined;
const toolRelease = new Promise<void>((resolve) => {
releaseToolExecution = resolve;
});
const waitTool: AgentTool = {
name: "wait",
label: "Wait",
description: "Wait for the test to release execution",
parameters: Type.Object({}),
execute: async () => {
await toolRelease;
return {
content: [{ type: "text", text: "released" }],
details: {},
};
},
};
const harness = await createHarness({
tools: [waitTool],
extensionFactories: [
(pi) => {
extensionApi = pi;
pi.registerCommand("testcmd", {
description: "Test command",
handler: async (args) => {
commandRuns.push(args);
},
});
},
],
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
fauxAssistantMessage("first turn complete"),
fauxAssistantMessage("queued follow-up handled by model"),
]);
const sawToolStart = new Promise<void>((resolve) => {
const unsubscribe = harness.session.subscribe((event) => {
if (event.type === "tool_execution_start" && event.toolName === "wait") {
unsubscribe();
resolve();
}
});
});
const promptPromise = harness.session.prompt("start");
await sawToolStart;
await new Promise((resolve) => setTimeout(resolve, 0));
extensionApi?.sendUserMessage("/testcmd queued", { deliverAs: "followUp" });
releaseToolExecution?.();
await promptPromise;
expect(commandRuns).toEqual([]);
expect(getUserTexts(harness)).toEqual(["start", "/testcmd queued"]);
expect(getAssistantTexts(harness)).toContain("queued follow-up handled by model");
});
});
@@ -0,0 +1,100 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerFauxProvider } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import {
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
} from "../../../src/core/agent-session-runtime.js";
import { AuthStorage } from "../../../src/core/auth-storage.js";
import { SessionManager } from "../../../src/core/session-manager.js";
describe("issue #2753 reload stale resource settings", () => {
const cleanups: Array<() => void> = [];
afterEach(() => {
while (cleanups.length > 0) {
cleanups.pop()?.();
}
});
it("applies updated top-level prompt settings on reload after startup", async () => {
const tempDir = join(tmpdir(), `pi-2753-${Date.now()}-${Math.random().toString(36).slice(2)}`);
const agentDir = join(tempDir, "agent");
const promptsDir = join(agentDir, "prompts");
mkdirSync(promptsDir, { recursive: true });
writeFileSync(join(promptsDir, "test.md"), "Echo test prompt\n");
const faux = registerFauxProvider({
models: [{ id: "faux-1", reasoning: false }],
});
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
cwd,
agentDir,
authStorage,
resourceLoaderOptions: {
extensionFactories: [
(pi) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
noSkills: true,
noThemes: true,
},
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model: faux.getModel(),
})),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: tempDir,
agentDir,
sessionManager: SessionManager.create(tempDir),
});
cleanups.push(() => {
runtime.session.dispose();
faux.unregister();
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
expect(runtime.session.promptTemplates.map((prompt) => prompt.name)).toContain("test");
writeFileSync(join(agentDir, "settings.json"), `${JSON.stringify({ prompts: ["-prompts/test.md"] }, null, 2)}\n`);
await runtime.session.reload();
expect(runtime.services.settingsManager.getGlobalSettings().prompts).toEqual(["-prompts/test.md"]);
expect(runtime.session.promptTemplates.map((prompt) => prompt.name)).not.toContain("test");
});
});
@@ -0,0 +1,120 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DefaultResourceLoader } from "../../../src/core/resource-loader.js";
describe("issue #2781 skill collision precedence: user skills should override package skills", () => {
let tempDir: string;
let agentDir: string;
let cwd: string;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-2781-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
cwd = join(tempDir, "project");
mkdirSync(agentDir, { recursive: true });
mkdirSync(cwd, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function createPackageWithSkill(name: string, description: string): string {
const pkgDir = join(tempDir, `fake-package-${name}`);
const skillDir = join(pkgDir, "skills", name);
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({ name: `fake-pkg-${name}`, version: "1.0.0", pi: { skills: [`skills/${name}`] } }, null, 2),
);
writeFileSync(
join(skillDir, "SKILL.md"),
`---\nname: ${name}\ndescription: ${description}\n---\nPackage skill content`,
);
return pkgDir;
}
function createUserSkill(name: string, description: string): string {
const skillDir = join(agentDir, "skills", name);
mkdirSync(skillDir, { recursive: true });
const skillPath = join(skillDir, "SKILL.md");
writeFileSync(skillPath, `---\nname: ${name}\ndescription: ${description}\n---\nUser skill content`);
return skillPath;
}
function createProjectSkill(name: string, description: string): string {
const skillDir = join(cwd, ".pi", "skills", name);
mkdirSync(skillDir, { recursive: true });
const skillPath = join(skillDir, "SKILL.md");
writeFileSync(skillPath, `---\nname: ${name}\ndescription: ${description}\n---\nProject skill content`);
return skillPath;
}
function createSettingsWithPackage(pkgDir: string, scope: "user" | "project"): void {
const settingsDir = scope === "user" ? agentDir : join(cwd, ".pi");
mkdirSync(settingsDir, { recursive: true });
writeFileSync(join(settingsDir, "settings.json"), JSON.stringify({ packages: [pkgDir] }, null, 2));
}
it("user auto-discovered skill should override package skill with same name", async () => {
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
const userSkillPath = createUserSkill("web-fetch", "User web-fetch override");
createSettingsWithPackage(pkgDir, "user");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
const { skills } = loader.getSkills();
const webFetch = skills.find((s) => s.name === "web-fetch");
expect(webFetch).toBeDefined();
expect(webFetch!.filePath).toBe(userSkillPath);
expect(webFetch!.description).toBe("User web-fetch override");
});
it("project auto-discovered skill should override package skill with same name", async () => {
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
const projectSkillPath = createProjectSkill("web-fetch", "Project web-fetch override");
createSettingsWithPackage(pkgDir, "user");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
const { skills } = loader.getSkills();
const webFetch = skills.find((s) => s.name === "web-fetch");
expect(webFetch).toBeDefined();
expect(webFetch!.filePath).toBe(projectSkillPath);
expect(webFetch!.description).toBe("Project web-fetch override");
});
it("project skill should override user skill which should override package skill", async () => {
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
createUserSkill("web-fetch", "User web-fetch override");
const projectSkillPath = createProjectSkill("web-fetch", "Project web-fetch override");
createSettingsWithPackage(pkgDir, "user");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
const { skills } = loader.getSkills();
const webFetch = skills.find((s) => s.name === "web-fetch");
expect(webFetch).toBeDefined();
expect(webFetch!.filePath).toBe(projectSkillPath);
expect(webFetch!.description).toBe("Project web-fetch override");
});
it("collision diagnostics should report package skill as loser when user skill wins", async () => {
const pkgDir = createPackageWithSkill("web-fetch", "Package web-fetch skill");
createUserSkill("web-fetch", "User web-fetch override");
createSettingsWithPackage(pkgDir, "user");
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
const { diagnostics } = loader.getSkills();
const collision = diagnostics.find((d) => d.type === "collision" && d.collision?.name === "web-fetch");
expect(collision).toBeDefined();
expect(collision!.collision!.loserPath).toContain("fake-package");
});
});
@@ -0,0 +1,106 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
/**
* Regression test for https://github.com/badlogic/pi-mono/issues/2791
*
* fs.watch() returns an FSWatcher (EventEmitter). If the watcher emits an
* 'error' event after creation and no error handler is attached, Node.js
* treats it as an uncaught exception and terminates the process.
*
* We test this by spawning a child process that:
* 1. Sets up a custom theme with the watcher enabled
* 2. Finds the FSWatcher via process._getActiveHandles()
* 3. Emits a synthetic 'error' event on it
* 4. If the watcher has no error handler -> crash (exit != 0) -> bug present
* 5. If the watcher has an error handler -> clean exit (exit 0) -> bug fixed
*/
describe("issue #2791 fs.watch error event crashes process", () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "pi-2791-"));
const agentDir = join(tempRoot, "agent");
const themesDir = join(agentDir, "themes");
mkdirSync(themesDir, { recursive: true });
// Copy dark.json as "custom-test" theme
const darkThemePath = join(__dirname, "../../../src/modes/interactive/theme/dark.json");
const darkTheme = JSON.parse(readFileSync(darkThemePath, "utf-8"));
darkTheme.name = "custom-test";
writeFileSync(join(themesDir, "custom-test.json"), JSON.stringify(darkTheme, null, 2));
});
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
it("process should survive an error event on the theme FSWatcher", () => {
const themeModulePath = join(__dirname, "../../../src/modes/interactive/theme/theme.js").replace(/\\/g, "/");
const agentDir = join(tempRoot, "agent").replace(/\\/g, "/");
// Script that sets up the watcher and emits a synthetic error on it.
// If no .on('error') handler is attached, EventEmitter.emit('error')
// throws, which either crashes the process or gets caught by our try/catch.
const scriptPath = join(tempRoot, "test-watcher-error.mts");
writeFileSync(
scriptPath,
`
import { setTheme, stopThemeWatcher } from "${themeModulePath}";
process.env.PI_CODING_AGENT_DIR = "${agentDir}";
setTheme("custom-test", true);
// Find the FSWatcher among active handles
const handles = (process as any)._getActiveHandles();
const fsWatcher = handles.find((h: any) => h.constructor?.name === "FSWatcher");
if (!fsWatcher) {
process.stderr.write("no FSWatcher found among active handles\\n");
process.exit(2);
}
const errorListenerCount = fsWatcher.listenerCount("error");
if (errorListenerCount === 0) {
process.stderr.write("BUG: FSWatcher has no error handler (issue #2791)\\n");
}
// Emitting 'error' on an EventEmitter with no error listener throws.
// This simulates an async OS error (e.g. ReadDirectoryChangesW invalidation).
try {
fsWatcher.emit("error", new Error("simulated OS watcher failure"));
} catch {
process.stderr.write("error event was unhandled and threw\\n");
process.exit(1);
}
stopThemeWatcher();
process.exit(0);
`,
);
let _stdout = "";
let stderr = "";
let exitCode: number;
try {
_stdout = execFileSync("npx", ["tsx", scriptPath], {
timeout: 10000,
encoding: "utf-8",
env: { ...process.env, PI_CODING_AGENT_DIR: agentDir },
stdio: ["pipe", "pipe", "pipe"],
});
exitCode = 0;
} catch (err: unknown) {
const e = err as { status: number; stdout: string; stderr: string };
_stdout = e.stdout ?? "";
stderr = e.stderr ?? "";
exitCode = e.status ?? 1;
}
expect(exitCode, `Child crashed (exit ${exitCode}). stderr: ${stderr.trim()}`).toBe(0);
});
});
@@ -0,0 +1,98 @@
import type { TUI } from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.js";
import { ScopedModelsSelectorComponent } from "../../../src/modes/interactive/components/scoped-models-selector.js";
import { initTheme } from "../../../src/modes/interactive/theme/theme.js";
import { createHarness, type Harness } from "../harness.js";
function createFakeTui(): TUI {
return {
requestRender: () => {},
} as unknown as TUI;
}
async function waitForAsyncRender(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("issue #3217 scoped model ordering", () => {
const harnesses: Harness[] = [];
beforeAll(() => {
initTheme("dark");
});
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("propagates reordered scoped models back to the session state", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
{ id: "faux-3", name: "Three", reasoning: true },
],
});
harnesses.push(harness);
const orderedIds = harness.models.map((model) => `${model.provider}/${model.id}`);
const changes: Array<string[] | null> = [];
const selector = new ScopedModelsSelectorComponent(
{
allModels: [...harness.models],
enabledModelIds: orderedIds,
},
{
onChange: (enabledModelIds) => {
changes.push(enabledModelIds);
},
onPersist: () => {},
onCancel: () => {},
},
);
selector.handleInput("\x1b[1;3B");
expect(changes).toEqual([[orderedIds[1], orderedIds[0], orderedIds[2]]]);
});
it("preserves scoped model order in the /model scoped tab", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
{ id: "faux-3", name: "Three", reasoning: true },
],
});
harnesses.push(harness);
const modelOne = harness.getModel("faux-1")!;
const modelTwo = harness.getModel("faux-2")!;
const modelThree = harness.getModel("faux-3")!;
const selector = new ModelSelectorComponent(
createFakeTui(),
modelOne,
harness.settingsManager,
harness.session.modelRegistry,
[{ model: modelTwo }, { model: modelOne }, { model: modelThree }],
() => {},
() => {},
);
await waitForAsyncRender();
const renderedLines = stripAnsi(selector.render(120).join("\n"))
.split("\n")
.filter((line) => line.includes(`[${modelOne.provider}]`));
const orderedIds = renderedLines.slice(0, 3).map((line) => {
const [modelId] = line.trim().replace(/^→\s*/, "").split(" [");
return modelId?.trim() ?? "";
});
expect(orderedIds).toEqual([modelTwo.id, modelOne.id, modelThree.id]);
});
});
@@ -0,0 +1,72 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createFindToolDefinition } from "../../../src/core/tools/find.js";
/**
* Regression test for https://github.com/badlogic/pi-mono/issues/3302
*
* The `find` tool advertises glob patterns like `src/**\/*.spec.ts`, but the
* default fd-backed implementation used `fd --glob <pattern>` without
* `--full-path`, which makes fd match only against the basename. Any pattern
* containing a `/` therefore silently returned no matches.
*
* The fix switches fd into full-path mode when the pattern contains a `/`
* and prepends `**\/` so the pattern can match against the absolute candidate
* path that fd feeds to the matcher.
*/
describe("issue #3302 find returns no results for path-based glob patterns", () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "pi-3302-"));
mkdirSync(join(tempRoot, "some", "parent", "child"), { recursive: true });
mkdirSync(join(tempRoot, "src", "foo", "bar"), { recursive: true });
writeFileSync(join(tempRoot, "some", "parent", "child", "file.ext"), "");
writeFileSync(join(tempRoot, "some", "parent", "child", "test.spec.ts"), "");
writeFileSync(join(tempRoot, "src", "foo", "bar", "example.spec.ts"), "");
});
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
async function runFind(pattern: string): Promise<string[]> {
const def = createFindToolDefinition(tempRoot);
// The find tool implementation does not touch ctx; pass a minimal stub.
const ctx = {} as Parameters<typeof def.execute>[4];
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
content: Array<{ type: string; text?: string }>;
};
const text = result.content[0]?.text ?? "";
if (text === "No files found matching pattern") return [];
return text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith("["));
}
it("basename pattern still matches (regression-safe)", async () => {
const files = await runFind("*.spec.ts");
expect(files.sort()).toEqual(["some/parent/child/test.spec.ts", "src/foo/bar/example.spec.ts"]);
});
it("directory-prefixed pattern with ** tail matches subtree", async () => {
const files = await runFind("some/parent/child/**");
// Matches files (and possibly directories) under the subtree. Assert the two files are present.
expect(files).toContain("some/parent/child/file.ext");
expect(files).toContain("some/parent/child/test.spec.ts");
});
it("leading ** wildcard with path segments matches", async () => {
const files = await runFind("**/parent/child/*");
expect(files.sort()).toContain("some/parent/child/file.ext");
expect(files.sort()).toContain("some/parent/child/test.spec.ts");
});
it("src/**/*.spec.ts matches nested spec file", async () => {
const files = await runFind("src/**/*.spec.ts");
expect(files).toEqual(["src/foo/bar/example.spec.ts"]);
});
});
@@ -0,0 +1,83 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createFindToolDefinition } from "../../../src/core/tools/find.js";
/**
* Regression test for https://github.com/badlogic/pi-mono/issues/3303
*
* The `find` tool previously collected every `.gitignore` under the search
* path and passed them to `fd` via `--ignore-file`. fd treats `--ignore-file`
* entries as a single global ignore source, so rules from `a/.gitignore`
* also filtered files under sibling `b/`. The fix switches to fd's
* hierarchical `.gitignore` handling via `--no-require-git` and drops the
* manual collection.
*/
describe("issue #3303 nested .gitignore rules leak into sibling directories", () => {
let tempRoot: string;
async function runFind(pattern: string): Promise<string[]> {
const def = createFindToolDefinition(tempRoot);
const ctx = {} as Parameters<typeof def.execute>[4];
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
content: Array<{ type: string; text?: string }>;
};
const text = result.content[0]?.text ?? "";
if (text === "No files found matching pattern") return [];
return text
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith("["))
.sort();
}
afterEach(() => {
if (tempRoot) rmSync(tempRoot, { recursive: true, force: true });
});
describe("flat sibling case", () => {
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-flat-"));
mkdirSync(join(tempRoot, "a"), { recursive: true });
mkdirSync(join(tempRoot, "b"), { recursive: true });
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
writeFileSync(join(tempRoot, "root.txt"), "");
});
it("applies a/.gitignore only inside a/ and leaves b/ untouched", async () => {
const files = await runFind("**/*.txt");
expect(files).toEqual(["a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
});
});
describe("deeply nested case", () => {
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-deep-"));
mkdirSync(join(tempRoot, "a", "deep"), { recursive: true });
mkdirSync(join(tempRoot, "b"), { recursive: true });
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
writeFileSync(join(tempRoot, "a", "deep", ".gitignore"), "secret.txt\n");
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
writeFileSync(join(tempRoot, "a", "deep", "ignored.txt"), "");
writeFileSync(join(tempRoot, "a", "deep", "secret.txt"), "");
writeFileSync(join(tempRoot, "a", "deep", "kept.txt"), "");
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
writeFileSync(join(tempRoot, "root.txt"), "");
});
it("scopes each .gitignore to its own subtree", async () => {
const files = await runFind("**/*.txt");
// a/.gitignore ignores 'ignored.txt' within a/ and a/deep/.
// a/deep/.gitignore additionally ignores 'secret.txt' within a/deep/.
// b/ is untouched by either.
expect(files).toEqual(["a/deep/kept.txt", "a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
});
});
});
@@ -0,0 +1,33 @@
import { fauxAssistantMessage } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, getAssistantTexts, type Harness } from "../harness.js";
describe("issue #3317 network connection lost retry", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it('retries transient "Network connection lost." failures', async () => {
const harness = await createHarness({
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage("", { stopReason: "error", errorMessage: "Network connection lost." }),
fauxAssistantMessage("recovered after reconnect"),
]);
await harness.session.prompt("test");
expect(harness.faux.state.callCount).toBe(2);
expect(harness.eventsOfType("auto_retry_start").map((event) => event.errorMessage)).toEqual([
"Network connection lost.",
]);
expect(harness.eventsOfType("auto_retry_end").map((event) => event.success)).toEqual([true]);
expect(getAssistantTexts(harness)).toContain("recovered after reconnect");
});
});