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,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");
});
});