Merge branch 'main' into fix/plugin-directory-extensions

This commit is contained in:
Can Bölük
2026-05-12 06:28:35 +02:00
committed by GitHub
357 changed files with 16519 additions and 9706 deletions
@@ -346,89 +346,6 @@ describe("AgentSession retry fallback", () => {
expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered after OpenAI timeout" });
});
it("auto-retries Bun socket closure errors", async () => {
const model = getBundledModel("openai", "gpt-4o-mini");
if (!model) {
throw new Error("Expected bundled OpenAI test model to exist");
}
const socketError =
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()";
const requestedModels: string[] = [];
let attemptCount = 0;
const agent = new Agent({
getApiKey: provider => `${provider}-test-key`,
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
streamFn: requestedModel => {
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
const stream = new MockAssistantStream();
queueMicrotask(() => {
attemptCount += 1;
if (attemptCount === 1) {
const message = createAssistantMessage(requestedModel, {
stopReason: "error",
errorMessage: socketError,
});
stream.push({ type: "start", partial: message });
stream.push({ type: "error", reason: "error", error: message });
return;
}
if (attemptCount === 2) {
const message = createAssistantMessage(requestedModel, {
text: "Recovered after socket closure",
stopReason: "stop",
});
stream.push({
type: "start",
partial: createAssistantMessage(requestedModel, { text: "", stopReason: "stop" }),
});
stream.push({ type: "done", reason: "stop", message });
return;
}
throw new Error(`Unexpected retry attempt in socket closure test: ${attemptCount}`);
});
return stream;
},
});
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.baseDelayMs": 5,
"retry.maxRetries": 1,
});
settings.setModelRole("default", `${model.provider}/${model.id}`);
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
await session.prompt("Retry Bun socket closure");
await session.waitForIdle();
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
expect(retryStartEvents).toHaveLength(1);
expect(retryStartEvents[0]).toMatchObject({
attempt: 1,
maxAttempts: 1,
errorMessage: socketError,
});
expect(retryEndEvents).toHaveLength(1);
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
const lastAssistant = getLastAssistantMessage(session);
expect(lastAssistant.stopReason).toBe("stop");
expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered after socket closure" });
});
it("auto-retries Anthropic stream-envelope failures before message_start", async () => {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) {
@@ -219,7 +219,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
it("rebuilds when an MCP tool's label changes", async () => {
// Tool labels are rendered into the prompt body (`{{label}}: \`{{name}}\``),
// so a label change \u2014 even with name and description constant \u2014 must force
// so a label change — even with name and description constant — must force
// a rebuild. Otherwise we'd serve a stale label after an MCP server upgrade.
let rebuildCount = 0;
const { session } = newSession(async toolNames => {
@@ -273,7 +273,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
it("rebuilds when a discoverable (inactive) registry tool's metadata changes", async () => {
// With MCP discovery on, the rebuilt prompt summarizes ALL discoverable MCP
// tools \u2014 including ones not in the active set. The signature must capture the
// tools — including ones not in the active set. The signature must capture the
// full registry; otherwise a description change to a discoverable-but-inactive
// tool would silently leave a stale summary in the cached prompt.
let rebuildCount = 0;
@@ -10,7 +10,6 @@ import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/ex
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TOOL_TIMEOUTS } from "@oh-my-pi/pi-coding-agent/tools/tool-timeouts";
import { TempDir } from "@oh-my-pi/pi-utils";
describe("AgentSession user shortcut hooks", () => {
@@ -178,29 +177,4 @@ describe("AgentSession user shortcut hooks", () => {
session.messages.some(message => message.role === "pythonExecution" && message.excludeFromContext === false),
).toBe(true);
});
it("passes the default native timeout to fallback bash execution", async () => {
vi.spyOn(bashExecutor, "executeBash").mockResolvedValue({
output: "bash fallback",
exitCode: 0,
cancelled: false,
truncated: false,
totalLines: 1,
totalBytes: 13,
outputLines: 1,
outputBytes: 13,
});
createSession();
await session.executeBash("pwd", undefined, { excludeFromContext: false });
expect(bashExecutor.executeBash).toHaveBeenCalledWith(
"pwd",
expect.objectContaining({
timeout: TOOL_TIMEOUTS.bash.default * 1000,
sessionKey: expect.any(String),
signal: expect.any(AbortSignal),
}),
);
});
});
-288
View File
@@ -1,288 +0,0 @@
import { describe, expect, test } from "bun:test";
import { Effort } from "@oh-my-pi/pi-ai";
import { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args";
describe("parseArgs", () => {
describe("--version flag", () => {
test("parses --version flag", () => {
const result = parseArgs(["--version"]);
expect(result.version).toBe(true);
});
test("parses -v shorthand", () => {
const result = parseArgs(["-v"]);
expect(result.version).toBe(true);
});
test("--version takes precedence over other args", () => {
const result = parseArgs(["--version", "--help", "some message"]);
expect(result.version).toBe(true);
expect(result.help).toBe(true);
expect(result.messages).toContain("some message");
});
});
describe("--help flag", () => {
test("parses --help flag", () => {
const result = parseArgs(["--help"]);
expect(result.help).toBe(true);
});
test("parses -h shorthand", () => {
const result = parseArgs(["-h"]);
expect(result.help).toBe(true);
});
});
describe("--print flag", () => {
test("parses --print flag", () => {
const result = parseArgs(["--print"]);
expect(result.print).toBe(true);
});
test("parses -p shorthand", () => {
const result = parseArgs(["-p"]);
expect(result.print).toBe(true);
});
});
describe("--continue flag", () => {
test("parses --continue flag", () => {
const result = parseArgs(["--continue"]);
expect(result.continue).toBe(true);
});
test("parses -c shorthand", () => {
const result = parseArgs(["-c"]);
expect(result.continue).toBe(true);
});
});
describe("--resume flag", () => {
test("parses --resume flag", () => {
const result = parseArgs(["--resume"]);
expect(result.resume).toBe(true);
});
test("parses -r shorthand", () => {
const result = parseArgs(["-r"]);
expect(result.resume).toBe(true);
});
test("parses --resume with session ID", () => {
const result = parseArgs(["--resume", "abc123"]);
expect(result.resume).toBe("abc123");
});
test("parses -r with session path", () => {
const result = parseArgs(["-r", "/path/to/session.jsonl"]);
expect(result.resume).toBe("/path/to/session.jsonl");
});
test("--resume without value before another flag stays boolean", () => {
const result = parseArgs(["--resume", "--model", "opus"]);
expect(result.resume).toBe(true);
expect(result.model).toBe("opus");
});
});
describe("--fork flag", () => {
test("parses --fork with session ID", () => {
const result = parseArgs(["--fork", "abc123"]);
expect(result.fork).toBe("abc123");
});
});
describe("flags with values", () => {
test("parses --provider", () => {
const result = parseArgs(["--provider", "openai"]);
expect(result.provider).toBe("openai");
});
test("parses --model", () => {
const result = parseArgs(["--model", "gpt-4o"]);
expect(result.model).toBe("gpt-4o");
});
test("parses --model=value with equals syntax", () => {
const result = parseArgs(["--model=gpt-4o"]);
expect(result.model).toBe("gpt-4o");
});
test("parses --api-key", () => {
const result = parseArgs(["--api-key", "sk-test-key"]);
expect(result.apiKey).toBe("sk-test-key");
});
test("parses --system-prompt", () => {
const result = parseArgs(["--system-prompt", "You are a helpful assistant"]);
expect(result.systemPrompt).toBe("You are a helpful assistant");
});
test("parses --append-system-prompt", () => {
const result = parseArgs(["--append-system-prompt", "Additional context"]);
expect(result.appendSystemPrompt).toBe("Additional context");
});
test("parses --provider-session-id", () => {
const result = parseArgs(["--provider-session-id", "reb_cache_key"]);
expect(result.providerSessionId).toBe("reb_cache_key");
});
test("parses --mode", () => {
const result = parseArgs(["--mode", "json"]);
expect(result.mode).toBe("json");
});
test("parses --mode rpc", () => {
const result = parseArgs(["--mode", "rpc"]);
expect(result.mode).toBe("rpc");
});
test("parses --session as alias for --resume", () => {
const result = parseArgs(["--session", "/path/to/session.jsonl"]);
expect(result.resume).toBe("/path/to/session.jsonl");
});
test("parses --export", () => {
const result = parseArgs(["--export", "session.jsonl"]);
expect(result.export).toBe("session.jsonl");
});
test("parses --thinking", () => {
const result = parseArgs(["--thinking", "high"]);
expect(result.thinking).toBe(Effort.High);
});
test("parses --models as comma-separated list", () => {
const result = parseArgs(["--models", "gpt-4o,claude-sonnet,gemini-pro"]);
expect(result.models).toEqual(["gpt-4o", "claude-sonnet", "gemini-pro"]);
});
});
describe("--no-session flag", () => {
test("parses --no-session flag", () => {
const result = parseArgs(["--no-session"]);
expect(result.noSession).toBe(true);
});
});
describe("--hook flag", () => {
test("parses single --hook", () => {
const result = parseArgs(["--hook", "./my-hook.ts"]);
expect(result.hooks).toEqual(["./my-hook.ts"]);
});
test("parses multiple --hook flags", () => {
const result = parseArgs(["--hook", "./hook1.ts", "--hook", "./hook2.ts"]);
expect(result.hooks).toEqual(["./hook1.ts", "./hook2.ts"]);
});
});
describe("--no-extensions flag", () => {
test("parses --no-extensions flag", () => {
const result = parseArgs(["--no-extensions"]);
expect(result.noExtensions).toBe(true);
});
test("parses --no-extensions with explicit -e flags", () => {
const result = parseArgs(["--no-extensions", "-e", "foo.ts", "-e", "bar.ts"]);
expect(result.noExtensions).toBe(true);
expect(result.extensions).toEqual(["foo.ts", "bar.ts"]);
});
});
describe("--no-skills flag", () => {
test("parses --no-skills flag", () => {
const result = parseArgs(["--no-skills"]);
expect(result.noSkills).toBe(true);
});
});
describe("--no-rules flag", () => {
test("parses --no-rules flag", () => {
const result = parseArgs(["--no-rules"]);
expect(result.noRules).toBe(true);
});
});
describe("--no-tools flag", () => {
test("parses --no-tools flag", () => {
const result = parseArgs(["--no-tools"]);
expect(result.noTools).toBe(true);
});
test("parses --no-tools with explicit --tools flags", () => {
const result = parseArgs(["--no-tools", "--tools", "read,bash"]);
expect(result.noTools).toBe(true);
expect(result.tools).toEqual(["read", "bash"]);
});
test("lowercases tool names passed to --tools", () => {
const result = parseArgs(["--tools", "Read,Search"]);
expect(result.tools).toEqual(["read", "search"]);
});
test("parses --tools=value with equals syntax", () => {
const result = parseArgs(["--tools=read,bash"]);
expect(result.tools).toEqual(["read", "bash"]);
});
test("parses --tools=value with single tool", () => {
const result = parseArgs(["--tools=ask"]);
expect(result.tools).toEqual(["ask"]);
});
});
describe("--no-lsp flag", () => {
test("parses --no-lsp flag", () => {
const result = parseArgs(["--no-lsp"]);
expect(result.noLsp).toBe(true);
});
});
describe("messages and file args", () => {
test("parses plain text messages", () => {
const result = parseArgs(["hello", "world"]);
expect(result.messages).toEqual(["hello", "world"]);
});
test("parses @file arguments", () => {
const result = parseArgs(["@README.md", "@src/main.ts"]);
expect(result.fileArgs).toEqual(["README.md", "src/main.ts"]);
});
test("parses mixed messages and file args", () => {
const result = parseArgs(["@file.txt", "explain this", "@image.png"]);
expect(result.fileArgs).toEqual(["file.txt", "image.png"]);
expect(result.messages).toEqual(["explain this"]);
});
test("ignores unknown flags starting with -", () => {
const result = parseArgs(["--unknown-flag", "message"]);
expect(result.messages).toEqual(["message"]);
});
});
describe("complex combinations", () => {
test("parses multiple flags together", () => {
const result = parseArgs([
"--provider",
"anthropic",
"--model",
"claude-sonnet",
"--print",
"--thinking",
"high",
"@prompt.md",
"Do the task",
]);
expect(result.provider).toBe("anthropic");
expect(result.model).toBe("claude-sonnet");
expect(result.print).toBe(true);
expect(result.thinking).toBe(Effort.High);
expect(result.fileArgs).toEqual(["prompt.md"]);
expect(result.messages).toEqual(["Do the task"]);
});
});
});
@@ -229,17 +229,48 @@ describe("AsyncJobManager", () => {
expect(manager.hasPendingDeliveries()).toBe(false);
});
test("uses custom id when provided and deduplicates collisions", async () => {
test("cancelAll with ownerId only cancels matching jobs", async () => {
const manager = new AsyncJobManager({
onJobComplete: async () => {},
});
const first = manager.register("task", "alpha", async () => "ok", { id: "2-CheckRustCrate" });
const second = manager.register("task", "beta", async () => "ok", { id: "2-CheckRustCrate" });
const hold = (signal: AbortSignal) =>
new Promise<void>(resolve => {
signal.addEventListener("abort", () => resolve(), { once: true });
});
expect(first).toBe("2-CheckRustCrate");
expect(second).toBe("2-CheckRustCrate-2");
const parentJobId = manager.register(
"bash",
"parent-job",
async ({ signal }) => {
await hold(signal);
return "parent-cancelled";
},
{ ownerId: "0-Main" },
);
const subagentJobId = manager.register(
"bash",
"subagent-job",
async ({ signal }) => {
await hold(signal);
return "subagent-cancelled";
},
{ ownerId: "3-AuthLoader" },
);
manager.cancelAll({ ownerId: "3-AuthLoader" });
expect(manager.getJob(parentJobId)?.status).toBe("running");
expect(manager.getJob(subagentJobId)?.status).toBe("cancelled");
// Filtered query mirrors filtered cancel.
expect(manager.getRunningJobs({ ownerId: "0-Main" }).map(j => j.id)).toEqual([parentJobId]);
expect(manager.getRunningJobs({ ownerId: "3-AuthLoader" })).toEqual([]);
expect(manager.getAllJobs({ ownerId: "0-Main" }).map(j => j.id)).toEqual([parentJobId]);
// Unscoped cancelAll still cleans up everything.
manager.cancelAll();
await manager.waitForAll();
expect(manager.getJob(parentJobId)?.status).toBe("cancelled");
});
});
@@ -3,7 +3,6 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { getDefault } from "@oh-my-pi/pi-coding-agent/config/settings-schema";
import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller";
import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils";
import { YAML } from "bun";
@@ -29,15 +28,6 @@ describe("autocompleteMaxVisible setting", () => {
}
});
it("should have default value of 5", () => {
expect(getDefault("autocompleteMaxVisible")).toBe(5);
});
it("should return default when not configured", async () => {
const settings = await Settings.init({ cwd: projectDir, agentDir });
expect(settings.get("autocompleteMaxVisible")).toBe(5);
});
it("should persist and read back a configured value", async () => {
const settings = await Settings.init({ cwd: projectDir, agentDir });
settings.set("autocompleteMaxVisible", 10);
@@ -22,38 +22,6 @@ function createTestToolSession(cwd: string, settings: Settings = Settings.isolat
}
describe("blockImages setting", () => {
describe("Settings", () => {
it("should default blockImages to false", () => {
const settings = Settings.isolated({});
expect(settings.get("images.blockImages")).toBe(false);
});
it("should return true when blockImages is set to true", () => {
const settings = Settings.isolated({ "images.blockImages": true });
expect(settings.get("images.blockImages")).toBe(true);
});
it("should persist blockImages setting via set", () => {
const settings = Settings.isolated({});
expect(settings.get("images.blockImages")).toBe(false);
settings.set("images.blockImages", true);
expect(settings.get("images.blockImages")).toBe(true);
settings.set("images.blockImages", false);
expect(settings.get("images.blockImages")).toBe(false);
});
it("should handle blockImages alongside autoResize", () => {
const settings = Settings.isolated({
"images.autoResize": true,
"images.blockImages": true,
});
expect(settings.get("images.autoResize")).toBe(true);
expect(settings.get("images.blockImages")).toBe(true);
});
});
describe("Read tool", () => {
let testDir: string;
@@ -1,70 +0,0 @@
/**
* Verify the documentation example from hooks.md compiles and works.
*/
import { describe, expect, it } from "bun:test";
import type {
HookAPI,
SessionBeforeCompactEvent,
SessionCompactEvent,
} from "@oh-my-pi/pi-coding-agent/extensibility/hooks";
describe("Documentation example", () => {
it("custom compaction example should type-check correctly", () => {
// This is the example from hooks.md - verify it compiles
const exampleHook = (pi: HookAPI) => {
pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx) => {
// All these should be accessible on the event
const { preparation, branchEntries } = event;
// sessionManager, modelRegistry, and model come from ctx
const { sessionManager, modelRegistry } = ctx;
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, isSplitTurn } =
preparation;
// Verify types
expect(Array.isArray(messagesToSummarize)).toBe(true);
expect(Array.isArray(turnPrefixMessages)).toBe(true);
expect(typeof isSplitTurn).toBe("boolean");
expect(typeof tokensBefore).toBe("number");
expect(typeof sessionManager.getEntries).toBe("function");
expect(typeof modelRegistry.getApiKey).toBe("function");
expect(typeof firstKeptEntryId).toBe("string");
expect(Array.isArray(branchEntries)).toBe(true);
const summary = messagesToSummarize
.filter(m => m.role === "user")
.map(m => `- ${typeof m.content === "string" ? m.content.slice(0, 100) : "[complex]"}`)
.join("\n");
// Hooks return compaction content - SessionManager adds id/parentId
return {
compaction: {
summary: `User requests:\n${summary}`,
firstKeptEntryId,
tokensBefore,
},
};
});
};
// Just verify the function exists and is callable
expect(typeof exampleHook).toBe("function");
});
it("compact event should have correct fields", () => {
const checkCompactEvent = (pi: HookAPI) => {
pi.on("session_compact", async (event: SessionCompactEvent) => {
// These should all be accessible
const entry = event.compactionEntry;
const fromExtension = event.fromExtension;
expect(entry.type).toBe("compaction");
expect(typeof entry.summary).toBe("string");
expect(typeof entry.tokensBefore).toBe("number");
expect(typeof fromExtension).toBe("boolean");
});
};
expect(typeof checkCompactEvent).toBe("function");
});
});
@@ -325,40 +325,6 @@ describe("remote compaction setting", () => {
}
});
it("leaves local summarization requests unattributed when no override is provided", async () => {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected anthropic/claude-sonnet-4-5 model to exist");
const entries: SessionEntry[] = [
createMessageEntry(createUserMessage("Turn 1")),
createMessageEntry(createAssistantMessage("Answer 1", createMockUsage(0, 100, 2000, 0))),
createMessageEntry(createUserMessage("Turn 2")),
createMessageEntry(createAssistantMessage("Answer 2", createMockUsage(0, 100, 5000, 0))),
createMessageEntry(createUserMessage("Turn 3")),
createMessageEntry(createAssistantMessage("Answer 3", createMockUsage(0, 100, 9000, 0))),
];
const preparation = prepareCompaction(entries, {
...DEFAULT_COMPACTION_SETTINGS,
keepRecentTokens: 1000,
remoteEnabled: false,
});
if (!preparation) throw new Error("Expected compaction preparation");
const completeSimpleSpy = vi.spyOn(ai, "completeSimple");
completeSimpleSpy
.mockResolvedValueOnce(createAssistantMessage("History summary"))
.mockResolvedValueOnce(createAssistantMessage("Turn prefix summary"))
.mockResolvedValueOnce(createAssistantMessage("Short summary"));
await compact(preparation, model, "test-api-key");
expect(completeSimpleSpy).toHaveBeenCalledTimes(3);
for (const call of completeSimpleSpy.mock.calls) {
const options = call[2] as { initiatorOverride?: string } | undefined;
expect(options?.initiatorOverride).toBeUndefined();
}
});
it("uses local summarization when remote compaction is disabled", async () => {
const model = getBundledModel("openai", "gpt-4o");
if (!model) {
@@ -906,14 +872,6 @@ describe("buildSessionContext", () => {
// ============================================================================
describe("Large session fixture", () => {
it("should parse the large session", async () => {
const entries = await loadLargeSessionEntries();
expect(entries.length).toBeGreaterThan(100);
const messageCount = entries.filter(e => e.type === "message").length;
expect(messageCount).toBeGreaterThan(100);
});
it("should find cut point in large session", async () => {
const entries = await loadLargeSessionEntries();
const result = findCutPoint(entries, 0, entries.length, DEFAULT_COMPACTION_SETTINGS.keepRecentTokens);
@@ -923,14 +881,6 @@ describe("Large session fixture", () => {
const role = (entries[result.firstKeptEntryIndex] as SessionMessageEntry).message.role;
expect(role === "user" || role === "assistant").toBe(true);
});
it("should load session correctly", async () => {
const entries = await loadLargeSessionEntries();
const loaded = buildSessionContext(entries);
expect(loaded.messages.length).toBeGreaterThan(100);
expect(Object.keys(loaded.models).length).toBeGreaterThan(0);
});
});
// ============================================================================
@@ -29,40 +29,6 @@ afterEach(async () => {
});
describe("config CLI schema coverage", () => {
it("lists non-UI schema settings in JSON output", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runConfigCommand({ action: "list", flags: { json: true } });
expect(logSpy).toHaveBeenCalledTimes(1);
const payload = logSpy.mock.calls[0]?.[0];
expect(typeof payload).toBe("string");
const parsed = JSON.parse(String(payload)) as Record<string, { type: string; description: string }>;
expect(parsed.enabledModels).toBeDefined();
expect(parsed.enabledModels.type).toBe("array");
expect(parsed.enabledModels.description).toBe("");
});
it("gets non-UI schema settings by key", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runConfigCommand({ action: "get", key: "enabledModels", flags: { json: true } });
expect(logSpy).toHaveBeenCalledTimes(1);
const payload = logSpy.mock.calls[0]?.[0];
expect(typeof payload).toBe("string");
const parsed = JSON.parse(String(payload)) as {
key: string;
type: string;
description: string;
};
expect(parsed.key).toBe("enabledModels");
expect(parsed.type).toBe("array");
expect(parsed.description).toBe("");
});
it("renders record settings as JSON and with record type in text output", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
@@ -21,18 +21,6 @@ describe("indentation resolver", () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("falls back to hard default when settings are not initialized", () => {
expect(getDefaultTabWidth()).toBe(3);
expect(getIndentation()).toBe(3);
});
it("uses configured default tab width from settings", async () => {
const runtimeSettings = await Settings.init({ inMemory: true, cwd: tempDir });
runtimeSettings.set("display.tabWidth", 5);
expect(getDefaultTabWidth()).toBe(5);
expect(getIndentation()).toBe(5);
});
it("applies current display tab width during initial settings load", async () => {
await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "display.tabWidth": 7 } });
expect(getDefaultTabWidth()).toBe(7);
@@ -272,15 +272,4 @@ describe("executeJs", () => {
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe(path.join("a", "b"));
});
it("exposes a cwd-bound `require` and `createRequire`", async () => {
const result = await executeJs(
'return { hasRequire: typeof require === "function", hasCreate: typeof createRequire === "function", path: require("node:path").sep };',
{ sessionId, session, sessionFile },
);
expect(result.exitCode).toBe(0);
expect(result.displayOutputs).toEqual([
{ type: "json", data: { hasRequire: true, hasCreate: true, path: path.sep } },
]);
});
});
@@ -41,18 +41,4 @@ describe("Python gateway environment filtering", () => {
expect(filtered.LC_CTYPE).toBe("UTF-8");
expect(filtered.LC_MESSAGES).toBe("en_US.UTF-8");
});
it("passes filtered env through to resolved runtime", () => {
const env: Record<string, string | undefined> = {
PATH: "/usr/bin",
HOME: "/home/test",
OPENAI_API_KEY: "secret",
PI_DEBUG: "1",
};
const filtered = filterEnv(env);
expect(filtered.OPENAI_API_KEY).toBeUndefined();
expect(filtered.PATH).toBe("/usr/bin");
expect(filtered.PI_DEBUG).toBe("1");
});
});
@@ -1,73 +0,0 @@
import { describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { EvalTool } from "@oh-my-pi/pi-coding-agent/tools/eval";
import { $which, getProjectDir } from "@oh-my-pi/pi-utils";
const resolvePythonPath = (): string | null => {
const venvPath = Bun.env.VIRTUAL_ENV;
const candidates = [venvPath, path.join(getProjectDir(), ".venv"), path.join(getProjectDir(), "venv")].filter(
Boolean,
) as string[];
for (const candidate of candidates) {
const binDir = process.platform === "win32" ? "Scripts" : "bin";
const exeName = process.platform === "win32" ? "python.exe" : "python";
const pythonCandidate = path.join(candidate, binDir, exeName);
if (fs.existsSync(pythonCandidate)) {
return pythonCandidate;
}
}
return $which("python") ?? $which("python3");
};
const pythonPath = resolvePythonPath();
const hasKernelDeps = (() => {
if (!pythonPath) return false;
const result = Bun.spawnSync(
[
pythonPath,
"-c",
"import importlib.util,sys;sys.exit(0 if importlib.util.find_spec('kernel_gateway') and importlib.util.find_spec('ipykernel') else 1)",
],
{ stdin: "ignore", stdout: "pipe", stderr: "pipe" },
);
return result.exitCode === 0;
})();
const shouldRun = Boolean(pythonPath) && hasKernelDeps;
describe.skipIf(!shouldRun)("PYTHON_PRELUDE integration", () => {
it("exposes prelude helpers via eval python backend", async () => {
const helpers = ["env", "read", "write", "append", "tree", "diff", "run", "output"];
const session = {
cwd: getProjectDir(),
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => null,
settings: Settings.isolated({
"lsp.diagnosticsOnWrite": false,
"eval.py": true,
"python.kernelMode": "per-call",
"python.sharedGateway": true,
}),
};
const tool = new EvalTool(session);
const code = [
`helpers = ${JSON.stringify(helpers)}`,
"missing = [name for name in helpers if name not in globals() or not callable(globals()[name])]",
'print("HELPERS_OK=" + ("1" if not missing else "0"))',
"if missing:",
' print("MISSING=" + ",".join(missing))',
].join("\n");
const result = await tool.execute("tool-call-1", {
input: `*** Begin PY\n*** Title: prelude helpers\n${code}\n*** End PY\n`,
});
const output = result.content.find(item => item.type === "text")?.text ?? "";
expect(output).toContain("HELPERS_OK=1");
expect(tool.description).toContain("read");
expect(tool.description).not.toContain("Documentation unavailable");
});
});
@@ -1,32 +0,0 @@
import { describe, expect, test } from "bun:test";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
describe("cycleOrder with custom roles", () => {
test("cycleOrder setting accepts custom role names", () => {
const settings = Settings.isolated({
cycleOrder: ["smol", "custom-fast", "default"],
});
expect(settings.get("cycleOrder")).toEqual(["smol", "custom-fast", "default"]);
});
test("cycleOrder falls back to default when not set", () => {
const settings = Settings.isolated({});
expect(settings.get("cycleOrder")).toEqual(["smol", "default", "slow"]);
});
test("modelTags can define custom role display info", () => {
const settings = Settings.isolated({
modelTags: {
"custom-fast": {
name: "Fast Custom",
color: "warning",
},
},
});
const modelTags = settings.get("modelTags") as Record<string, any>;
expect(modelTags["custom-fast"]).toEqual({
name: "Fast Custom",
color: "warning",
});
});
});
@@ -19,12 +19,6 @@ describe("DebugLogViewerModel", () => {
return row.kind;
}
};
it("defaults cursor to the newest log entry", () => {
const logs = ["alpha", "beta", "gamma"].join("\n");
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
expect(model.cursorLogIndex).toBe(2);
});
it("inserts session boundary warning between older and current-session logs", () => {
const processStartMs = Date.parse("2026-02-14T12:00:00.000Z");
const logs = [
@@ -0,0 +1,372 @@
/**
* Regressions for two issues that together produced silent edit failures on
* auto-generated files:
*
* 1. The streaming auto-generated guard was gated behind `edit.streamingAbort`
* (default false), so it never fired in default configs.
* 2. `executeSinglePathEntries` (multi-edit, single-path orchestrator)
* swallowed per-entry exceptions and returned an aggregate result with
* no `isError` flag. The UI then fell through to the streaming preview
* branch and rendered the *proposed* diff, making a hard failure look
* indistinguishable from success.
*/
import { afterEach, beforeEach, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
import { type AssistantMessage, getBundledModel, type StopReason, type ToolCall } from "@oh-my-pi/pi-ai";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { EditTool } from "@oh-my-pi/pi-coding-agent/edit";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import * as autoGeneratedGuard from "@oh-my-pi/pi-coding-agent/tools/auto-generated-guard";
import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
import { Snowflake } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
class MockAssistantStream extends AssistantMessageEventStream {}
function createAssistantMessage(content: AssistantMessage["content"], stopReason: StopReason): AssistantMessage {
return {
role: "assistant",
content,
api: "anthropic-messages",
provider: "anthropic",
model: "mock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
timestamp: Date.now(),
};
}
function createToolCall(id: string, args: Record<string, unknown>): ToolCall {
return { type: "toolCall", id, name: "edit", arguments: args };
}
function lastAssistantMessage(messages: Array<{ role: string }>): AssistantMessage | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "assistant") return msg as AssistantMessage;
}
return undefined;
}
function buildMockEditTool(): AgentTool {
const schema = Type.Object({
path: Type.String(),
diff: Type.String(),
op: Type.Optional(Type.String()),
rename: Type.Optional(Type.String()),
});
return {
name: "edit",
label: "Edit",
description: "",
parameters: schema,
async execute() {
return { content: [{ type: "text", text: "ok" }] };
},
};
}
function createSessionWith(
tempDir: string,
streamFn: Agent["streamFn"],
tool: AgentTool,
settingsOverrides: Record<string, unknown> = {},
): Promise<{ agent: Agent; session: AgentSession; authStorage: AuthStorage }> {
return (async () => {
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: ["Test"], tools: [tool] },
streamFn,
});
const sessionManager = SessionManager.inMemory(tempDir);
const settings = Settings.isolated(settingsOverrides);
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
return {
agent,
session: new AgentSession({ agent, sessionManager, settings, modelRegistry }),
authStorage,
};
})();
}
function streamForSingleToolCall(
filePath: string,
diff: string,
abortSignalRef: { current?: AbortSignal },
): Agent["streamFn"] {
let callIndex = 0;
return (_model, _context, options) => {
abortSignalRef.current = options?.signal;
const stream = new MockAssistantStream();
const toolCallId = "call_edit_regression";
let aborted = false;
const notifyAbort = () => {
if (aborted) return;
aborted = true;
const partial = createToolCall(toolCallId, { path: filePath, diff });
stream.push({
type: "toolcall_delta",
contentIndex: 0,
delta: "",
partial: createAssistantMessage([partial], "stop"),
});
stream.push({ type: "error", reason: "aborted", error: createAssistantMessage([], "aborted") });
};
options?.signal?.addEventListener("abort", notifyAbort, { once: true });
queueMicrotask(async () => {
if (callIndex > 0) {
const finalMessage = createAssistantMessage([{ type: "text", text: "done" }], "stop");
stream.push({ type: "done", reason: "stop", message: finalMessage });
callIndex++;
return;
}
stream.push({ type: "start", partial: createAssistantMessage([], "stop") });
const startCall = createToolCall(toolCallId, { path: filePath, diff: "" });
stream.push({
type: "toolcall_start",
contentIndex: 0,
partial: createAssistantMessage([startCall], "stop"),
});
// Emit the full diff in one delta to simplify timing; the abort path
// should still preempt before the call completes (or, if it loses the
// race, the next per-entry `assertEditableFile` is the hard stop).
let acc = "";
for (const chunk of diff.match(/.{1,4}/g) ?? []) {
if (aborted) return;
acc += chunk;
const partial = createToolCall(toolCallId, { path: filePath, diff: acc });
stream.push({
type: "toolcall_delta",
contentIndex: 0,
delta: chunk,
partial: createAssistantMessage([partial], "stop"),
});
await Bun.sleep(0);
}
if (aborted) return;
const finalCall = createToolCall(toolCallId, { path: filePath, diff });
const finalMessage = createAssistantMessage([finalCall], "toolUse");
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: finalCall, partial: finalMessage });
stream.push({ type: "done", reason: "toolUse", message: finalMessage });
callIndex++;
});
return stream;
};
}
let tempDir: string;
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `pi-edit-regressions-${Snowflake.next()}`);
fs.mkdirSync(tempDir, { recursive: true });
});
afterEach(async () => {
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
});
it("auto-generated streaming abort fires even when edit.streamingAbort is disabled", async () => {
// The setting `edit.streamingAbort` defaults to false. The auto-generated
// guard must NOT be gated by that flag — editing a generated file is never
// the user's intent regardless of the patch-preview verification setting.
const checkSpy = vi
.spyOn(autoGeneratedGuard, "assertEditableFile")
.mockRejectedValue(new ToolError("Cannot modify auto-generated file"));
await Bun.write(path.join(tempDir, "generated.ts"), "// AUTO-GENERATED\nexport const x = 1;\n");
const abortSignalRef: { current?: AbortSignal } = {};
const streamFn = streamForSingleToolCall(
"generated.ts",
"@@\n-export const x = 1;\n+export const x = 2;\n",
abortSignalRef,
);
// Explicitly disable edit.streamingAbort to confirm the auto-generated path
// is independent of it.
const { agent, session, authStorage } = await createSessionWith(tempDir, streamFn, buildMockEditTool(), {
"edit.streamingAbort": false,
});
const abortSpy = vi.spyOn(agent, "abort");
try {
await session.prompt("apply patch");
expect(checkSpy).toHaveBeenCalled();
expect(abortSpy).toHaveBeenCalled();
expect(abortSignalRef.current?.aborted ?? false).toBe(true);
const lastAssistant = lastAssistantMessage(session.state.messages);
expect(lastAssistant?.stopReason).toBe("aborted");
} finally {
checkSpy.mockRestore();
abortSpy.mockRestore();
try {
await session.dispose();
} finally {
authStorage.close();
}
}
});
it("multi-entry edit on an auto-generated file surfaces isError + error text instead of silently faking success", async () => {
// Direct invocation of the patch-mode EditTool with two entries against an
// auto-generated file. The orchestrator (executeSinglePathEntries) must NOT
// swallow the per-entry errors — it must mark the aggregate result with
// isError: true so the renderer takes the error branch instead of falling
// through to the streaming preview (which displays the *proposed* diff and
// looks identical to success).
const generatedPath = path.join(tempDir, "generated.ts");
await Bun.write(
generatedPath,
"// Code generated by sqlc. DO NOT EDIT.\nexport const foo = 1;\nexport const bar = 2;\n",
);
const originalVariant = Bun.env.PI_EDIT_VARIANT;
Bun.env.PI_EDIT_VARIANT = "patch";
// The auto-generated guard reads from the *global* settings singleton, so we
// must initialize it (the per-tool `Settings.isolated(...)` we pass into the
// EditTool isn't what the guard sees).
_resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "edit.blockAutoGenerated": true } });
try {
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionDir = path.join(tempDir, "session");
const session = {
cwd: tempDir,
hasUI: false,
getSessionFile: () => sessionFile,
getSessionSpawns: () => "*",
getArtifactsDir: () => sessionDir,
allocateOutputArtifact: async (toolType: string) => {
fs.mkdirSync(sessionDir, { recursive: true });
return { id: "a-1", path: path.join(sessionDir, `a-1.${toolType}.log`) };
},
settings: Settings.isolated({ "edit.blockAutoGenerated": true }),
enableLsp: false,
} as ToolSession;
const editTool = new EditTool(session);
const result = await editTool.execute("test-multi-entry", {
path: generatedPath,
edits: [
{ op: "update", diff: "@@\n-export const foo = 1;\n+export const foo = 11;\n" },
{ op: "update", diff: "@@\n-export const bar = 2;\n+export const bar = 22;\n" },
],
});
// File is untouched on disk.
const contentOnDisk = await Bun.file(generatedPath).text();
expect(contentOnDisk).toContain("export const foo = 1;");
expect(contentOnDisk).toContain("export const bar = 2;");
// Aggregate result must signal an error so the renderer doesn't draw
// the streaming preview as if it succeeded.
expect(result.isError).toBe(true);
// Both per-entry failures must be preserved in the content text so the
// agent (and the error renderer) see the real cause.
const text = (result.content?.find(c => c.type === "text") as { text?: string } | undefined)?.text ?? "";
const occurrences = text.match(/Cannot modify auto-generated file/g) ?? [];
expect(occurrences.length).toBe(2);
// `details.diff` must not contain a fabricated diff that would mislead the
// renderer's preview-fallback branch into showing the proposed change.
const details = result.details as { diff?: string } | undefined;
expect(details?.diff ?? "").toBe("");
} finally {
if (originalVariant === undefined) {
delete Bun.env.PI_EDIT_VARIANT;
} else {
Bun.env.PI_EDIT_VARIANT = originalVariant;
}
}
});
it("agent-loop propagates explicit isError from a tool result to the wire", async () => {
// Validates the boundary fix: `coerceToolResult` preserves a tool-self-reported
// `isError: true`, and agent-loop honors it (emits tool_execution_end with
// isError=true and constructs a tool-result message with isError=true).
const schema = Type.Object({ note: Type.Optional(Type.String()) });
const errorTool: AgentTool = {
name: "edit",
label: "Edit",
description: "",
parameters: schema,
async execute() {
return {
content: [{ type: "text", text: "intentional non-throwing failure" }],
details: {},
isError: true,
};
},
};
let callIndex = 0;
const streamFn: Agent["streamFn"] = (_model, _context, _options) => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
const toolCall = createToolCall("call_self_error", {});
const finalMessage = createAssistantMessage([toolCall], "toolUse");
stream.push({ type: "start", partial: createAssistantMessage([], "stop") });
stream.push({
type: "toolcall_start",
contentIndex: 0,
partial: createAssistantMessage([toolCall], "stop"),
});
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial: finalMessage });
stream.push({ type: "done", reason: "toolUse", message: finalMessage });
} else {
const finalMessage = createAssistantMessage([{ type: "text", text: "done" }], "stop");
stream.push({ type: "done", reason: "stop", message: finalMessage });
}
callIndex++;
});
return stream;
};
const { session, authStorage } = await createSessionWith(tempDir, streamFn, errorTool);
try {
await session.prompt("trigger self-error");
const toolResult = session.state.messages.find(m => m.role === "toolResult") as
| { isError?: boolean; content: Array<{ type: string; text?: string }> }
| undefined;
expect(toolResult).toBeDefined();
expect(toolResult?.isError).toBe(true);
const text = toolResult?.content?.find(c => c.type === "text")?.text ?? "";
expect(text).toContain("intentional non-throwing failure");
} finally {
try {
await session.dispose();
} finally {
authStorage.close();
}
}
});
@@ -33,24 +33,6 @@ describe("dropIncompleteLastEdit", () => {
});
});
describe("apply_patch extractCompleteEdits", () => {
const strategy = EDIT_MODE_STRATEGIES.apply_patch;
test("returns args unchanged (payload is plain text)", () => {
const args = { input: "*** Begin Patch\n*** Update File: a.ts\n@@\n-x\n+y\n*** End Patch\n" };
expect(strategy.extractCompleteEdits(args, undefined)).toEqual(args);
});
});
describe("vim extractCompleteEdits", () => {
const strategy = EDIT_MODE_STRATEGIES.vim;
test("returns args unchanged (vim stream handled elsewhere)", () => {
const args = { file: "a.ts", steps: [] };
expect(strategy.extractCompleteEdits(args, undefined)).toEqual(args);
});
});
describe("hashline streaming preview (multi-section)", () => {
const strategy = EDIT_MODE_STRATEGIES.hashline;
let tmpDir: string;
@@ -0,0 +1,18 @@
import { describe, expect, it } from "bun:test";
import { TEMPLATE } from "../../src/export/html/template.generated";
describe("HTML export template developer message support", () => {
it("renders developer-role messages in the main feed", () => {
expect(TEMPLATE).toContain("msg.role === 'developer'");
expect(TEMPLATE).toContain("developer-message");
});
it("labels developer entries in the sidebar tree", () => {
expect(TEMPLATE).toContain("tree-role-developer");
expect(TEMPLATE).toContain("developer:");
});
it("counts developer messages in header stats", () => {
expect(TEMPLATE).toContain("developerMessages");
});
});
@@ -25,12 +25,6 @@ function createApi(): CustomCommandAPI {
}
describe("GreenCommand", () => {
it("exposes the /green command name", () => {
const command = new GreenCommand(createApi());
expect(command.name).toBe("green");
});
it("includes tag instructions when HEAD has a tag", async () => {
vi.spyOn(git.ref, "tags").mockResolvedValue(["v0.1.0-alpha2"]);
const command = new GreenCommand(createApi());
@@ -1,89 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { isHindsightConfigured, loadHindsightConfig } from "@oh-my-pi/pi-coding-agent/hindsight/config";
describe("loadHindsightConfig", () => {
beforeEach(() => {
_resetSettingsForTest();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns sane defaults from an empty Settings", () => {
const settings = Settings.isolated();
const cfg = loadHindsightConfig(settings, {});
expect(cfg.hindsightApiUrl).toBe("http://localhost:8888"); // schema default
expect(cfg.recallBudget).toBe("mid");
expect(cfg.retainMode).toBe("full-session");
expect(cfg.recallTypes).toEqual(["world", "experience"]);
expect(cfg.autoRecall).toBe(true);
expect(cfg.autoRetain).toBe(true);
expect(cfg.scoping).toBe("per-project-tagged");
});
it("env overrides win over settings", () => {
const settings = Settings.isolated({
"hindsight.apiUrl": "http://settings.example",
"hindsight.autoRecall": true,
"hindsight.recallMaxTokens": 256,
"hindsight.scoping": "global",
"hindsight.retainMode": "full-session",
});
const cfg = loadHindsightConfig(settings, {
HINDSIGHT_API_URL: "http://env.example",
HINDSIGHT_AUTO_RECALL: "false",
HINDSIGHT_RECALL_MAX_TOKENS: "9999",
HINDSIGHT_SCOPING: "per-project",
HINDSIGHT_RETAIN_MODE: "last-turn",
});
expect(cfg.hindsightApiUrl).toBe("http://env.example");
expect(cfg.autoRecall).toBe(false);
expect(cfg.recallMaxTokens).toBe(9999);
expect(cfg.scoping).toBe("per-project");
expect(cfg.retainMode).toBe("last-turn");
});
it("ignores invalid scoping values and falls back to the schema default", () => {
const settings = Settings.isolated();
const cfg = loadHindsightConfig(settings, { HINDSIGHT_SCOPING: "garbage" });
expect(cfg.scoping).toBe("per-project-tagged");
});
it("ignores invalid retainMode env values and falls back to schema default", () => {
const settings = Settings.isolated();
const cfg = loadHindsightConfig(settings, { HINDSIGHT_RETAIN_MODE: "garbage" });
expect(cfg.retainMode).toBe("full-session");
});
it("coerces non-numeric ints back to undefined so settings/default takes over", () => {
const settings = Settings.isolated({ "hindsight.recallMaxTokens": 512 });
const cfg = loadHindsightConfig(settings, { HINDSIGHT_RECALL_MAX_TOKENS: "not-a-number" });
expect(cfg.recallMaxTokens).toBe(512);
});
it("respects falsy boolean env strings", () => {
const settings = Settings.isolated();
const cfg = loadHindsightConfig(settings, {
HINDSIGHT_AUTO_RECALL: "no",
HINDSIGHT_AUTO_RETAIN: "0",
});
expect(cfg.autoRecall).toBe(false);
expect(cfg.autoRetain).toBe(false);
});
});
describe("isHindsightConfigured", () => {
it("returns true when an apiUrl is set", () => {
const cfg = loadHindsightConfig(Settings.isolated({ "hindsight.apiUrl": "http://x" }), {});
expect(isHindsightConfigured(cfg)).toBe(true);
});
it("returns false when apiUrl is missing", () => {
const cfg = loadHindsightConfig(Settings.isolated({ "hindsight.apiUrl": "" }), {
HINDSIGHT_API_URL: "",
});
expect(isHindsightConfigured(cfg)).toBe(false);
});
});
@@ -3,10 +3,6 @@ import { describe, expect, it } from "bun:test";
describe("Buffer.toBase64", async () => {
await import("../src/utils/image-resize");
it("should be defined", () => {
expect(Buffer.prototype.toBase64).toBeDefined();
});
it("should return a base64 string", () => {
const buffer = Buffer.from("Hello, world!");
expect(buffer.toBase64()).toBe("SGVsbG8sIHdvcmxkIQ==");
@@ -7,16 +7,6 @@ describe("ensureSupportedImageInput", () => {
vi.restoreAllMocks();
});
test("returns supported image input unchanged", async () => {
const convertToPngSpy = vi.spyOn(imageConvert, "convertToPng");
const input = { type: "image" as const, data: "abc", mimeType: "image/png" };
const result = await ensureSupportedImageInput(input);
expect(result).toEqual(input);
expect(convertToPngSpy).not.toHaveBeenCalled();
});
test("converts unsupported image input to png", async () => {
const convertToPngSpy = vi
.spyOn(imageConvert, "convertToPng")
@@ -1,4 +1,4 @@
import { describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
@@ -7,7 +7,7 @@ import {
LocalProtocolHandler,
resolveLocalRoot,
resolveLocalUrlToPath,
} from "../../src/internal-urls";
} from "@oh-my-pi/pi-coding-agent/internal-urls";
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "local-protocol-"));
@@ -18,25 +18,28 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
}
}
function createRouter(options: { artifactsDir?: string | null; sessionId?: string | null }): InternalUrlRouter {
const router = new InternalUrlRouter();
router.register(
new LocalProtocolHandler({
getArtifactsDir: () => options.artifactsDir ?? null,
getSessionId: () => options.sessionId ?? null,
}),
);
return router;
}
describe("LocalProtocolHandler", () => {
beforeEach(() => {
LocalProtocolHandler.resetOverrideForTests();
InternalUrlRouter.resetForTests();
});
afterEach(() => {
LocalProtocolHandler.resetOverrideForTests();
InternalUrlRouter.resetForTests();
});
it("lists files at local://", async () => {
await withTempDir(async tempDir => {
const artifactsDir = path.join(tempDir, "artifacts");
await fs.mkdir(path.join(artifactsDir, "local"), { recursive: true });
await Bun.write(path.join(artifactsDir, "local", "handoff.json"), '{"ok":true}');
const router = createRouter({ artifactsDir, sessionId: "session-a" });
LocalProtocolHandler.setOverride({
getArtifactsDir: () => artifactsDir,
getSessionId: () => "session-a",
});
const router = InternalUrlRouter.instance();
const resource = await router.resolve("local://");
expect(resource.contentType).toBe("text/markdown");
@@ -51,7 +54,11 @@ describe("LocalProtocolHandler", () => {
await fs.mkdir(path.dirname(localFile), { recursive: true });
await Bun.write(localFile, "trace");
const router = createRouter({ artifactsDir, sessionId: "session-b" });
LocalProtocolHandler.setOverride({
getArtifactsDir: () => artifactsDir,
getSessionId: () => "session-b",
});
const router = InternalUrlRouter.instance();
const resource = await router.resolve("local://subtasks/trace.txt");
expect(resource.content).toBe("trace");
@@ -61,7 +68,11 @@ describe("LocalProtocolHandler", () => {
it("blocks path traversal attempts", async () => {
await withTempDir(async tempDir => {
const router = createRouter({ artifactsDir: path.join(tempDir, "artifacts"), sessionId: "session-c" });
LocalProtocolHandler.setOverride({
getArtifactsDir: () => path.join(tempDir, "artifacts"),
getSessionId: () => "session-c",
});
const router = InternalUrlRouter.instance();
await expect(router.resolve("local://../secret.txt")).rejects.toThrow(
"Path traversal (..) is not allowed in local:// URLs",
);
@@ -91,7 +102,11 @@ describe("LocalProtocolHandler", () => {
await Bun.write(path.join(outsideDir, "secret.txt"), "secret");
await fs.symlink(outsideDir, path.join(localRoot, "linked"));
const router = createRouter({ artifactsDir, sessionId: "session-d" });
LocalProtocolHandler.setOverride({
getArtifactsDir: () => artifactsDir,
getSessionId: () => "session-d",
});
const router = InternalUrlRouter.instance();
await expect(router.resolve("local://linked/secret.txt")).rejects.toThrow("local:// URL escapes local root");
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { InternalUrlRouter, McpProtocolHandler } from "../../src/internal-urls";
import type { MCPManager } from "../../src/mcp/manager";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { InternalUrlRouter } from "../../src/internal-urls";
import { MCPManager } from "../../src/mcp/manager";
import type { MCPResource, MCPResourceReadResult, MCPResourceTemplate } from "../../src/mcp/types";
function createMockManager(opts: {
@@ -19,25 +19,26 @@ function createMockManager(opts: {
} as unknown as MCPManager;
}
function createRouter(manager?: MCPManager): InternalUrlRouter {
const router = new InternalUrlRouter();
router.register(
new McpProtocolHandler({
getMcpManager: () => manager,
}),
);
return router;
}
describe("McpProtocolHandler", () => {
beforeEach(() => {
MCPManager.resetForTests();
InternalUrlRouter.resetForTests();
});
afterEach(() => {
MCPManager.resetForTests();
InternalUrlRouter.resetForTests();
});
it("returns error when no MCP manager is available", async () => {
const router = createRouter();
const router = InternalUrlRouter.instance();
await expect(router.resolve("mcp://test://resource")).rejects.toThrow("No MCP manager");
});
it("requires resource URI in mcp URL", async () => {
const manager = createMockManager({ servers: ["server-a"] });
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
await expect(router.resolve("mcp://")).rejects.toThrow("mcp:// URL requires a resource URI");
});
@@ -48,7 +49,8 @@ describe("McpProtocolHandler", () => {
templates: [],
});
const manager = createMockManager({ servers: ["server-a"], resources });
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
await expect(router.resolve("mcp://test://missing")).rejects.toThrow("No MCP server has resource");
await expect(router.resolve("mcp://test://missing")).rejects.toThrow("file://known");
@@ -66,7 +68,8 @@ describe("McpProtocolHandler", () => {
resources,
readResult: { contents: [{ uri: "test://doc", text: "hello world" }] },
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("mcp://test://doc");
expect(resource.content).toBe("hello world");
@@ -84,7 +87,8 @@ describe("McpProtocolHandler", () => {
resources,
readResult: { contents: [{ uri: "test://doc?q=1", text: "query resource" }] },
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("mcp://test://doc?q=1");
expect(resource.content).toBe("query resource");
@@ -101,7 +105,8 @@ describe("McpProtocolHandler", () => {
resources,
readResult: { contents: [{ uri: "test://docs/foo/raw", text: "from template" }] },
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("mcp://test://docs/foo/raw");
expect(resource.content).toBe("from template");
@@ -118,7 +123,8 @@ describe("McpProtocolHandler", () => {
resources,
readResult: { contents: [{ uri: "test://docs", text: "empty expansion" }] },
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("mcp://test://docs");
expect(resource.content).toBe("empty expansion");
@@ -139,7 +145,8 @@ describe("McpProtocolHandler", () => {
resources,
readResult: { contents: [{ uri: "test://foo/123", text: "from specific" }] },
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("mcp://test://foo/123");
expect(resource.notes).toEqual(["MCP server: specific-server"]);
@@ -160,7 +167,8 @@ describe("McpProtocolHandler", () => {
resources,
readResult: { contents: [{ uri: "test://foo", text: "from first" }] },
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("mcp://test://foo");
expect(resource.notes).toEqual(["MCP server: first"]);
@@ -173,7 +181,8 @@ describe("McpProtocolHandler", () => {
templates: [{ uriTemplate: "testing://{id}", name: "testing-template" }],
});
const manager = createMockManager({ servers: ["tmpl-server"], resources });
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
await expect(router.resolve("mcp://test://foo")).rejects.toThrow("No MCP server has resource");
});
@@ -189,7 +198,8 @@ describe("McpProtocolHandler", () => {
resources,
readResult: undefined,
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
await expect(router.resolve("mcp://test://empty")).rejects.toThrow("returned no content");
await expect(router.resolve("mcp://test://empty")).rejects.toThrow("null-server");
@@ -209,7 +219,8 @@ describe("McpProtocolHandler", () => {
contents: [{ uri: "test://image", mimeType: "image/png", blob: blobData }],
},
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("mcp://test://image");
expect(resource.content).toContain("[Binary content:");
@@ -233,7 +244,8 @@ describe("McpProtocolHandler", () => {
],
},
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("mcp://test://mixed");
expect(resource.content).toContain("part one");
@@ -254,7 +266,8 @@ describe("McpProtocolHandler", () => {
contents: [{ uri: "test://blank" }],
},
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("mcp://test://blank");
expect(resource.content).toBe("(empty resource)");
@@ -271,7 +284,8 @@ describe("McpProtocolHandler", () => {
resources,
readError: new Error("connection refused"),
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
await expect(router.resolve("mcp://test://fail")).rejects.toThrow("MCP resource read error:");
await expect(router.resolve("mcp://test://fail")).rejects.toThrow("connection refused");
@@ -292,7 +306,8 @@ describe("McpProtocolHandler", () => {
resources,
readResult: { contents: [{ uri: "test://shared", text: "from first" }] },
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("mcp://test://shared");
expect(resource.notes).toEqual(["MCP server: first"]);
@@ -300,7 +315,8 @@ describe("McpProtocolHandler", () => {
it("shows (none) when no servers have any resources", async () => {
const manager = createMockManager({ servers: ["lonely-server"] });
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
await expect(router.resolve("mcp://test://anything")).rejects.toThrow("(none)");
});
@@ -318,7 +334,8 @@ describe("McpProtocolHandler", () => {
contents: [{ uri: "test://bin", blob: "data" }],
},
});
const router = createRouter(manager);
MCPManager.setInstance(manager);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("mcp://test://bin");
expect(resource.content).toContain("[Binary content: unknown,");
@@ -1,36 +1,67 @@
import { describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { InternalUrlRouter, MemoryProtocolHandler } from "../../src/internal-urls";
import { InternalUrlRouter } from "@oh-my-pi/pi-coding-agent/internal-urls";
import { getMemoryRoot } from "@oh-my-pi/pi-coding-agent/memories";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { getAgentDir, setAgentDir } from "@oh-my-pi/pi-utils";
import { AgentRegistry } from "../../src/registry/agent-registry";
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-protocol-"));
interface MemoryFixture {
cwd: string;
memoryRoot: string;
agentDir: string;
cleanupRoot: string;
}
async function withMemoryFixture(fn: (fixture: MemoryFixture) => Promise<void>): Promise<void> {
const cleanupRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-protocol-"));
const previousAgentDir = getAgentDir();
try {
return await fn(dir);
const agentDir = path.join(cleanupRoot, "agent");
await fs.mkdir(agentDir, { recursive: true });
const cwd = path.join(cleanupRoot, "project");
await fs.mkdir(cwd, { recursive: true });
setAgentDir(agentDir);
const memoryRoot = getMemoryRoot(agentDir, cwd);
await fs.mkdir(memoryRoot, { recursive: true });
AgentRegistry.global().register({
id: "test-main",
displayName: "test",
kind: "main",
session: {
sessionManager: {
getCwd: () => cwd,
getArtifactsDir: () => null,
getSessionId: () => "test",
},
} as unknown as AgentSession,
sessionFile: null,
});
await fn({ cwd, memoryRoot, agentDir, cleanupRoot });
} finally {
await fs.rm(dir, { recursive: true, force: true });
setAgentDir(previousAgentDir);
await fs.rm(cleanupRoot, { recursive: true, force: true });
}
}
function createRouter(memoryRoot: string): InternalUrlRouter {
const router = new InternalUrlRouter();
router.register(
new MemoryProtocolHandler({
getMemoryRoot: () => memoryRoot,
}),
);
return router;
}
describe("MemoryProtocolHandler", () => {
beforeEach(() => {
AgentRegistry.resetGlobalForTests();
InternalUrlRouter.resetForTests();
});
afterEach(() => {
AgentRegistry.resetGlobalForTests();
InternalUrlRouter.resetForTests();
});
it("resolves memory://root to memory_summary.md", async () => {
await withTempDir(async tempDir => {
const memoryRoot = path.join(tempDir, "memory");
await fs.mkdir(memoryRoot, { recursive: true });
await withMemoryFixture(async ({ memoryRoot }) => {
await Bun.write(path.join(memoryRoot, "memory_summary.md"), "summary");
const router = createRouter(memoryRoot);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("memory://root");
expect(resource.content).toBe("summary");
@@ -39,13 +70,12 @@ describe("MemoryProtocolHandler", () => {
});
it("resolves memory://root/<path> within memory root", async () => {
await withTempDir(async tempDir => {
const memoryRoot = path.join(tempDir, "memory");
await withMemoryFixture(async ({ memoryRoot }) => {
const skillPath = path.join(memoryRoot, "skills", "demo", "SKILL.md");
await fs.mkdir(path.dirname(skillPath), { recursive: true });
await Bun.write(skillPath, "demo skill");
const router = createRouter(memoryRoot);
const router = InternalUrlRouter.instance();
const resource = await router.resolve("memory://root/skills/demo/SKILL.md");
expect(resource.content).toBe("demo skill");
@@ -54,11 +84,8 @@ describe("MemoryProtocolHandler", () => {
});
it("throws for unknown memory namespace", async () => {
await withTempDir(async tempDir => {
const memoryRoot = path.join(tempDir, "memory");
await fs.mkdir(memoryRoot, { recursive: true });
const router = createRouter(memoryRoot);
await withMemoryFixture(async () => {
const router = InternalUrlRouter.instance();
await expect(router.resolve("memory://other/memory_summary.md")).rejects.toThrow(
"Unknown memory namespace: other. Supported: root",
);
@@ -66,11 +93,8 @@ describe("MemoryProtocolHandler", () => {
});
it("blocks path traversal attempts", async () => {
await withTempDir(async tempDir => {
const memoryRoot = path.join(tempDir, "memory");
await fs.mkdir(memoryRoot, { recursive: true });
const router = createRouter(memoryRoot);
await withMemoryFixture(async () => {
const router = InternalUrlRouter.instance();
await expect(router.resolve("memory://root/../secret.md")).rejects.toThrow(
"Path traversal (..) is not allowed in memory:// URLs",
);
@@ -81,11 +105,8 @@ describe("MemoryProtocolHandler", () => {
});
it("throws clear error for missing files", async () => {
await withTempDir(async tempDir => {
const memoryRoot = path.join(tempDir, "memory");
await fs.mkdir(memoryRoot, { recursive: true });
const router = createRouter(memoryRoot);
await withMemoryFixture(async () => {
const router = InternalUrlRouter.instance();
await expect(router.resolve("memory://root/missing.md")).rejects.toThrow(
"Memory file not found: memory://root/missing.md",
);
@@ -95,15 +116,13 @@ describe("MemoryProtocolHandler", () => {
it("blocks symlink escapes outside memory root", async () => {
if (process.platform === "win32") return;
await withTempDir(async tempDir => {
const memoryRoot = path.join(tempDir, "memory");
const outsideDir = path.join(tempDir, "outside");
await fs.mkdir(memoryRoot, { recursive: true });
await withMemoryFixture(async ({ memoryRoot, cleanupRoot }) => {
const outsideDir = path.join(cleanupRoot, "outside");
await fs.mkdir(outsideDir, { recursive: true });
await Bun.write(path.join(outsideDir, "secret.md"), "secret");
await fs.symlink(outsideDir, path.join(memoryRoot, "linked"));
const router = createRouter(memoryRoot);
const router = InternalUrlRouter.instance();
await expect(router.resolve("memory://root/linked/secret.md")).rejects.toThrow(
"memory:// URL escapes memory root",
);
@@ -0,0 +1,66 @@
import { describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
/**
* Regression for https://github.com/can1357/oh-my-pi/issues/1011
*
* In v14.5.13 `spawnTabWorker` (in `src/tools/browser/tab-supervisor.ts`) was
* introduced to host browser tabs in a `Worker`. The worker URL was assembled
* as:
*
* ```ts
* const url = new URL("./tab-worker-entry.ts", import.meta.url);
* const worker = new Worker(url.href, { type: "module" });
* ```
*
* Bun's `--compile` bundler does NOT statically discover that pattern (the
* worker entry is hidden behind a local variable and `.href`), so the entry
* file is never embedded in the single-file binary. At runtime the worker
* thread tries to load `/$bunfs/root/tab-worker-entry.ts`, the module is
* missing, and the supervisor surfaces the symptom from the issue:
* `Timed out initializing browser tab worker`.
*
* `Bun.build` exposes the same static-analysis pass that drives `--compile`.
* If `tab-worker-entry.ts` is reachable to the bundler, it appears in the
* outputs as a separate `asset` chunk. If the spawn pattern hides it from
* the bundler, only the entry point is emitted.
*
* The bundler is driven through a `bun -e` subprocess that writes its report
* to a tmp file: invoking `Bun.build` directly from inside `bun test` does
* not auto-resolve TypeScript imports the way the real build pipeline does.
*/
describe("issue #1011 — tab worker entry must survive `bun build --compile`", () => {
it("bundles tab-worker-entry.ts as a discoverable asset of tab-supervisor.ts", async () => {
const supervisor = path.resolve(import.meta.dir, "../src/tools/browser/tab-supervisor.ts");
const packageDir = path.resolve(import.meta.dir, "..");
const reportPath = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "issue-1011-")), "report.json");
const script = `const r = await Bun.build({ entrypoints: [${JSON.stringify(supervisor)}], target: "bun" }); await Bun.write(${JSON.stringify(reportPath)}, JSON.stringify({ success: r.success, outputs: r.outputs.map(o => ({ path: o.path, kind: o.kind })), logs: r.logs.map(l => l.message) }));`;
const proc = Bun.spawnSync(["bun", "-e", script], {
cwd: packageDir,
stdout: "pipe",
stderr: "pipe",
});
const stderr = proc.stderr.toString();
expect(proc.exitCode, `bun -e exited with ${proc.exitCode}; stderr=${stderr}`).toBe(0);
const report = (await Bun.file(reportPath).json()) as {
success: boolean;
outputs: { path: string; kind: string }[];
logs: string[];
};
expect(report.success, `bundler logs: ${report.logs.join("; ")}`).toBe(true);
const workerAssets = report.outputs.filter(out => out.kind === "asset" && out.path.includes("tab-worker-entry"));
if (workerAssets.length === 0) {
const summary = report.outputs.map(o => `${o.kind}:${o.path}`).join(", ");
throw new Error(
`tab-worker-entry.ts was not bundled as an asset of tab-supervisor.ts. ` +
`Bun's --compile bundler cannot embed the worker because the Worker ` +
`constructor argument is not a statically-analyzable URL literal. ` +
`Bundler outputs were: [${summary}]`,
);
}
});
});
@@ -54,20 +54,4 @@ describe("ModelRegistry LM Studio Fixes", () => {
expect(available.some(m => m.provider === "ollama")).toBe(true);
expect(available.some(m => m.provider === "lm-studio")).toBe(true);
});
test("lm-studio discovery handles trailing slashes in baseUrl correctly", async () => {
let _requestedUrl = "";
using _hook = hookFetch(input => {
const url = String(input);
// Only track URLs from our test endpoints; ignore concurrent built-in provider discovery
if (url.includes("127.0.0.1:1234") || url.includes("127.0.0.1:9999") || url.startsWith("not a url")) {
_requestedUrl = url;
return new Response(JSON.stringify({ data: [{ id: "model-1" }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(null, { status: 404 });
});
});
});
@@ -62,17 +62,6 @@ describe("isValidVersionForCache", () => {
// ── getCachedPluginPath ──────────────────────────────────────────────────────
describe("getCachedPluginPath", () => {
it("returns a deterministic path with ___ separators", () => {
const p = getCachedPluginPath("/cache", "my-market", "my-plugin", "1.0.0");
expect(p).toBe("/cache/my-market___my-plugin___1.0.0");
});
it("is independent of cacheDir content — pure path construction", () => {
const p1 = getCachedPluginPath("/a", "m", "p", "1");
const p2 = getCachedPluginPath("/b", "m", "p", "1");
expect(path.basename(p1)).toBe(path.basename(p2));
});
it("throws on invalid marketplace name (uppercase)", () => {
expect(() => getCachedPluginPath("/cache", "My-Market", "plugin", "1.0.0")).toThrow(/Invalid marketplace name/);
});
@@ -195,13 +195,6 @@ describe("MarketplaceManager", () => {
);
});
it("installPlugin calls clearPluginRootsCache", async () => {
await ctx.manager.addMarketplace(FIXTURE_DIR);
const before = ctx.clearCount();
await ctx.manager.installPlugin("hello-plugin", "test-marketplace");
expect(ctx.clearCount()).toBe(before + 1);
});
// ── Uninstall ──────────────────────────────────────────────────────────
it("uninstallPlugin → cache removed + deregistered", async () => {
@@ -224,14 +217,6 @@ describe("MarketplaceManager", () => {
await expect(ctx.manager.uninstallPlugin("no-at-sign")).rejects.toThrow(/Invalid plugin ID format/);
});
it("uninstallPlugin calls clearPluginRootsCache", async () => {
await ctx.manager.addMarketplace(FIXTURE_DIR);
await ctx.manager.installPlugin("hello-plugin", "test-marketplace");
const before = ctx.clearCount();
await ctx.manager.uninstallPlugin("hello-plugin@test-marketplace");
expect(ctx.clearCount()).toBe(before + 1);
});
// ── setPluginEnabled ───────────────────────────────────────────────────
it("setPluginEnabled → persisted in registry", async () => {
@@ -252,14 +237,6 @@ describe("MarketplaceManager", () => {
await expect(ctx.manager.setPluginEnabled("ghost@nowhere", true)).rejects.toThrow(/not installed/);
});
it("setPluginEnabled calls clearPluginRootsCache", async () => {
await ctx.manager.addMarketplace(FIXTURE_DIR);
await ctx.manager.installPlugin("hello-plugin", "test-marketplace");
const before = ctx.clearCount();
await ctx.manager.setPluginEnabled("hello-plugin@test-marketplace", false);
expect(ctx.clearCount()).toBe(before + 1);
});
// ── version fallback ───────────────────────────────────────────────────
it("installPlugin falls back to plugin.json version when catalog version is missing", async () => {
@@ -191,14 +191,6 @@ describe("parseInternalUrl — protocol field", () => {
expect(parseInternalUrl("local://x").protocol).toBe("local:");
});
it("extracts rule: protocol", () => {
expect(parseInternalUrl("rule://x").protocol).toBe("rule:");
});
it("extracts artifact: protocol", () => {
expect(parseInternalUrl("artifact://x").protocol).toBe("artifact:");
});
it("extracts protocol from fallback-parsed URL", () => {
// This URL fails new URL() due to colon-as-port
expect(parseInternalUrl("skill://a:b").protocol).toBe("skill:");
@@ -1,53 +0,0 @@
import { describe, expect, it } from "bun:test";
import { buildPluginDirRoot } from "@oh-my-pi/pi-coding-agent/discovery/plugin-dir-roots";
describe("buildPluginDirRoot", () => {
it("builds root with manifest name", () => {
const root = buildPluginDirRoot("/path/to/my-plugin", "custom-name");
expect(root).toEqual({
id: "custom-name@__local__",
marketplace: "__local__",
plugin: "custom-name",
version: "local",
path: "/path/to/my-plugin",
scope: "user",
});
});
it("falls back to directory basename when no manifest name", () => {
const root = buildPluginDirRoot("/path/to/my-plugin");
expect(root.plugin).toBe("my-plugin");
expect(root.id).toBe("my-plugin@__local__");
});
it("falls back to directory basename when manifest name is undefined", () => {
const root = buildPluginDirRoot("/some/dir/cool-plugin", undefined);
expect(root.plugin).toBe("cool-plugin");
expect(root.id).toBe("cool-plugin@__local__");
});
it("uses __local__ marketplace", () => {
const root = buildPluginDirRoot("/any/path", "test");
expect(root.marketplace).toBe("__local__");
});
it("uses local version string", () => {
const root = buildPluginDirRoot("/any/path", "test");
expect(root.version).toBe("local");
});
it("sets scope to user", () => {
const root = buildPluginDirRoot("/any/path", "test");
expect(root.scope).toBe("user");
});
it("preserves absolute path", () => {
const root = buildPluginDirRoot("/absolute/path/to/plugin", "test");
expect(root.path).toBe("/absolute/path/to/plugin");
});
it("constructs id as pluginName@__local__", () => {
const root = buildPluginDirRoot("/p", "my-tool");
expect(root.id).toBe("my-tool@__local__");
});
});
@@ -103,14 +103,6 @@ describe("roots response shape", () => {
expect(result.roots[0].name).toBe("project");
});
it("produces valid file:// URI on Windows-style paths", () => {
// path.basename and pathToFileURL are platform-dependent for
// Windows paths; only assert the URI format, not the name.
const result = getRoots("C:\\Users\\dev\\myproject");
expect(result.roots[0].uri).toMatch(/^file:\/\/\//);
expect(result.roots[0].name).toBeTruthy();
});
it("handles paths with spaces", () => {
const result = getRoots("/home/user/my project");
expect(result.roots[0].uri).toContain("my%20project");
@@ -11,16 +11,6 @@ describe("resolveMemoryBackend", () => {
_resetSettingsForTest();
});
it("returns the off backend when memory.backend is off", () => {
const settings = Settings.isolated({ "memory.backend": "off" });
expect(resolveMemoryBackend(settings).id).toBe("off");
});
it("returns the local backend when memory.backend is local", () => {
const settings = Settings.isolated({ "memory.backend": "local", "memories.enabled": false });
expect(resolveMemoryBackend(settings).id).toBe("local");
});
it("returns the hindsight backend when memory.backend is hindsight, regardless of legacy memories.enabled", () => {
const a = Settings.isolated({ "memory.backend": "hindsight", "memories.enabled": false });
const b = Settings.isolated({ "memory.backend": "hindsight", "memories.enabled": true });
@@ -95,15 +95,6 @@ describe("ModelRegistry runtime provider registration", () => {
expect(registry.find(providerName, modelId)?.headers?.[headerName]).toBe(headerValue);
}
test("loads built-in GitLab Duo models and OAuth provider metadata", () => {
const registry = new ModelRegistry(authStorage, modelsJsonPath);
const model = registry.find("gitlab-duo", "claude-sonnet-4-5-20250929");
expect(model).toBeDefined();
expect(model?.api).toBe("anthropic-messages");
expect(getOAuthProviders().some(provider => provider.id === "gitlab-duo")).toBe(true);
});
test("validates provider config before mutating custom API state", () => {
const registry = new ModelRegistry(authStorage, modelsJsonPath);
const beforeAnthropicCount = registry.getAll().filter(model => model.provider === "anthropic").length;
@@ -125,27 +116,6 @@ describe("ModelRegistry runtime provider registration", () => {
expect(afterAnthropicCount).toBe(beforeAnthropicCount);
});
test("merges provider/model headers and adds Authorization when authHeader is enabled", () => {
const registry = new ModelRegistry(authStorage, modelsJsonPath);
const config: ProviderConfigInput = {
baseUrl: "https://runtime.example.com/v1",
apiKey: "RUNTIME_KEY",
api: "openai-completions",
authHeader: true,
headers: { "X-Provider": "provider-header" },
models: [{ ...baseModel, headers: { "X-Model": "model-header" } }],
};
registry.registerProvider("runtime-provider", config, "ext://runtime");
const model = registry.find("runtime-provider", "runtime-model");
expect(model).toBeDefined();
expect(model?.headers?.Authorization).toBe("Bearer RUNTIME_KEY");
expect(model?.headers?.["X-Provider"]).toBe("provider-header");
expect(model?.headers?.["X-Model"]).toBe("model-header");
});
test("registerProvider applies headers-only overrides to existing provider models across refresh", async () => {
const registry = new ModelRegistry(authStorage, modelsJsonPath);
const providerName = "anthropic";
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Effort, type Model, type OpenAICompat, type ThinkingConfig, writeModelCache } from "@oh-my-pi/pi-ai";
import { kNoAuth, MODEL_ROLES, ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { kNoAuth, ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { hookFetch, Snowflake } from "@oh-my-pi/pi-utils";
@@ -14,11 +14,6 @@ describe("ModelRegistry", () => {
let cacheDbPath: string;
let authStorage: AuthStorage;
test("commit role includes a visible badge tag", () => {
expect(MODEL_ROLES.commit.tag).toBe("COMMIT");
expect(MODEL_ROLES.commit.color).toBe("dim");
});
beforeEach(async () => {
_resetSettingsForTest();
tempDir = path.join(os.tmpdir(), `pi-test-model-registry-${Snowflake.next()}`);
@@ -56,44 +56,6 @@ describe("ModelSelector role badge thinking display", () => {
}
});
test("renders per-role thinking labels with inherit mode to avoid badge ambiguity", async () => {
installTestTheme();
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5");
const settings = Settings.isolated({
modelRoles: {
default: `${model.provider}/${model.id}`,
smol: `${model.provider}/${model.id}:minimal`,
slow: `${model.provider}/${model.id}`,
plan: `${model.provider}/${model.id}:high`,
commit: `${model.provider}/${model.id}:medium`,
},
});
const selector = createSelector(model, settings);
await Bun.sleep(0);
installTestTheme();
const rendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(rendered).toContain("DEFAULT (inherit)");
expect(rendered).toContain("SMOL (min)");
expect(rendered).toContain("SLOW (inherit)");
expect(rendered).toContain("PLAN (high)");
expect(rendered).toContain("COMMIT (medium)");
expect(rendered).not.toContain("Role Thinking:");
selector.handleInput("\n");
installTestTheme();
const menuRendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(menuRendered).toContain("Set as DEFAULT (Default)");
expect(menuRendered).toContain("Set as SMOL (Fast)");
expect(menuRendered).toContain("Set as SLOW (Thinking)");
expect(menuRendered).toContain("Set as PLAN (Architect)");
expect(menuRendered).toContain("Set as COMMIT (Commit)");
});
test("shows custom roles from cycleOrder/modelRoles and honors built-in metadata overrides", async () => {
installTestTheme();
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
@@ -0,0 +1,65 @@
import { beforeAll, describe, expect, it } from "bun:test";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { TreeSelectorComponent } from "../../../src/modes/components/tree-selector";
import * as themeModule from "../../../src/modes/theme/theme";
import type { SessionEntry, SessionTreeNode } from "../../../src/session/session-manager";
let counter = 0;
function makeMessageNode(message: AgentMessage, parentId: string | null = null, label?: string): SessionTreeNode {
const id = `entry-${counter++}`;
const entry: SessionEntry = {
type: "message",
id,
parentId,
timestamp: new Date().toISOString(),
message,
};
return { entry, children: [], label };
}
function render(tree: SessionTreeNode[], width = 120): string {
const selector = new TreeSelectorComponent(
tree,
tree[tree.length - 1]?.entry.id ?? null,
60,
() => {},
() => {},
);
return Bun.stripANSI(selector.render(width).join("\n"));
}
describe("TreeSelectorComponent developer message rendering", () => {
beforeAll(async () => {
await themeModule.initTheme(false, undefined, undefined, "dark", "light");
});
it("renders developer messages with their content, not just [developer]", () => {
const planContent = "## Plan\n\n1. Fix the tree selector\n2. Update the HTML export";
const root = makeMessageNode({ role: "user", content: "/plan", timestamp: 1 });
const developer = makeMessageNode(
{ role: "developer", content: [{ type: "text", text: planContent }], timestamp: 2 },
root.entry.id,
);
root.children.push(developer);
const rendered = render([root]);
expect(rendered).toContain("developer:");
expect(rendered).toContain("Fix the tree selector");
expect(rendered).toContain("Update the HTML export");
expect(rendered).not.toMatch(/^\s*\[developer\]\s*$/m);
});
it("matches developer messages in search (content is searchable)", () => {
const planContent = "ZZZ_UNIQUE_PLAN_TOKEN approved plan body";
const root = makeMessageNode({ role: "user", content: "/plan", timestamp: 1 });
const developer = makeMessageNode(
{ role: "developer", content: [{ type: "text", text: planContent }], timestamp: 2 },
root.entry.id,
);
root.children.push(developer);
const rendered = render([root]);
expect(rendered).toContain("ZZZ_UNIQUE_PLAN_TOKEN");
});
});
@@ -85,29 +85,6 @@ describe("BtwController", () => {
expect(controller.hasActiveRequest()).toBe(true);
});
it("streams text deltas through onTextDelta into the panel", async () => {
const deltas: string[] = [];
const runEphemeralTurn = vi.fn(async (args: RunEphemeralTurnArgs) => {
args.onTextDelta?.("Hel");
args.onTextDelta?.("lo");
return { replyText: "Hello", assistantMessage: createAssistantMessage("Hello") };
});
const ctx = makeCtx(makeFakeSession(runEphemeralTurn));
const controller = new BtwController(ctx);
await controller.start("Hi?");
await Promise.resolve();
await Promise.resolve();
// Use the captured deltas to verify the callback is wired through.
const callArg = runEphemeralTurn.mock.calls[0]?.[0];
expect(callArg).toBeDefined();
callArg?.onTextDelta?.("X");
deltas.push("X");
expect(deltas).toEqual(["X"]);
expect(controller.hasActiveRequest()).toBe(true);
});
it("replaces a previous request by aborting it before issuing the next runEphemeralTurn", async () => {
const signals: AbortSignal[] = [];
let firstRelease!: () => void;
@@ -1,7 +1,15 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import type { TextContent, UserMessage } from "@oh-my-pi/pi-ai";
import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers";
import type { CustomMessage } from "@oh-my-pi/pi-coding-agent/session/messages";
import { Container } from "@oh-my-pi/pi-tui";
beforeAll(() => {
initTheme();
});
function createUserMessage(text: string): UserMessage {
return {
@@ -111,3 +119,88 @@ describe("EventController message_start (user role)", () => {
expect(ctx.optimisticUserMessageSignature).toBeUndefined();
});
});
function createIrcMessage(timestamp: number): CustomMessage<{ from: string; message: string }> {
return {
role: "custom",
customType: "irc:incoming",
content: "Ready",
display: true,
details: { from: "0-Main", message: "Ready" },
timestamp,
};
}
function createIrcContext() {
const chatContainer = new Container();
const requestRender = vi.fn();
const ctx = {
isInitialized: true,
statusLine: { invalidate: vi.fn() },
updateEditorTopBorder: vi.fn(),
ui: { requestRender },
chatContainer,
session: {},
} as unknown as InteractiveModeContext;
const helpers = new UiHelpers(ctx);
const addMessageToChat: InteractiveModeContext["addMessageToChat"] = vi.fn((message, options) =>
helpers.addMessageToChat(message, options),
);
ctx.addMessageToChat = addMessageToChat;
return { ctx, chatContainer, requestRender, addMessageToChat };
}
describe("EventController IRC expiry", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("renders IRC messages immediately and removes their components after the TTL", async () => {
vi.useFakeTimers();
const message = createIrcMessage(1);
const { ctx, chatContainer, requestRender } = createIrcContext();
const controller = new EventController(ctx);
await controller.handleEvent({ type: "irc_message", message });
expect(chatContainer.children).toHaveLength(2);
expect(requestRender).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(9_999);
expect(chatContainer.children).toHaveLength(2);
vi.advanceTimersByTime(1);
expect(chatContainer.children).toHaveLength(0);
expect(requestRender).toHaveBeenCalledTimes(2);
});
it("does not schedule duplicate expiry for duplicate IRC events", async () => {
vi.useFakeTimers();
const message = createIrcMessage(2);
const { ctx, chatContainer, addMessageToChat } = createIrcContext();
const controller = new EventController(ctx);
await controller.handleEvent({ type: "irc_message", message });
await controller.handleEvent({ type: "irc_message", message });
expect(addMessageToChat).toHaveBeenCalledTimes(1);
expect(chatContainer.children).toHaveLength(2);
vi.advanceTimersByTime(10_000);
expect(chatContainer.children).toHaveLength(0);
});
it("clears pending IRC expiry timers on dispose", async () => {
vi.useFakeTimers();
const message = createIrcMessage(3);
const { ctx, chatContainer, requestRender } = createIrcContext();
const controller = new EventController(ctx);
await controller.handleEvent({ type: "irc_message", message });
controller.dispose();
vi.advanceTimersByTime(10_000);
expect(chatContainer.children).toHaveLength(2);
expect(requestRender).toHaveBeenCalledTimes(1);
});
});
@@ -1,38 +0,0 @@
import { afterEach, describe, expect, it } from "bun:test";
import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { renderOutputBlock } from "@oh-my-pi/pi-coding-agent/tui/output-block";
import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui";
type MutableTerminalInfo = {
imageProtocol: ImageProtocol | null;
};
const terminal = TERMINAL as unknown as MutableTerminalInfo;
describe("renderOutputBlock", () => {
const originalProtocol = TERMINAL.imageProtocol;
afterEach(() => {
terminal.imageProtocol = originalProtocol;
});
it("passes SIXEL lines through without trimming or padding", async () => {
terminal.imageProtocol = ImageProtocol.Sixel;
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const sixel = "\x1bPqabc\x1b\\";
const lines = renderOutputBlock(
{
width: 40,
sections: [{ label: "Output", lines: ["regular line", sixel] }],
},
uiTheme,
);
expect(lines.filter(line => line === sixel)).toHaveLength(1);
const regularLine = lines.find(line => line.includes("regular line"));
expect(regularLine).toBeDefined();
expect(regularLine).not.toBe("regular line");
});
});
@@ -1,14 +0,0 @@
import { describe, expect, it } from "bun:test";
import { prompt } from "@oh-my-pi/pi-utils";
import planModeApprovedPrompt from "../../src/prompts/system/plan-mode-approved.md" with { type: "text" };
describe("plan-mode-approved prompt", () => {
it("includes final plan artifact path in injected execution prompt", () => {
const rendered = prompt.render(planModeApprovedPrompt, {
planContent: "1. Do work",
finalPlanFilePath: "local://WP_MIGRATION_PLAN.md",
});
expect(rendered).toContain("local://WP_MIGRATION_PLAN.md");
});
});
@@ -9,12 +9,6 @@ const TEST_CONFIG: CliConfig = {
};
describe("Plugin command scope parsing", () => {
it("accepts project scope", async () => {
const command = new Plugin(["install", "--scope", "project"], TEST_CONFIG);
const { flags } = await command.parse(Plugin);
expect(flags.scope).toBe("project");
});
it("rejects invalid scope values", async () => {
const command = new Plugin(["install", "--scope", "porject"], TEST_CONFIG);
await expect(command.parse(Plugin)).rejects.toThrow(/Expected --scope to be one of: user, project/);
@@ -18,14 +18,6 @@ import { parseCommandArgs, substituteArgs } from "@oh-my-pi/pi-coding-agent/util
// ============================================================================
describe("substituteArgs", () => {
test("should replace $ARGUMENTS with all args joined", () => {
expect(substituteArgs("Test: $ARGUMENTS", ["a", "b", "c"])).toBe("Test: a b c");
});
test("should replace $@ with all args joined", () => {
expect(substituteArgs("Test: $@", ["a", "b", "c"])).toBe("Test: a b c");
});
test("should support $@ slicing with start offset", () => {
expect(substituteArgs("Test: $@[2]", ["a", "b", "c"])).toBe("Test: b c");
});
@@ -66,18 +58,6 @@ describe("substituteArgs", () => {
expect(substituteArgs("$1: $@", ["prefix", "a", "b"])).toBe("prefix: prefix a b");
});
test("should handle empty arguments array with $ARGUMENTS", () => {
expect(substituteArgs("Test: $ARGUMENTS", [])).toBe("Test: ");
});
test("should handle empty arguments array with $@", () => {
expect(substituteArgs("Test: $@", [])).toBe("Test: ");
});
test("should handle empty arguments array with $1", () => {
expect(substituteArgs("Test: $1", [])).toBe("Test: ");
});
test("should handle multiple occurrences of $ARGUMENTS", () => {
expect(substituteArgs("$ARGUMENTS and $ARGUMENTS", ["a", "b"])).toBe("a b and a b");
});
@@ -116,14 +96,6 @@ describe("substituteArgs", () => {
expect(substituteArgs("$ARGUMENTS", ["first arg", "second arg"])).toBe("first arg second arg");
});
test("should handle single argument with $ARGUMENTS", () => {
expect(substituteArgs("Test: $ARGUMENTS", ["only"])).toBe("Test: only");
});
test("should handle single argument with $@", () => {
expect(substituteArgs("Test: $@", ["only"])).toBe("Test: only");
});
test("should handle $0 (zero index)", () => {
expect(substituteArgs("$0", ["a", "b"])).toBe("");
});
@@ -140,49 +112,16 @@ describe("substituteArgs", () => {
expect(substituteArgs("pre$@", ["a", "b"])).toBe("prea b");
});
test("should handle empty arguments in middle of list", () => {
expect(substituteArgs("$ARGUMENTS", ["a", "", "c"])).toBe("a c");
});
test("should handle trailing and leading spaces in arguments", () => {
expect(substituteArgs("$ARGUMENTS", [" leading ", "trailing "])).toBe(" leading trailing ");
});
test("should handle argument containing pattern partially", () => {
expect(substituteArgs("Prefix $ARGUMENTS suffix", ["ARGUMENTS"])).toBe("Prefix ARGUMENTS suffix");
});
test("should handle non-matching patterns", () => {
expect(substituteArgs("$A $$ $ $ARGS", ["a"])).toBe("$A $$ $ $ARGS");
});
test("should handle case variations (case-sensitive)", () => {
expect(substituteArgs("$arguments $Arguments $ARGUMENTS", ["a", "b"])).toBe("$arguments $Arguments a b");
});
test("should handle both syntaxes in same command with same result", () => {
const args = ["x", "y", "z"];
const result1 = substituteArgs("$@ and $ARGUMENTS", args);
const result2 = substituteArgs("$ARGUMENTS and $@", args);
expect(result1).toBe(result2);
expect(result1).toBe("x y z and x y z");
});
test("should handle very long argument lists", () => {
const args = Array.from({ length: 100 }, (_, i) => `arg${i}`);
const result = substituteArgs("$ARGUMENTS", args);
expect(result).toBe(args.join(" "));
});
test("should handle numbered placeholders with single digit", () => {
expect(substituteArgs("$1 $2 $3", ["a", "b", "c"])).toBe("a b c");
});
test("should handle numbered placeholders with multiple digits", () => {
const args = Array.from({ length: 15 }, (_, i) => `val${i}`);
expect(substituteArgs("$10 $12 $15", args)).toBe("val9 val11 val14");
});
test("should handle escaped dollar signs (literal backslash preserved)", () => {
// Note: No escape mechanism exists - backslash is treated literally
expect(substituteArgs("Price: \\$100", [])).toBe("Price: \\");
@@ -257,14 +196,6 @@ describe("parseCommandArgs", () => {
// Note: This implementation doesn't handle escaped quotes - backslash is literal
expect(parseCommandArgs('"quoted \\"text\\""')).toEqual(["quoted \\text\\"]);
});
test("should handle trailing spaces", () => {
expect(parseCommandArgs("a b c ")).toEqual(["a", "b", "c"]);
});
test("should handle leading spaces", () => {
expect(parseCommandArgs(" a b c")).toEqual(["a", "b", "c"]);
});
});
// ============================================================================
@@ -1,21 +0,0 @@
import { describe, expect, it } from "bun:test";
describe("review request prompt", () => {
it("renders the additional instructions block from the template", async () => {
const template = await Bun.file(new URL("../../src/prompts/review-request.md", import.meta.url)).text();
expect(template).toContain("{{#if additionalInstructions}}");
expect(template).toContain("### Additional Instructions");
expect(template).toContain("{{additionalInstructions}}");
expect(template).toContain("{{/if}}");
});
it("keeps the additional instructions suffix out of TypeScript", async () => {
const source = await Bun.file(
new URL("../../src/extensibility/custom-commands/bundled/review/index.ts", import.meta.url),
).text();
expect(source).not.toContain("### Additional Instructions");
expect(source).not.toContain("appendInstructions(");
});
});
@@ -1,82 +0,0 @@
import { describe, expect, it } from "bun:test";
import { type PendingExtensionRequest, requestRpcEditor } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-mode";
import type { RpcExtensionUIRequest } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-types";
function isExtensionUiRequest(obj: RpcExtensionUIRequest | object): obj is RpcExtensionUIRequest {
return "type" in obj && obj.type === "extension_ui_request";
}
describe("requestRpcEditor", () => {
it("serializes promptStyle on editor requests", async () => {
const pendingRequests = new Map<string, PendingExtensionRequest>();
const requests: RpcExtensionUIRequest[] = [];
const promise = requestRpcEditor(
pendingRequests,
obj => {
if (isExtensionUiRequest(obj)) {
requests.push(obj);
}
},
"Enter your response:",
"draft",
undefined,
{ promptStyle: true },
);
expect(requests).toHaveLength(1);
const request = requests[0];
if (!request || request.method !== "editor") {
throw new Error("Expected an editor request");
}
expect(request.promptStyle).toBe(true);
expect(request.prefill).toBe("draft");
const pending = pendingRequests.get(request.id);
if (!pending) {
throw new Error("Expected a pending request");
}
pending.resolve({ type: "extension_ui_response", id: request.id, value: "custom response" });
await expect(promise).resolves.toBe("custom response");
expect(pendingRequests.size).toBe(0);
});
it("resolves editor requests on abort and clears pending state", async () => {
const pendingRequests = new Map<string, PendingExtensionRequest>();
const requests: RpcExtensionUIRequest[] = [];
const controller = new AbortController();
const promise = requestRpcEditor(
pendingRequests,
obj => {
if (isExtensionUiRequest(obj)) {
requests.push(obj);
}
},
"Enter your response:",
undefined,
{ signal: controller.signal },
{ promptStyle: true },
);
expect(requests).toHaveLength(1);
const request = requests[0];
if (!request || request.method !== "editor") {
throw new Error("Expected an editor request");
}
expect(request.promptStyle).toBe(true);
expect(pendingRequests.has(request.id)).toBe(true);
controller.abort();
expect(requests).toHaveLength(2);
const cancelRequest = requests[1];
if (!cancelRequest || cancelRequest.method !== "cancel") {
throw new Error("Expected a cancel request");
}
expect(cancelRequest.targetId).toBe(request.id);
await expect(promise).resolves.toBeUndefined();
expect(pendingRequests.has(request.id)).toBe(false);
});
});
@@ -7,12 +7,6 @@ import { SecretObfuscator } from "../src/secrets/obfuscator";
import { compileSecretRegex } from "../src/secrets/regex";
describe("compileSecretRegex", () => {
it("compiles pattern with explicit flags and enforces global scanning", () => {
const regex = compileSecretRegex("api[_-]?key\\s*=\\s*\\w+", "gi");
expect(regex.source).toBe("api[_-]?key\\s*=\\s*\\w+");
expect(regex.flags).toBe("gi");
});
it("adds global flag when not provided", () => {
const regex = compileSecretRegex("api[_-]?key\\s*=\\s*\\w+", "i");
expect(regex.source).toBe("api[_-]?key\\s*=\\s*\\w+");
@@ -1,19 +0,0 @@
import { describe, expect, it } from "bun:test";
import { getSessionAccentHex } from "../src/utils/session-color";
import { formatSessionTerminalTitle } from "../src/utils/title-generator";
describe("getSessionAccentHex", () => {
it("returns a stable hex for the same name", () => {
expect(getSessionAccentHex("Named session")).toBe(getSessionAccentHex("Named session"));
});
});
describe("formatSessionTerminalTitle", () => {
it("uses the session name when present", () => {
expect(formatSessionTerminalTitle("Manual title", "/work/pi")).toBe("π: Manual title");
});
it("falls back to the cwd basename when the session name is missing", () => {
expect(formatSessionTerminalTitle(undefined, "/work/pi")).toBe("π: pi");
});
});
@@ -24,29 +24,6 @@ describe("loadEntriesFromFile", () => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("returns empty array for non-existent file", async () => {
const entries = await loadEntriesFromFile(path.join(tempDir, "nonexistent.jsonl"));
expect(entries).toEqual([]);
});
it("returns empty array for empty file", async () => {
const file = path.join(tempDir, "empty.jsonl");
fs.writeFileSync(file, "");
expect(await loadEntriesFromFile(file)).toEqual([]);
});
it("returns empty array for file without valid session header", async () => {
const file = path.join(tempDir, "no-header.jsonl");
fs.writeFileSync(file, '{"type":"message","id":"1"}\n');
expect(await loadEntriesFromFile(file)).toEqual([]);
});
it("returns empty array for malformed JSON", async () => {
const file = path.join(tempDir, "malformed.jsonl");
fs.writeFileSync(file, "not json\n");
expect(await loadEntriesFromFile(file)).toEqual([]);
});
it("loads valid session file", async () => {
const file = path.join(tempDir, "valid.jsonl");
fs.writeFileSync(
@@ -85,25 +62,6 @@ describe("findMostRecentSession", () => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("returns null for empty directory", async () => {
expect(await findMostRecentSession(tempDir)).toBeNull();
});
it("returns null for non-existent directory", async () => {
expect(await findMostRecentSession(path.join(tempDir, "nonexistent"))).toBeNull();
});
it("ignores non-jsonl files", async () => {
fs.writeFileSync(path.join(tempDir, "file.txt"), "hello");
fs.writeFileSync(path.join(tempDir, "file.json"), "{}");
expect(await findMostRecentSession(tempDir)).toBeNull();
});
it("ignores jsonl files without valid session header", async () => {
fs.writeFileSync(path.join(tempDir, "invalid.jsonl"), '{"type":"message"}\n');
expect(await findMostRecentSession(tempDir)).toBeNull();
});
it("returns single valid session file", async () => {
const file = path.join(tempDir, "session.jsonl");
fs.writeFileSync(file, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
@@ -1,35 +0,0 @@
import { describe, expect, it } from "bun:test";
import { getProviderDetails, type Model } from "@oh-my-pi/pi-ai";
import { renderProviderSection } from "@oh-my-pi/pi-coding-agent/modes/controllers/command-controller";
describe("session provider section", () => {
it("renders codex provider details with transport fields", () => {
const model: Model<"openai-codex-responses"> = {
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
preferWebsockets: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 128000,
};
const details = getProviderDetails({
model,
sessionId: "session-1",
authMode: "oauth",
});
const output = renderProviderSection(details, { fg: (_color: string, text: string) => text });
expect(output).toContain("Name:");
expect(output).toContain("openai-codex");
expect(output).toContain("Transport:");
expect(output).toContain("WebSocket:");
expect(output).toContain("Reuse:");
expect(output).toContain("Auth:");
});
});
@@ -95,17 +95,4 @@ describe("/force slash command", () => {
expect(harness.showStatus).not.toHaveBeenCalled();
expect(harness.setText).toHaveBeenCalledWith("");
});
it("does not pass through prompt when tool validation fails", async () => {
const harness = createRuntimeHarness({
setForcedToolChoice: () => {
throw new Error('Tool "write" is not currently active.');
},
});
const result = await executeBuiltinSlashCommand("/force:write fix stuff", harness.runtime);
expect(result).toBe(true);
expect(harness.showError).toHaveBeenCalledWith('Tool "write" is not currently active.');
});
});
@@ -1,14 +1,15 @@
import { describe, expect, it } from "bun:test";
import { buildRemoteCommand } from "../../src/ssh/connection-manager";
import { buildRemoteCommand, supportsSshControlMaster } from "../../src/ssh/connection-manager";
describe("buildRemoteCommand", () => {
it("includes -n to bind stdin to /dev/null for mux channel opens", async () => {
it("includes -n and OpenSSH ControlMaster options on Unix-like platforms", async () => {
const args = await buildRemoteCommand(
{
name: "host",
host: "192.168.3.146",
},
"ls -la",
{ platform: "linux" },
);
expect(args[0]).toBe("-n");
@@ -16,4 +17,34 @@ describe("buildRemoteCommand", () => {
expect(args.at(-2)).toBe("192.168.3.146");
expect(args.at(-1)).toBe("ls -la");
});
it("omits OpenSSH ControlMaster options on Windows", async () => {
const args = await buildRemoteCommand(
{
name: "host",
host: "192.168.3.146",
},
"ls -la",
{ platform: "win32" },
);
expect(args[0]).toBe("-n");
expect(args).not.toContain("ControlMaster=auto");
expect(args.some(arg => arg.startsWith("ControlPath="))).toBe(false);
expect(args).not.toContain("ControlPersist=3600");
expect(args).toContain("BatchMode=yes");
expect(args.at(-2)).toBe("192.168.3.146");
expect(args.at(-1)).toBe("ls -la");
});
});
describe("supportsSshControlMaster", () => {
it("disables OpenSSH connection multiplexing on native Windows", () => {
expect(supportsSshControlMaster("win32")).toBe(false);
});
it("keeps OpenSSH connection multiplexing on Unix-like platforms", () => {
expect(supportsSshControlMaster("linux")).toBe(true);
expect(supportsSshControlMaster("darwin")).toBe(true);
});
});
@@ -32,14 +32,6 @@ describe("parseGitHubRepo", () => {
expect(parseGitHubRepo("https://gitlab.com/user/repo.git")).toBeNull();
});
test("returns null for empty string", () => {
expect(parseGitHubRepo("")).toBeNull();
});
test("returns null for malformed URL", () => {
expect(parseGitHubRepo("not-a-url")).toBeNull();
});
test("handles GitHub Enterprise-style URLs (no match)", () => {
expect(parseGitHubRepo("https://github.corp.com/org/repo.git")).toBeNull();
});
@@ -3,9 +3,6 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_COLUMN,
DEFAULT_MAX_LINES,
formatHeadTruncationNotice,
formatTailTruncationNotice,
OutputSink,
@@ -41,14 +38,6 @@ afterEach(async () => {
else Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH = originalAllowPassthrough;
});
describe("streaming-output exports", () => {
test("exports expected default limits", () => {
expect(DEFAULT_MAX_LINES).toBe(3000);
expect(DEFAULT_MAX_BYTES).toBe(50 * 1024);
expect(DEFAULT_MAX_COLUMN).toBe(1024);
});
});
describe("truncateTailBytes", () => {
test("returns source when already under limit", () => {
const text = "hello";
@@ -226,7 +226,7 @@ describe("system Handlebars prompt templates", () => {
expect(rendered).toContain("call `search_tool_bm25` before concluding no such tool exists");
});
test("buildSystemPrompt keeps system project and now as separate ordered blocks", async () => {
test("buildSystemPrompt keeps system and project as separate ordered blocks with date context in project", async () => {
await withTempDir(async dir => {
const { systemPrompt } = await buildSystemPrompt({
cwd: dir,
@@ -243,14 +243,14 @@ describe("system Handlebars prompt templates", () => {
},
});
expect(systemPrompt).toHaveLength(3);
expect(systemPrompt).toHaveLength(2);
expect(systemPrompt[0]).toContain("[CONTRACT]");
expect(systemPrompt[0]).not.toContain("current working directory");
expect(systemPrompt[1]).toContain("<workstation>");
expect(systemPrompt[1]).toContain("<workspace-tree>");
expect(systemPrompt[1]).not.toContain("current working directory");
expect(systemPrompt[2]).toContain("Today is ");
expect(systemPrompt[2]).toContain(`current working directory is '${dir}'.`);
expect(systemPrompt[1]).toContain("Today is ");
expect(systemPrompt[1]).toContain(`current working directory is '${dir}'.`);
expect(systemPrompt[1].indexOf("</workspace-tree>")).toBeLessThan(systemPrompt[1].indexOf("Today is "));
});
});
test("buildSystemPrompt renders workspace tree after directory context in project prompt", async () => {
@@ -1,87 +0,0 @@
import { describe, expect, it } from "bun:test";
import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { taskToolRenderer } from "../../src/task/render";
import type { TaskToolDetails } from "../../src/task/types";
describe("taskToolRenderer report_finding safety", () => {
it("renders progress without crashing when report_finding payload is malformed", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const details: TaskToolDetails = {
projectAgentsDir: null,
results: [],
totalDurationMs: 42,
progress: [
{
index: 0,
id: "1-Reviewer",
agent: "reviewer",
agentSource: "bundled",
status: "running",
task: "Review patch",
recentTools: [],
recentOutput: [],
toolCount: 1,
tokens: 0,
durationMs: 42,
extractedToolData: {
report_finding: [{}],
},
},
],
};
const rendered = taskToolRenderer.renderResult(
{
content: [{ type: "text", text: "" }],
details,
},
{ expanded: false, isPartial: true },
uiTheme,
);
expect(() => rendered.render(120)).not.toThrow();
});
it("renders abort reason inline for aborted subagent results", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const details: TaskToolDetails = {
projectAgentsDir: null,
results: [
{
index: 0,
id: "1-Reviewer",
agent: "reviewer",
agentSource: "bundled",
task: "Review patch",
exitCode: 1,
output: "",
stderr: "",
truncated: false,
durationMs: 42,
tokens: 0,
aborted: true,
abortReason: "blocked by permissions",
},
],
totalDurationMs: 42,
};
const rendered = taskToolRenderer.renderResult(
{
content: [{ type: "text", text: "" }],
details,
},
{ expanded: false, isPartial: false },
uiTheme,
);
const lines = rendered.render(120);
expect(lines.join("\n")).toContain("blocked by permissions");
});
});
@@ -6,49 +6,6 @@ const forced = { type: "tool", name: "write" } as const;
const forcedRead = { type: "tool", name: "read" } as const;
describe("ToolChoiceQueue", () => {
it("returns undefined when empty", () => {
const q = new ToolChoiceQueue();
expect(q.nextToolChoice()).toBeUndefined();
});
it("pushOnce yields once then exhausts", () => {
const q = new ToolChoiceQueue();
q.pushOnce(forced, { label: "a" });
expect(q.nextToolChoice()).toEqual(forced);
q.resolve();
expect(q.nextToolChoice()).toBeUndefined();
});
it("pushSequence yields in order then exhausts", () => {
const q = new ToolChoiceQueue();
q.pushSequence([forced, "none"], { label: "seq" });
expect(q.nextToolChoice()).toEqual(forced);
q.resolve();
expect(q.nextToolChoice()).toBe("none");
q.resolve();
expect(q.nextToolChoice()).toBeUndefined();
});
it("now:true prepends to head", () => {
const q = new ToolChoiceQueue();
q.pushOnce(forced, { label: "first" });
q.pushOnce(forcedRead, { label: "urgent", now: true });
expect(q.nextToolChoice()).toEqual(forcedRead);
q.resolve();
expect(q.nextToolChoice()).toEqual(forced);
});
it("multiple directives drain in FIFO order", () => {
const q = new ToolChoiceQueue();
q.pushOnce(forced, { label: "a" });
q.pushOnce(forcedRead, { label: "b" });
expect(q.nextToolChoice()).toEqual(forced);
q.resolve();
expect(q.nextToolChoice()).toEqual(forcedRead);
q.resolve();
expect(q.nextToolChoice()).toBeUndefined();
});
describe("resolve callback", () => {
it("fires onResolved with the served choice", () => {
const q = new ToolChoiceQueue();
@@ -61,11 +18,6 @@ describe("ToolChoiceQueue", () => {
q.resolve();
expect(resolved).toEqual([{ choice: forced }]);
});
it("does not fire onResolved when queue is empty", () => {
const q = new ToolChoiceQueue();
q.resolve(); // no-op, nothing in-flight
});
});
describe("reject callback", () => {
@@ -102,39 +54,6 @@ describe("ToolChoiceQueue", () => {
expect(q.nextToolChoice()).toBeUndefined();
});
it("default (no callback) drops the yield", () => {
const q = new ToolChoiceQueue();
q.pushOnce(forced, { label: "a" });
expect(q.nextToolChoice()).toEqual(forced);
q.reject("aborted");
expect(q.nextToolChoice()).toBeUndefined();
});
it("reject is a no-op when nothing is in-flight", () => {
const q = new ToolChoiceQueue();
q.pushOnce(forced, {
label: "a",
onRejected: () => "requeue",
});
q.reject("aborted"); // no-op, nothing yielded yet
expect(q.nextToolChoice()).toEqual(forced);
});
it("passes the correct reason to onRejected", () => {
const q = new ToolChoiceQueue();
const reasons: string[] = [];
q.pushOnce(forced, {
label: "a",
onRejected: info => {
reasons.push(info.reason);
return "drop";
},
});
q.nextToolChoice();
q.reject("error");
expect(reasons).toEqual(["error"]);
});
it("requeued directive preserves onRejected so it can re-requeue across aborts", () => {
const q = new ToolChoiceQueue();
let rejectCount = 0;
@@ -60,14 +60,6 @@ async function getToolMetadata(): Promise<Map<string, { loadMode?: string; summa
return metadata;
}
describe("BUILTIN_TOOLS public factory map", () => {
it("exposes callable tool factories (back-compat for external SDK callers)", () => {
// External callers may invoke BUILTIN_TOOLS.read(session) directly. Verify the value
// is a function, not a metadata object wrapping a factory.
expect(typeof BUILTIN_TOOLS.read).toBe("function");
expect(typeof BUILTIN_TOOLS.bash).toBe("function");
expect(typeof BUILTIN_TOOLS.edit).toBe("function");
});
it("sets loading fields on tool definitions without wrapping factories", async () => {
const metadata = await getToolMetadata();
const missing = Object.keys(BUILTIN_TOOLS).filter(name => metadata.get(name)?.loadMode === undefined);
@@ -76,48 +68,6 @@ describe("BUILTIN_TOOLS public factory map", () => {
});
describe("built-in tool loadMode annotations", () => {
it("marks read, bash, edit, and search_tool_bm25 as essential", async () => {
const metadata = await getToolMetadata();
expect(metadata.get("read")?.loadMode).toBe("essential");
expect(metadata.get("bash")?.loadMode).toBe("essential");
expect(metadata.get("edit")?.loadMode).toBe("essential");
expect(metadata.get("search_tool_bm25")?.loadMode).toBe("essential");
});
it("marks non-essential tools as discoverable", async () => {
const discoverableExpected = [
"ast_grep",
"ast_edit",
"render_mermaid",
"ask",
"debug",
"eval",
"calc",
"ssh",
"github",
"find",
"search",
"lsp",
"inspect_image",
"browser",
"checkpoint",
"rewind",
"task",
"job",
"recipe",
"irc",
"todo_write",
"web_search",
"write",
"retain",
"recall",
"reflect",
];
const metadata = await getToolMetadata();
const missing = discoverableExpected.filter(name => metadata.get(name)?.loadMode !== "discoverable");
expect(missing).toEqual([]);
});
it("provides a summary for every discoverable tool", async () => {
const missing: string[] = [];
const metadata = await getToolMetadata();
@@ -130,14 +80,6 @@ describe("built-in tool loadMode annotations", () => {
});
});
describe("DEFAULT_ESSENTIAL_TOOL_NAMES", () => {
it("contains the expected defaults", () => {
expect(DEFAULT_ESSENTIAL_TOOL_NAMES).toContain("read");
expect(DEFAULT_ESSENTIAL_TOOL_NAMES).toContain("bash");
expect(DEFAULT_ESSENTIAL_TOOL_NAMES).toContain("edit");
});
});
describe("computeEssentialBuiltinNames", () => {
it("returns DEFAULT_ESSENTIAL_TOOL_NAMES when override is empty", () => {
const settings = Settings.isolated({});
@@ -174,26 +116,6 @@ describe("computeEssentialBuiltinNames", () => {
});
describe("tools.discoveryMode settings schema", () => {
it("defaults to off", () => {
const settings = Settings.isolated({});
expect(settings.get("tools.discoveryMode")).toBe("off");
});
it("accepts mcp-only", () => {
const settings = Settings.isolated({ "tools.discoveryMode": "mcp-only" });
expect(settings.get("tools.discoveryMode")).toBe("mcp-only");
});
it("accepts all", () => {
const settings = Settings.isolated({ "tools.discoveryMode": "all" });
expect(settings.get("tools.discoveryMode")).toBe("all");
});
it("tools.essentialOverride defaults to empty array", () => {
const settings = Settings.isolated({});
expect(settings.get("tools.essentialOverride")).toEqual([]);
});
it("back-compat: mcp.discoveryMode still accepted", () => {
const settings = Settings.isolated({ "mcp.discoveryMode": true });
expect(settings.get("mcp.discoveryMode")).toBe(true);
@@ -26,11 +26,6 @@ describe("persistence back-compat: buildDiscoverableMCPSearchIndex wraps generic
},
];
it("returns correct document count", () => {
const index = buildDiscoverableMCPSearchIndex(legacyMCPTools);
expect(index.documents).toHaveLength(2);
});
it("maps description → summary in the index", () => {
const index = buildDiscoverableMCPSearchIndex(legacyMCPTools);
// The documents contain DiscoverableTool objects with .summary, not .description
@@ -6,45 +6,6 @@ import { Settings } from "../../src/config/settings";
// without needing to spin up a full AgentSession or subagent.
// ─────────────────────────────────────────────────────────────────────────────
describe("tools.discoveryMode subagent inheritance via settings", () => {
it("'off' propagates to child as 'off'", () => {
const parentSettings = Settings.isolated({ "tools.discoveryMode": "off" });
// Subagent inherits the same settings object (in task/executor.ts, subagentSettings is
// derived from parent settings). The setting value should be preserved.
const child = Settings.isolated({ "tools.discoveryMode": parentSettings.get("tools.discoveryMode") });
expect(child.get("tools.discoveryMode")).toBe("off");
});
it("'mcp-only' propagates to child as 'mcp-only'", () => {
const parentSettings = Settings.isolated({ "tools.discoveryMode": "mcp-only" });
const child = Settings.isolated({ "tools.discoveryMode": parentSettings.get("tools.discoveryMode") });
expect(child.get("tools.discoveryMode")).toBe("mcp-only");
});
it("'all' propagates to child as 'all'", () => {
const parentSettings = Settings.isolated({ "tools.discoveryMode": "all" });
const child = Settings.isolated({ "tools.discoveryMode": parentSettings.get("tools.discoveryMode") });
expect(child.get("tools.discoveryMode")).toBe("all");
});
it("mcp.discoveryMode=true propagates to child as back-compat", () => {
const parentSettings = Settings.isolated({ "mcp.discoveryMode": true });
const child = Settings.isolated({ "mcp.discoveryMode": parentSettings.get("mcp.discoveryMode") });
expect(child.get("mcp.discoveryMode")).toBe(true);
});
it("explicit toolNames override discovery — child with toolNames=['read'] ignores discovery mode", () => {
// If a subagent definition specifies explicit tools, those take precedence
// over the discovery mode. This is enforced in task/executor.ts by only
// building the toolNames list from the agent.tools if present.
const toolNames = ["read"];
// When toolNames is explicit, only those tools are used regardless of discovery mode
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("find");
expect(toolNames).not.toContain("search");
});
});
describe("effective discovery mode resolution", () => {
function resolveEffectiveMode(settings: Settings): "off" | "mcp-only" | "all" {
const toolsMode = settings.get("tools.discoveryMode");
+6 -6
View File
@@ -260,6 +260,7 @@ describe("Coding Agent Tools", () => {
} else {
Bun.env.PI_EDIT_VARIANT = originalEditVariant;
}
AsyncJobManager.resetForTests();
});
describe("read tool", () => {
@@ -1105,6 +1106,7 @@ function b() {
deliveries.push(text);
},
});
AsyncJobManager.setInstance(asyncJobManager);
const autoBackgroundBashTool = wrapToolWithMetaNotice(
new BashTool(
createTestToolSession(
@@ -1114,7 +1116,6 @@ function b() {
"bash.autoBackground.thresholdMs": 50,
}),
{
asyncJobManager,
getSessionId: () => "test-session",
},
),
@@ -1138,6 +1139,7 @@ function b() {
deliveries.push({ jobId, text });
},
});
AsyncJobManager.setInstance(asyncJobManager);
const autoBackgroundBashTool = wrapToolWithMetaNotice(
new BashTool(
createTestToolSession(
@@ -1147,7 +1149,6 @@ function b() {
"bash.autoBackground.thresholdMs": 50,
}),
{
asyncJobManager,
getSessionId: () => "test-session",
},
),
@@ -1184,6 +1185,7 @@ function b() {
deliveries.push({ jobId, text });
},
});
AsyncJobManager.setInstance(asyncJobManager);
const autoBackgroundBashTool = wrapToolWithMetaNotice(
new BashTool(
createTestToolSession(
@@ -1193,7 +1195,6 @@ function b() {
"bash.autoBackground.thresholdMs": 60_000,
}),
{
asyncJobManager,
getSessionId: () => "test-session",
},
),
@@ -1274,9 +1275,8 @@ function b() {
const manager = new AsyncJobManager({
onJobComplete: async () => {},
});
const session = createTestToolSession(testDir, Settings.isolated({ "bash.autoBackground.enabled": true }), {
asyncJobManager: manager,
});
const session = createTestToolSession(testDir, Settings.isolated({ "bash.autoBackground.enabled": true }), {});
AsyncJobManager.setInstance(manager);
const jobTool = JobTool.createIf(session)!;
const jobId = manager.register("bash", "test job", async () => "success");
@@ -92,6 +92,36 @@ describe("AskTool cancellation", () => {
expect(abort).toHaveBeenCalledTimes(1);
});
it("defaults to no timeout when ask.timeout is unset", async () => {
// Regression for the surprise-auto-select report: a fresh install must let the user
// deliberate indefinitely. The dialog timeout is opt-in via the `ask.timeout` setting.
const tool = new AskTool(createSession());
const select = vi.fn(
async (_prompt: string, options: string[], _dialogOptions?: { initialIndex?: number; timeout?: number }) =>
options[0],
);
const context = createContext({ select });
await tool.execute(
"call-default-no-timeout",
{
questions: [
{
id: "confirm",
question: "Proceed?",
options: [{ label: "yes" }, { label: "no" }],
},
],
},
undefined,
undefined,
context,
);
expect(select).toHaveBeenCalledTimes(1);
expect(select.mock.calls[0]?.[2]?.timeout).toBeUndefined();
});
it("still aborts when user explicitly cancels with timeout configured", async () => {
const tool = new AskTool(
createSession({
@@ -80,12 +80,6 @@ describe("assertEditableFileContent", () => {
});
describe("assertEditableFile", () => {
it("detects auto-generated filename patterns", async () => {
const filePath = path.join(tempDir, "zz_generated.deepcopy.go");
await Bun.write(filePath, "package generated");
await expect(assertEditableFile(filePath)).rejects.toBeInstanceOf(ToolError);
});
it("detects content marker from file prefix", async () => {
const filePath = path.join(tempDir, "service.ts");
await Bun.write(filePath, "// Code generated by sqlc. DO NOT EDIT.\nexport const foo = 1;");
@@ -1,6 +1,10 @@
import { describe, expect, it } from "bun:test";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { editToolRenderer } from "@oh-my-pi/pi-coding-agent/edit/renderer";
import { HL_EDIT_SEP } from "@oh-my-pi/pi-coding-agent/hashline/hash";
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { TUI } from "@oh-my-pi/pi-tui";
async function getUiTheme() {
await themeModule.initTheme(false, undefined, undefined, "dark", "light");
@@ -40,6 +44,32 @@ describe("editToolRenderer", () => {
expect(rendered).not.toContain("The first line of the patch must be");
});
it("shows hashline envelope input while preview diff is not computable yet", async () => {
await getUiTheme();
const uiStub = { requestRender() {} } as unknown as TUI;
const hashlineTool = { name: "edit", label: "Edit", mode: "hashline" } as unknown as AgentTool;
const component = new ToolExecutionComponent(
"edit",
{
input: [
"*** Begin Patch",
"@crates/pi-natives/src/shell.rs",
"+ EOF",
`${HL_EDIT_SEP}pub fn streaming_preview() {`,
].join("\n"),
},
{},
hashlineTool,
uiStub,
);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("crates/pi-natives/src/shell.rs");
expect(rendered).toContain("+ EOF");
expect(rendered).toContain(`${HL_EDIT_SEP}pub fn streaming_preview() {`);
expect(rendered).not.toContain("*** Begin Patch");
});
it("recognizes compact and quoted hashline input headers", async () => {
const uiTheme = await getUiTheme();
const compactComponent = editToolRenderer.renderCall(
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import * as evalIndex from "@oh-my-pi/pi-coding-agent/eval";
import * as pyKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { EvalTool } from "@oh-my-pi/pi-coding-agent/tools/eval";
function makeSession(): ToolSession {
return {
cwd: "/tmp/eval-test",
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => null,
settings: Settings.isolated(),
};
}
function baseResult(overrides: Record<string, unknown> = {}) {
return {
output: "",
exitCode: 0,
cancelled: false,
truncated: false,
artifactId: undefined,
totalLines: 0,
totalBytes: 0,
outputLines: 0,
outputBytes: 0,
displayOutputs: [] as unknown[],
...overrides,
};
}
describe("EvalTool display() text surfacing", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("includes display() JSON values in the text content the model sees", async () => {
vi.spyOn(pyKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
vi.spyOn(evalIndex.jsBackend, "execute").mockResolvedValue(
baseResult({
displayOutputs: [{ type: "json", data: { stdout: "hi", exit_code: 0 } }],
}) as never,
);
const tool = new EvalTool(makeSession());
const result = await tool.execute("call-display-json", {
input: "```js\ndisplay({ stdout: 'hi', exit_code: 0 });\n```\n",
});
const text = result.content.map(c => (c.type === "text" ? c.text : "")).join("\n");
expect(text).toContain("display[1]");
expect(text).toContain('"stdout": "hi"');
expect(text).toContain('"exit_code": 0');
expect(text).not.toBe("(no text output)");
});
it("interleaves stdout text and display() JSON values", async () => {
vi.spyOn(pyKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
vi.spyOn(evalIndex.jsBackend, "execute").mockResolvedValue(
baseResult({
output: "before\n",
displayOutputs: [{ type: "json", data: [1, 2, 3] }],
}) as never,
);
const tool = new EvalTool(makeSession());
const result = await tool.execute("call-mixed", {
input: "```js\nprint('before'); display([1,2,3]);\n```\n",
});
const text = result.content.map(c => (c.type === "text" ? c.text : "")).join("\n");
expect(text).toContain("before");
expect(text.indexOf("before")).toBeLessThan(text.indexOf("display[1]"));
expect(text).toContain("[\n 1,\n 2,\n 3\n]");
});
it("surfaces displayed images to the model as ImageContent blocks, not inlined base64", async () => {
vi.spyOn(pyKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const base64 = Buffer.from([0, 1, 2, 3]).toString("base64");
vi.spyOn(evalIndex.jsBackend, "execute").mockResolvedValue(
baseResult({
displayOutputs: [{ type: "image", data: base64, mimeType: "image/png" }],
}) as never,
);
const tool = new EvalTool(makeSession());
const result = await tool.execute("call-image", {
input: "```js\ndisplay({ type: 'image', data: '...', mimeType: 'image/png' });\n```\n",
});
const imageBlocks = result.content.filter(c => c.type === "image");
expect(imageBlocks).toHaveLength(1);
expect(imageBlocks[0]).toMatchObject({ type: "image", data: base64, mimeType: "image/png" });
const textBlocks = result.content.filter(c => c.type === "text");
const text = textBlocks.map(c => (c.type === "text" ? c.text : "")).join("\n");
expect(text).not.toContain(base64); // base64 must not leak into text channel
expect(text).toMatch(/displayed 1 image/);
// Image is in content, so details.images must be empty to avoid double-rendering.
expect(result.details?.images).toBeUndefined();
});
it("still reports (no text output) when nothing was printed or displayed", async () => {
vi.spyOn(pyKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
vi.spyOn(evalIndex.jsBackend, "execute").mockResolvedValue(baseResult() as never);
const tool = new EvalTool(makeSession());
const result = await tool.execute("call-empty", {
input: "```js\nconst x = 1;\n```\n",
});
const text = result.content.map(c => (c.type === "text" ? c.text : "")).join("\n");
expect(text).toContain("(no output)");
});
it("truncates oversized display values rather than blasting the context", async () => {
vi.spyOn(pyKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const huge = "x".repeat(20000);
vi.spyOn(evalIndex.jsBackend, "execute").mockResolvedValue(
baseResult({
displayOutputs: [{ type: "json", data: { payload: huge } }],
}) as never,
);
const tool = new EvalTool(makeSession());
const result = await tool.execute("call-huge", {
input: "```js\ndisplay({ payload: 'x'.repeat(20000) });\n```\n",
});
const text = result.content.map(c => (c.type === "text" ? c.text : "")).join("\n");
expect(text).toContain("chars truncated");
expect(text.length).toBeLessThan(20000);
});
});
@@ -35,12 +35,6 @@ describe("ExitPlanModeTool", () => {
};
}
it("requires title in schema", () => {
const tool = new ExitPlanModeTool(createSession());
const schema = tool.parameters as { required?: string[] };
expect(schema.required).toContain("title");
});
it("normalizes title to .md final plan path", async () => {
const tool = new ExitPlanModeTool(createSession());
const result = await tool.execute("call-1", { title: "WP_MIGRATION_PLAN" });
@@ -1,194 +0,0 @@
import { describe, expect, it } from "bun:test";
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { getThemeByName } from "../../src/modes/theme/theme";
import type { GhToolDetails } from "../../src/tools/gh";
import { githubToolRenderer } from "../../src/tools/gh-renderer";
import { toolRenderers } from "../../src/tools/renderers";
describe("githubToolRenderer", () => {
it("renders a compact ghw-style run summary", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const result: {
content: Array<{ type: string; text?: string }>;
details?: GhToolDetails;
isError?: boolean;
} = {
content: [{ type: "text", text: "llm-visible text stays unchanged" }],
details: {
watch: {
mode: "run",
state: "watching",
repo: "v12-security/v12x",
run: {
id: 23856332053,
workflowName: "CI",
branch: "dev",
jobs: [
{
id: 1,
name: "Workflow Lint",
status: "completed",
conclusion: "success",
durationSeconds: 55,
},
{
id: 2,
name: "Frontend Checks",
status: "in_progress",
durationSeconds: 40,
},
{
id: 3,
name: "Rust Tests",
status: "queued",
durationSeconds: 5,
},
],
},
},
},
};
const component = githubToolRenderer.renderResult(result, { expanded: false, isPartial: true }, uiTheme);
const rendered = sanitizeText(component.render(64).join("\n"));
expect(toolRenderers.github).toBeDefined();
expect(rendered).toContain("watching run #23856332053 on v12-security/v12x");
expect(rendered).toContain("CI dev #23856332053");
expect(rendered).toContain(`${uiTheme.status.success} Workflow Lint`);
expect(rendered).toContain(`${uiTheme.status.enabled} Frontend Checks`);
expect(rendered).toContain(`${uiTheme.status.shadowed} Rust Tests`);
expect(rendered).toContain("55s");
expect(rendered).toContain("40s");
expect(rendered).toContain("5s");
expect(rendered).not.toContain("llm-visible text stays unchanged");
});
it("shows failed log tails without dumping the full log when collapsed", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const result: {
content: Array<{ type: string; text?: string }>;
details?: GhToolDetails;
isError?: boolean;
} = {
content: [{ type: "text", text: "full markdown result" }],
details: {
watch: {
mode: "run",
state: "completed",
repo: "owner/repo",
run: {
id: 77,
workflowName: "CI",
branch: "feature/bugfix",
conclusion: "failure",
jobs: [
{
id: 202,
name: "test",
status: "completed",
conclusion: "failure",
durationSeconds: 360,
},
],
},
failedLogs: [
{
runId: 77,
workflowName: "CI",
jobName: "test",
available: true,
tail: ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"].join("\n"),
},
],
},
},
};
const component = githubToolRenderer.renderResult(result, { expanded: false, isPartial: false }, uiTheme);
const rendered = sanitizeText(component.render(72).join("\n"));
expect(rendered).toContain("failed logs");
expect(rendered).toContain("delta");
expect(rendered).toContain("epsilon");
expect(rendered).toContain("zeta");
expect(rendered).not.toContain("alpha");
expect(rendered).toContain("more log lines");
});
it("renders issue_view as a status header with collapsed body and expand hint", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const bodyLines = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`);
const result = {
content: [
{
type: "text",
text: ["# Issue #903: Bug report", "State: OPEN", "", "## Body", "", ...bodyLines].join("\n"),
},
],
};
const component = githubToolRenderer.renderResult(result, { expanded: false, isPartial: false }, uiTheme, {
op: "issue_view",
issue: "903",
repo: "owner/repo",
});
const rendered = sanitizeText(component.render(80).join("\n"));
expect(rendered).toContain("GitHub Issue");
expect(rendered).toContain("#903");
expect(rendered).toContain("owner/repo");
expect(rendered).toContain("# Issue #903: Bug report");
expect(rendered).toContain("more lines");
expect(rendered).not.toContain("line 30");
});
it("renders issue_view fully when expanded", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const bodyLines = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`);
const result = {
content: [{ type: "text", text: bodyLines.join("\n") }],
};
const component = githubToolRenderer.renderResult(result, { expanded: true, isPartial: false }, uiTheme, {
op: "issue_view",
issue: "https://github.com/owner/repo/issues/903",
});
const rendered = sanitizeText(component.render(80).join("\n"));
expect(rendered).toContain("#903");
expect(rendered).toContain("line 1");
expect(rendered).toContain("line 30");
expect(rendered).not.toContain("more lines");
});
it("truncates each line to the available width to avoid overflow", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const longLine = "x".repeat(500);
const result = { content: [{ type: "text", text: longLine }] };
const component = githubToolRenderer.renderResult(result, { expanded: false, isPartial: false }, uiTheme, {
op: "issue_view",
issue: "1",
});
const lines = component.render(60);
for (const line of lines) {
expect(sanitizeText(line).length).toBeLessThanOrEqual(60);
}
});
});
+107 -1
View File
@@ -6,7 +6,7 @@ import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { GithubTool } from "@oh-my-pi/pi-coding-agent/tools/gh";
import { buildSearchDateQualifier, GithubTool, parseSearchDateBound } from "@oh-my-pi/pi-coding-agent/tools/gh";
import { wrapToolWithMetaNotice } from "@oh-my-pi/pi-coding-agent/tools/output-meta";
import * as git from "@oh-my-pi/pi-coding-agent/utils/git";
import { getAgentDir, setAgentDir } from "@oh-my-pi/pi-utils";
@@ -458,6 +458,112 @@ describe("github tool", () => {
expect(prArgs?.at(-1)).toBe("-label:bug");
});
it("parseSearchDateBound: relative duration walks back from `now` and returns YYYY-MM-DD", () => {
const now = new Date("2026-05-12T15:00:00Z");
expect(parseSearchDateBound("3d", now)).toBe("2026-05-09");
expect(parseSearchDateBound("2w", now)).toBe("2026-04-28");
expect(parseSearchDateBound("12h", now)).toBe("2026-05-12");
expect(parseSearchDateBound("1mo", now)).toBe("2026-04-12");
expect(parseSearchDateBound("1y", now)).toBe("2025-05-12");
});
it("parseSearchDateBound: passes ISO dates through and normalizes ISO datetimes", () => {
expect(parseSearchDateBound("2026-05-01")).toBe("2026-05-01");
expect(parseSearchDateBound("2026-05-01T08:30:00Z")).toBe("2026-05-01T08:30:00.000Z");
});
it("parseSearchDateBound: rejects unparseable input", () => {
expect(() => parseSearchDateBound("yesterday")).toThrow(/invalid date bound/);
expect(() => parseSearchDateBound(" ")).toThrow(/must not be empty/);
});
it("buildSearchDateQualifier: emits >=, <=, or range depending on which bounds are set", () => {
const now = new Date("2026-05-12T00:00:00Z");
expect(buildSearchDateQualifier("created", "3d", undefined, now)).toBe("created:>=2026-05-09");
expect(buildSearchDateQualifier("created", undefined, "2026-05-01", now)).toBe("created:<=2026-05-01");
expect(buildSearchDateQualifier("committer-date", "7d", "1d", now)).toBe("committer-date:2026-05-05..2026-05-11");
expect(buildSearchDateQualifier("created", undefined, undefined)).toBeUndefined();
});
it("search_issues: appends a created:>= qualifier built from `since`", async () => {
const spy = vi.spyOn(git.github, "json").mockResolvedValue([]);
const tool = new GithubTool(createSession());
await tool.execute("search-issues", {
op: "search_issues",
query: "is:open",
repo: "owner/repo",
since: "2026-05-01",
limit: 5,
});
const args = spy.mock.calls[0]?.[1];
expect(args?.at(-1)).toBe("is:open created:>=2026-05-01");
});
it("search_prs: builds a qualifier-only query when `query` is omitted", async () => {
const spy = vi.spyOn(git.github, "json").mockResolvedValue([]);
const tool = new GithubTool(createSession());
await tool.execute("search-prs", {
op: "search_prs",
repo: "owner/repo",
since: "2026-05-01",
until: "2026-05-09",
dateField: "updated",
limit: 5,
});
const args = spy.mock.calls[0]?.[1];
expect(args?.at(-1)).toBe("updated:2026-05-01..2026-05-09");
});
it("search_prs: errors when neither `query` nor a date bound is provided", async () => {
vi.spyOn(git.github, "json").mockResolvedValue([]);
const tool = new GithubTool(createSession());
await expect(tool.execute("search-prs", { op: "search_prs", repo: "owner/repo" })).rejects.toThrow(
/query is required/,
);
});
it("search_commits: forces `committer-date` regardless of `dateField`", async () => {
const spy = vi.spyOn(git.github, "json").mockResolvedValue([]);
const tool = new GithubTool(createSession());
await tool.execute("search-commits", {
op: "search_commits",
query: "refactor",
repo: "owner/repo",
since: "2026-05-01",
dateField: "updated",
limit: 5,
});
const args = spy.mock.calls[0]?.[1];
expect(args?.at(-1)).toBe("refactor committer-date:>=2026-05-01");
});
it("search_repos: maps dateField=updated to the `pushed:` qualifier", async () => {
const spy = vi.spyOn(git.github, "json").mockResolvedValue([]);
const tool = new GithubTool(createSession());
await tool.execute("search-repos", {
op: "search_repos",
query: "language:rust",
since: "2026-05-01",
dateField: "updated",
limit: 1,
});
const args = spy.mock.calls[0]?.[1];
expect(args?.at(-1)).toBe("language:rust pushed:>=2026-05-01");
});
it("search_code: rejects since/until since GitHub code search has no date qualifier", async () => {
const spy = vi.spyOn(git.github, "json").mockResolvedValue([]);
const tool = new GithubTool(createSession());
await expect(tool.execute("search-code", { op: "search_code", query: "foo", since: "3d" })).rejects.toThrow(
/search_code does not support since\/until/,
);
expect(spy).not.toHaveBeenCalled();
});
it("formats code search results with paths, repo, sha, and match fragment", async () => {
vi.spyOn(git.github, "json").mockResolvedValue([
{
@@ -267,84 +267,6 @@ describe("tool schema validation (post-sanitization)", () => {
expect(allViolations).toEqual([]);
});
it("no sanitized schema contains $schema declaration", async () => {
const session = createTestSession();
const tools = await createTools(session);
for (const tool of tools) {
const schema = tool.parameters;
if (!schema) continue;
const sanitized = sanitizeSchemaForGoogle(schema);
const violations = validateSchema(sanitized, tool.name).filter(v => v.key === "$schema");
expect(violations).toEqual([]);
}
});
it("no sanitized schema contains $ref or $defs", async () => {
const session = createTestSession();
const tools = await createTools(session);
for (const tool of tools) {
const schema = tool.parameters;
if (!schema) continue;
const sanitized = sanitizeSchemaForGoogle(schema);
const violations = validateSchema(sanitized, tool.name).filter(v => v.key === "$ref" || v.key === "$defs");
expect(violations).toEqual([]);
}
});
it("no sanitized schema contains Draft 2020-12 specific features", async () => {
const session = createTestSession();
const tools = await createTools(session);
const draft2020Features = [
"prefixItems",
"$dynamicRef",
"$dynamicAnchor",
"unevaluatedProperties",
"unevaluatedItems",
];
for (const tool of tools) {
const schema = tool.parameters;
if (!schema) continue;
const sanitized = sanitizeSchemaForGoogle(schema);
const violations = validateSchema(sanitized, tool.name).filter(v => draft2020Features.includes(v.key));
expect(violations).toEqual([]);
}
});
it("sanitization removes const (converts to enum)", async () => {
const session = createTestSession();
const tools = await createTools(session);
for (const tool of tools) {
const schema = tool.parameters;
if (!schema) continue;
const sanitized = sanitizeSchemaForGoogle(schema);
const violations = validateSchema(sanitized, tool.name).filter(v => v.key === "const");
expect(violations).toEqual([]);
}
});
it("no sanitized schema contains examples field", async () => {
const session = createTestSession();
const tools = await createTools(session);
for (const tool of tools) {
const schema = tool.parameters;
if (!schema) continue;
const sanitized = sanitizeSchemaForGoogle(schema);
const violations = validateSchema(sanitized, tool.name).filter(v => v.key === "examples");
expect(violations).toEqual([]);
}
});
it("hidden tools also have valid sanitized schemas", async () => {
const session = createTestSession();
@@ -365,41 +287,6 @@ describe("tool schema validation (post-sanitization)", () => {
}
}
});
it("logs warnings for potentially problematic features (non-blocking)", async () => {
const session = createTestSession();
const tools = await createTools(session);
const warnings: { tool: string; violations: SchemaViolation[] }[] = [];
for (const tool of tools) {
const schema = tool.parameters;
if (!schema) continue;
const sanitized = sanitizeSchemaForGoogle(schema);
const violations = validateSchema(sanitized, tool.name);
const toolWarnings = violations.filter(v => v.severity === "warning");
if (toolWarnings.length > 0) {
warnings.push({ tool: tool.name, violations: toolWarnings });
}
}
// Log warnings but don't fail - these are advisory
if (warnings.length > 0) {
const message = warnings
.map(({ tool, violations }) => {
const details = violations.map(v => ` - ${v.path}: ${v.key} = ${JSON.stringify(v.value)}`).join("\n");
return `${tool}:\n${details}`;
})
.join("\n\n");
console.log(`Schema warnings (non-blocking):\n\n${message}`);
}
// This test passes regardless - warnings are informational
expect(true).toBe(true);
});
});
describe("validateSchema helper", () => {
@@ -452,12 +339,6 @@ describe("validateSchema helper", () => {
expect(warning?.severity).toBe("warning");
});
it("does not warn on additionalProperties: true", () => {
const schema = { type: "object", additionalProperties: true };
const violations = validateSchema(schema);
expect(violations.some(v => v.key === "additionalProperties")).toBe(false);
});
it("warns on format keyword", () => {
const schema = { type: "string", format: "uri" };
const violations = validateSchema(schema);
@@ -490,17 +371,4 @@ describe("validateSchema helper", () => {
const violations = validateSchema(schema);
expect(violations.some(v => v.key === "const")).toBe(true);
});
it("returns empty array for valid schema", () => {
const schema = {
type: "object",
properties: {
name: { type: "string", description: "User name" },
age: { type: "number", minimum: 0 },
},
required: ["name"],
};
const violations = validateSchema(schema);
expect(violations.filter(v => v.severity === "error")).toEqual([]);
});
});
@@ -3,11 +3,10 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { ArtifactProtocolHandler } from "@oh-my-pi/pi-coding-agent/internal-urls/artifact-protocol";
import { LocalProtocolHandler } from "@oh-my-pi/pi-coding-agent/internal-urls/local-protocol";
import { InternalUrlRouter } from "@oh-my-pi/pi-coding-agent/internal-urls/router";
import { InternalUrlRouter, LocalProtocolHandler } from "@oh-my-pi/pi-coding-agent/internal-urls";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { SearchTool } from "@oh-my-pi/pi-coding-agent/tools/search";
import { AgentRegistry } from "../../src/registry/agent-registry";
function getResultText(result: { content: Array<{ type: string; text?: string }> }): string {
return result.content
@@ -24,10 +23,27 @@ describe("SearchTool internal URL resolution", () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "grep-test-"));
artifactsDir = path.join(tmpDir, "artifacts");
await fs.mkdir(artifactsDir);
AgentRegistry.resetGlobalForTests();
LocalProtocolHandler.resetOverrideForTests();
InternalUrlRouter.resetForTests();
// Register a synthetic main session so artifact:// can derive
// `artifactsDir` from its sessionFile (sessionFile.slice(0,-6)).
AgentRegistry.global().register({
id: "test-main",
displayName: "test",
kind: "main",
session: null,
sessionFile: `${artifactsDir}.jsonl`,
});
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
AgentRegistry.resetGlobalForTests();
LocalProtocolHandler.resetOverrideForTests();
InternalUrlRouter.resetForTests();
});
function createSession(overrides: Partial<ToolSession> = {}): ToolSession {
@@ -41,18 +57,11 @@ describe("SearchTool internal URL resolution", () => {
};
}
function createRouterWithArtifacts(): InternalUrlRouter {
const router = new InternalUrlRouter();
router.register(new ArtifactProtocolHandler({ getArtifactsDir: () => artifactsDir }));
return router;
}
it("resolves artifact:// URL to backing file and greps it", async () => {
const content = "line one\nfound the needle here\nline three\n";
await Bun.write(path.join(artifactsDir, "5.bash.log"), content);
const router = createRouterWithArtifacts();
const session = createSession({ internalRouter: router });
const session = createSession();
const tool = new SearchTool(session);
const result = await tool.execute("test-call", {
@@ -68,8 +77,7 @@ describe("SearchTool internal URL resolution", () => {
const content = "ERROR: connection refused\nWARN: timeout\nERROR: disk full\nINFO: ok\n";
await Bun.write(path.join(artifactsDir, "3.python.log"), content);
const router = createRouterWithArtifacts();
const session = createSession({ internalRouter: router });
const session = createSession();
const tool = new SearchTool(session);
const result = await tool.execute("test-call", {
@@ -85,31 +93,18 @@ describe("SearchTool internal URL resolution", () => {
});
it("throws when internal URL has no sourcePath", async () => {
const router = new InternalUrlRouter();
router.register({
scheme: "agent",
immutable: true,
async resolve() {
return {
url: "agent://0",
content: "some content",
contentType: "text/plain" as const,
};
},
});
const session = createSession({ internalRouter: router });
const session = createSession();
const tool = new SearchTool(session);
expect(tool.execute("test-call", { pattern: "foo", paths: ["agent://0"] })).rejects.toThrow(
"Cannot search internal URL without a backing file",
expect(tool.execute("test-call", { pattern: "foo", paths: ["artifact://999"] })).rejects.toThrow(
"Artifact 999 not found",
);
});
it("falls back to normal path resolution when no internalRouter", async () => {
await Bun.write(path.join(tmpDir, "test.txt"), "hello world\n");
const session = createSession(); // no internalRouter
const session = createSession();
const tool = new SearchTool(session);
const result = await tool.execute("test-call", {
@@ -124,8 +119,7 @@ describe("SearchTool internal URL resolution", () => {
it("falls back to normal resolution for non-internal URLs", async () => {
await Bun.write(path.join(tmpDir, "data.log"), "some data here\n");
const router = createRouterWithArtifacts();
const session = createSession({ internalRouter: router });
const session = createSession();
const tool = new SearchTool(session);
const result = await tool.execute("test-call", {
@@ -141,8 +135,7 @@ describe("SearchTool internal URL resolution", () => {
const content = "alpha line\nbeta needle line\ngamma line\n";
await Bun.write(path.join(artifactsDir, "9.bash.log"), content);
const router = createRouterWithArtifacts();
const session = createSession({ internalRouter: router, hasEditTool: true });
const session = createSession({ hasEditTool: true });
const tool = new SearchTool(session);
const result = await tool.execute("test-call", {
@@ -161,14 +154,9 @@ describe("SearchTool internal URL resolution", () => {
await fs.mkdir(localRoot, { recursive: true });
await Bun.write(path.join(localRoot, "plan.md"), "alpha line\nbeta needle line\ngamma line\n");
const router = new InternalUrlRouter();
router.register(
new LocalProtocolHandler({
getArtifactsDir: () => artifactsDir,
getSessionId: () => "session",
}),
);
const session = createSession({ internalRouter: router, hasEditTool: true });
LocalProtocolHandler.setOverride({ getArtifactsDir: () => artifactsDir, getSessionId: () => "session" });
const session = createSession({ hasEditTool: true });
const tool = new SearchTool(session);
const result = await tool.execute("test-call", {
@@ -187,8 +175,7 @@ describe("SearchTool internal URL resolution", () => {
await Bun.write(path.join(artifactsDir, "11.bash.log"), content);
await Bun.write(path.join(tmpDir, "mixed.txt"), "mixed needle line\n");
const router = createRouterWithArtifacts();
const session = createSession({ internalRouter: router, hasEditTool: true });
const session = createSession({ hasEditTool: true });
const tool = new SearchTool(session);
const result = await tool.execute("test-call", {
@@ -203,8 +190,7 @@ describe("SearchTool internal URL resolution", () => {
});
it("throws on nonexistent artifact ID", async () => {
const router = createRouterWithArtifacts();
const session = createSession({ internalRouter: router });
const session = createSession();
const tool = new SearchTool(session);
expect(tool.execute("test-call", { pattern: "foo", paths: ["artifact://999"] })).rejects.toThrow(
@@ -1,11 +1,10 @@
import { describe, expect, it } from "bun:test";
import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { Settings } from "../../src/config/settings";
// Back-compat import check — these re-exports from mcp/discoverable-tool-metadata should still work
import { buildDiscoverableMCPSearchIndex, type DiscoverableMCPTool } from "../../src/mcp/discoverable-tool-metadata";
import type { DiscoverableMCPSearchIndex, DiscoverableTool } from "../../src/tool-discovery/tool-index";
import type { ToolSession } from "../../src/tools/index";
import { SearchToolBm25Tool, searchToolBm25Renderer } from "../../src/tools/search-tool-bm25";
import { SearchToolBm25Tool } from "../../src/tools/search-tool-bm25";
type TestDiscoverableTool = DiscoverableTool;
@@ -145,187 +144,6 @@ describe("SearchToolBm25Tool", () => {
]);
});
it("renders a titled discovery summary instead of the raw tool name", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const renderedCall = searchToolBm25Renderer.renderCall(
{ query: "github issue", limit: 2 },
{ expanded: false, isPartial: false },
uiTheme,
);
expect(renderedCall.render(120).join("\n")).toContain("Tool Discovery");
expect(renderedCall.render(120).join("\n")).not.toContain("search_tool_bm25");
const renderedResult = searchToolBm25Renderer.renderResult(
{
content: [{ type: "text", text: "" }],
details: {
query: "github issue",
limit: 2,
total_tools: 3,
activated_tools: ["mcp__github_create_issue"],
active_selected_tools: ["mcp__github_create_issue"],
tools: [
{
name: "mcp__github_create_issue",
label: "github/create_issue",
description: "Create a GitHub issue in the selected repository",
server_name: "github",
mcp_tool_name: "create_issue",
schema_keys: ["owner", "repo", "title", "body"],
score: 1.234567,
},
],
},
},
{ expanded: false, isPartial: false },
uiTheme,
);
const renderedText = renderedResult.render(120).join("\n");
expect(renderedText).toContain("Tool Discovery");
expect(renderedText).toContain("github/create_issue");
expect(renderedText).toContain("1 active");
expect(renderedText).toContain("limit:2");
expect(renderedText).not.toContain("keys:");
expect(renderedText).not.toContain("search_tool_bm25");
});
it("truncates fallback discovery text before rendering", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const longLine = "Long discovery output ".repeat(20);
const renderedResult = searchToolBm25Renderer.renderResult(
{
content: [{ type: "text", text: longLine }],
},
{ expanded: false, isPartial: false },
uiTheme,
);
const renderedText = renderedResult.render(200).join("\n");
expect(renderedText).toContain("Tool Discovery");
expect(renderedText).toContain("Long discovery output Long discovery output");
expect(renderedText).not.toContain(longLine);
});
it("tolerates partially streamed render-call arguments", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const renderedCall = searchToolBm25Renderer.renderCall(
{} as never,
{ expanded: false, isPartial: true },
uiTheme,
);
expect(renderedCall.render(120).join("\n")).toContain("(empty query)");
});
it("sanitizes MCP metadata before rendering discovery output", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const renderedResult = searchToolBm25Renderer.renderResult(
{
content: [{ type: "text", text: "" }],
details: {
query: "github\tissue",
limit: 2,
total_tools: 1,
activated_tools: ["mcp__github_create_issue"],
active_selected_tools: ["mcp__github_create_issue"],
tools: [
{
name: "mcp__github_create_issue",
label: "github\t/create_issue",
description: "Create\ta GitHub issue",
server_name: "git\thub",
mcp_tool_name: "create_issue",
schema_keys: ["owner", "repo"],
score: 1.234567,
},
],
},
},
{ expanded: true, isPartial: false },
uiTheme,
);
const renderedText = renderedResult.render(120).join("\n");
expect(renderedText).not.toContain("\t");
expect(renderedText).toContain("github issue");
expect(renderedText).toContain("git hub");
expect(renderedText).toContain("Create a GitHub issue");
});
it("shows at most five tools in collapsed renderer output", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const tools = Array.from({ length: 6 }, (_, index) => ({
name: `mcp__github_tool_${index + 1}`,
label: `github/tool_${index + 1}`,
description: `GitHub tool ${index + 1}`,
server_name: "github",
mcp_tool_name: `tool_${index + 1}`,
schema_keys: ["owner", "repo"],
score: 1 - index * 0.01,
}));
const rendered = searchToolBm25Renderer.renderResult(
{
content: [{ type: "text", text: "" }],
details: {
query: "github tools",
limit: 8,
total_tools: 6,
activated_tools: tools.map(tool => tool.name),
active_selected_tools: tools.map(tool => tool.name),
tools,
},
},
{ expanded: false, isPartial: false },
uiTheme,
);
const renderedText = rendered.render(120).join("\n");
expect(renderedText).toContain("github/tool_5");
expect(renderedText).not.toContain("github/tool_6");
expect(renderedText).toContain("1 more tool");
});
it("defaults to 8 results and lets callers override the limit", async () => {
const manyTools: DiscoverableTool[] = Array.from({ length: 10 }, (_, index) =>
mcpTool(
`mcp__github_tool_${index + 1}`,
"github",
`tool_${index + 1}`,
`GitHub tool ${index + 1} for repository workflows`,
["owner", "repo", `field_${index + 1}`],
),
);
const tool = new SearchToolBm25Tool(createSession(manyTools));
const defaultResult = await tool.execute("call-default", { query: "github" });
expect(defaultResult.details?.limit).toBe(8);
expect(defaultResult.details?.tools).toHaveLength(8);
expect(defaultResult.details?.active_selected_tools).toHaveLength(8);
const defaultContent = defaultResult.content[0];
expect(defaultContent).toBeDefined();
expect(defaultContent).toEqual({
type: "text",
text: JSON.stringify({
query: "github",
activated_tools: defaultResult.details?.activated_tools,
match_count: 8,
total_tools: 10,
}),
});
const limitedTool = new SearchToolBm25Tool(createSession(manyTools));
const limitedResult = await limitedTool.execute("call-limited", { query: "github", limit: 3 });
expect(limitedResult.details?.limit).toBe(3);
expect(limitedResult.details?.tools).toHaveLength(3);
expect(limitedResult.details?.active_selected_tools).toHaveLength(3);
});
it("returns ranked matches and unions activated tools across repeated searches", async () => {
const session = createSession(discoverableTools);
const tool = new SearchToolBm25Tool(session);
@@ -8,11 +8,6 @@ import type { RenderResult } from "@oh-my-pi/pi-coding-agent/web/scrapers/types"
const SKIP = !Bun.env.WEB_FETCH_INTEGRATION;
describe.skipIf(SKIP)("handleSemanticScholar", () => {
it("returns null for non-S2 URLs", async () => {
const result = await handleSemanticScholar("https://example.com", 10);
expect(result).toBeNull();
});
it("fetches a known paper", async () => {
// "Attention Is All You Need" paper
const result = await handleSemanticScholar(
@@ -92,11 +87,6 @@ describe.skipIf(SKIP)("handlePubMed", () => {
return cachedKnownPubMed;
};
it("returns null for non-PubMed URLs", async () => {
const result = await handlePubMed("https://example.com", 10);
expect(result).toBeNull();
});
it("fetches a known article from pubmed.ncbi.nlm.nih.gov", async () => {
// PMID 33782455 - COVID-19 vaccine paper
const result = await fetchKnownPubMed();
@@ -149,11 +139,6 @@ describe.skipIf(SKIP)("handlePubMed", () => {
});
describe.skipIf(SKIP)("handleArxiv", () => {
it("returns null for non-arXiv URLs", async () => {
const result = await handleArxiv("https://example.com", 10000);
expect(result).toBeNull();
});
it("fetches a known paper", async () => {
// "Attention Is All You Need" paper
const result = await handleArxiv("https://arxiv.org/abs/1706.03762", 30000);
@@ -209,11 +194,6 @@ describe.skipIf(SKIP)("handleArxiv", () => {
});
describe.skipIf(SKIP)("handleIacr", () => {
it("returns null for non-IACR URLs", async () => {
const result = await handleIacr("https://example.com", 10000);
expect(result).toBeNull();
});
it("fetches a known ePrint", async () => {
// Using a well-known paper
const result = await handleIacr("https://eprint.iacr.org/2023/123", 30000);
@@ -86,16 +86,6 @@ describe.skipIf(SKIP)("handleGitHub", () => {
expect(result).toBeDefined();
});
it("fetches pull request", async () => {
const result = await handleGitHub("https://github.com/facebook/react/pull/1", 20000);
if (result !== null) {
expect(result.method).toBe("github-pr");
expect(result.contentType).toBe("text/markdown");
expect(result.content.length).toBeGreaterThan(0);
}
expect(result).toBeDefined();
});
it("fetches issues list", async () => {
const result = await handleGitHub("https://github.com/facebook/react/issues", 20000);
if (result !== null) {
@@ -106,69 +96,12 @@ describe.skipIf(SKIP)("handleGitHub", () => {
expect(result).toBeDefined();
});
it("handles repository with underscore in name", async () => {
const result = await handleGitHub("https://github.com/rust-lang/rust-analyzer", 20000);
if (result !== null) {
expect(result.method).toBe("github-repo");
}
expect(result).toBeDefined();
});
it("handles repository with dash in name", async () => {
const result = await handleGitHub("https://github.com/vercel/next.js", 20000);
if (result !== null) {
expect(result.method).toBe("github-repo");
}
expect(result).toBeDefined();
});
it("returns null for invalid URL structure", async () => {
const result = await handleGitHub("https://github.com/", 10000);
expect(result).toBeNull();
});
it("returns null for single path segment", async () => {
const result = await handleGitHub("https://github.com/facebook", 10000);
expect(result).toBeNull();
});
it("handles pulls list endpoint", async () => {
const result = await handleGitHub("https://github.com/facebook/react/pulls", 20000);
// Should be handled as pulls list but currently falls back to null
// This tests the actual behavior
expect(result).toBeDefined();
});
it("fetches file with path containing multiple directories", async () => {
const result = await handleGitHub(
"https://github.com/facebook/react/blob/main/packages/react/package.json",
20000,
);
expect(result).not.toBeNull();
expect(result?.method).toBe("github-raw");
});
it("fetches deeply nested directory", async () => {
const result = await handleGitHub("https://github.com/facebook/react/tree/main/packages/react/src", 20000);
if (result !== null) {
expect(result.method).toBe("github-tree");
}
expect(result).toBeDefined();
});
it("returns null for discussion URLs", async () => {
const result = await handleGitHub("https://github.com/facebook/react/discussions", 10000);
// Discussions not fully implemented, should return null
expect(result).toBeDefined();
});
it("handles trailing slash in repository URL", async () => {
const result = await handleGitHub("https://github.com/facebook/react/", 20000);
if (result !== null) {
expect(result.method).toBe("github-repo");
}
expect(result).toBeDefined();
});
});
// =============================================================================
@@ -9,16 +9,6 @@ import { handleRubyGems } from "@oh-my-pi/pi-coding-agent/web/scrapers/rubygems"
const SKIP = !Bun.env.WEB_FETCH_INTEGRATION;
describe.skipIf(SKIP)("handleBrew", () => {
it("returns null for non-Homebrew URLs", async () => {
const result = await handleBrew("https://example.com", 20);
expect(result).toBeNull();
});
it("returns null for non-package Homebrew URLs", async () => {
const result = await handleBrew("https://formulae.brew.sh/", 20);
expect(result).toBeNull();
});
it("fetches wget formula", async () => {
const result = await handleBrew("https://formulae.brew.sh/formula/wget", 20);
expect(result).not.toBeNull();
@@ -43,16 +33,6 @@ describe.skipIf(SKIP)("handleBrew", () => {
});
describe.skipIf(SKIP)("handleAur", () => {
it("returns null for non-AUR URLs", async () => {
const result = await handleAur("https://example.com", 20);
expect(result).toBeNull();
});
it("returns null for non-package AUR URLs", async () => {
const result = await handleAur("https://aur.archlinux.org/", 20);
expect(result).toBeNull();
});
it("fetches yay package", async () => {
const result = await handleAur("https://aur.archlinux.org/packages/yay", 20);
expect(result).not.toBeNull();
@@ -67,16 +47,6 @@ describe.skipIf(SKIP)("handleAur", () => {
});
describe.skipIf(SKIP)("handleRubyGems", () => {
it("returns null for non-RubyGems URLs", async () => {
const result = await handleRubyGems("https://example.com", 20);
expect(result).toBeNull();
});
it("returns null for non-gem RubyGems URLs", async () => {
const result = await handleRubyGems("https://rubygems.org/", 20);
expect(result).toBeNull();
});
it("fetches rails gem", async () => {
const result = await handleRubyGems("https://rubygems.org/gems/rails", 20);
expect(result).not.toBeNull();
@@ -90,16 +60,6 @@ describe.skipIf(SKIP)("handleRubyGems", () => {
});
describe.skipIf(SKIP)("handleNuGet", () => {
it("returns null for non-NuGet URLs", async () => {
const result = await handleNuGet("https://example.com", 20);
expect(result).toBeNull();
});
it("returns null for non-package NuGet URLs", async () => {
const result = await handleNuGet("https://www.nuget.org/", 20);
expect(result).toBeNull();
});
it("fetches Newtonsoft.Json package", async () => {
const result = await handleNuGet("https://www.nuget.org/packages/Newtonsoft.Json", 20);
expect(result).not.toBeNull();
@@ -113,16 +73,6 @@ describe.skipIf(SKIP)("handleNuGet", () => {
});
describe.skipIf(SKIP)("handlePackagist", () => {
it("returns null for non-Packagist URLs", async () => {
const result = await handlePackagist("https://example.com", 20);
expect(result).toBeNull();
});
it("returns null for non-package Packagist URLs", async () => {
const result = await handlePackagist("https://packagist.org/", 20);
expect(result).toBeNull();
});
it("fetches laravel/framework package", async () => {
const result = await handlePackagist("https://packagist.org/packages/laravel/framework", 20);
expect(result).not.toBeNull();
@@ -136,16 +86,6 @@ describe.skipIf(SKIP)("handlePackagist", () => {
});
describe.skipIf(SKIP)("handleMaven", () => {
it("returns null for non-Maven URLs", async () => {
const result = await handleMaven("https://example.com", 20);
expect(result).toBeNull();
});
it("returns null for non-artifact Maven URLs", async () => {
const result = await handleMaven("https://search.maven.org/", 20);
expect(result).toBeNull();
});
it("fetches commons-lang3 artifact from search.maven.org", async () => {
const result = await handleMaven("https://search.maven.org/artifact/org.apache.commons/commons-lang3", 20);
expect(result).not.toBeNull();
@@ -35,57 +35,6 @@ describe.skipIf(SKIP)("handleMastodon", () => {
{ timeout: 30000 },
);
it(
"fetches a Mastodon post",
async () => {
// Gargron's post ID 1 - the first ever Mastodon post
const result = await handleMastodon("https://mastodon.social/@Gargron/1", 20);
// Post 1 may not exist anymore; check gracefully
if (result !== null) {
expect(result.method).toBe("mastodon");
expect(result.contentType).toBe("text/markdown");
expect(result.content).toContain("Post by");
expect(result.content).toContain("@Gargron");
expect(result.fetchedAt).toBeTruthy();
expect(result.truncated).toBeDefined();
expect(result.notes?.[0]).toContain("Mastodon API");
}
},
{ timeout: 30000 },
);
it(
"handles a stable pinned post",
async () => {
// Use a well-known post from mastodon.social - Gargron's announcement post
const result = await handleMastodon("https://mastodon.social/@Gargron/109318821117356215", 20);
// May not exist, check gracefully
if (result !== null) {
expect(result.method).toBe("mastodon");
expect(result.contentType).toBe("text/markdown");
expect(result.content).toContain("@Gargron");
expect(result.content).toContain("replies");
expect(result.content).toContain("boosts");
expect(result.content).toContain("favorites");
expect(result.fetchedAt).toBeTruthy();
}
},
{ timeout: 30000 },
);
it(
"includes recent posts in profile",
async () => {
const result = await handleMastodon("https://mastodon.social/@Gargron", 20);
expect(result).not.toBeNull();
// May include recent posts section
if (result?.content?.includes("## Recent Posts")) {
expect(result.content).toMatch(/###\s+\w+/); // Date header
}
},
{ timeout: 30000 },
);
it("returns null for non-Mastodon instance with @user pattern", async () => {
// A site that has @user pattern but isn't Mastodon
const result = await handleMastodon("https://twitter.com/@jack", 20);
@@ -140,53 +89,4 @@ describe.skipIf(SKIP)("handleBluesky", () => {
},
{ timeout: 30000 },
);
it(
"fetches a Bluesky post",
async () => {
// A post from bsky.app - use a well-known stable post
const result = await handleBluesky("https://bsky.app/profile/bsky.app/post/3juzlwllznd24", 20);
// Post may not exist, check gracefully
if (result !== null) {
expect(result.method).toBe("bluesky-api");
expect(result.contentType).toBe("text/markdown");
expect(result.content).toContain("# Bluesky Post");
expect(result.content).toContain("@bsky.app");
expect(result.fetchedAt).toBeTruthy();
expect(result.truncated).toBeDefined();
expect(result.notes?.[0]).toContain("AT URI");
}
},
{ timeout: 30000 },
);
it(
"includes post stats",
async () => {
const result = await handleBluesky("https://bsky.app/profile/bsky.app/post/3juzlwllznd24", 20);
// Stats include likes, reposts, replies
if (result?.content) {
// Should have some engagement markers
const hasStats =
result.content.includes("❤️") || result.content.includes("🔁") || result.content.includes("💬");
expect(hasStats || result.content.includes("# Bluesky Post")).toBe(true);
}
},
{ timeout: 30000 },
);
it(
"handles www.bsky.app URLs",
async () => {
const result = await handleBluesky("https://www.bsky.app/profile/bsky.app", 20);
expect(result).not.toBeNull();
expect(result?.method).toBe("bluesky-api");
},
{ timeout: 30000 },
);
it("returns null for invalid profile handle", async () => {
const result = await handleBluesky("https://bsky.app/profile/", 20);
expect(result).toBeNull();
});
});
@@ -6,11 +6,6 @@ import { handleTwitter } from "@oh-my-pi/pi-coding-agent/web/scrapers/twitter";
const SKIP = !Bun.env.WEB_FETCH_INTEGRATION;
describe.skipIf(SKIP)("handleTwitter", () => {
it("returns null for non-Twitter URLs", async () => {
const result = await handleTwitter("https://example.com", 10);
expect(result).toBeNull();
});
it(
"handles twitter.com status URLs",
async () => {
@@ -47,26 +42,6 @@ describe.skipIf(SKIP)("handleTwitter", () => {
{ timeout: 30000 },
);
it(
"handles www.twitter.com URLs",
async () => {
const result = await handleTwitter("https://www.twitter.com/twitter/status/1", 10000);
expect(result).not.toBeNull();
expect(result?.method).toMatch(/^twitter/);
},
{ timeout: 30000 },
);
it(
"handles www.x.com URLs",
async () => {
const result = await handleTwitter("https://www.x.com/twitter/status/1", 10000);
expect(result).not.toBeNull();
expect(result?.method).toMatch(/^twitter/);
},
{ timeout: 30000 },
);
it(
"may fail due to Nitter availability",
async () => {
@@ -84,11 +59,6 @@ describe.skipIf(SKIP)("handleTwitter", () => {
});
describe.skipIf(SKIP)("handleReddit", () => {
it("returns null for non-Reddit URLs", async () => {
const result = await handleReddit("https://example.com", 10);
expect(result).toBeNull();
});
it("fetches subreddit", async () => {
const result = await handleReddit("https://www.reddit.com/r/programming/", 20000);
expect(result).not.toBeNull();
@@ -110,58 +80,9 @@ describe.skipIf(SKIP)("handleReddit", () => {
expect(result.notes).toContain("Fetched via Reddit JSON API");
}
});
it("includes comments in post when available", async () => {
const result = await handleReddit("https://www.reddit.com/r/programming/", 20000);
// Comments test - just verify structure if post with comments is found
if (result?.content?.includes("## Top Comments")) {
expect(result.content).toContain("### u/");
expect(result.content).toContain("points");
}
});
it("handles old.reddit.com", async () => {
const result = await handleReddit("https://old.reddit.com/r/programming/", 20000);
expect(result).not.toBeNull();
expect(result?.method).toBe("reddit");
expect(result?.contentType).toBe("text/markdown");
expect(result?.content).toContain("# r/");
expect(result?.notes).toContain("Fetched via Reddit JSON API");
});
it("handles reddit.com without www", async () => {
const result = await handleReddit("https://reddit.com/r/programming/", 20000);
expect(result).not.toBeNull();
expect(result?.method).toBe("reddit");
});
it("handles URLs with query parameters", async () => {
const result = await handleReddit("https://www.reddit.com/r/programming/?sort=top", 20000);
expect(result).not.toBeNull();
expect(result?.method).toBe("reddit");
expect(result?.content).toContain("# r/");
});
it("returns null for malformed Reddit URLs", async () => {
const result = await handleReddit("https://www.reddit.com/invalid", 20000);
// May return null or empty result
if (result !== null) {
expect(result.content).toBeDefined();
}
});
});
describe.skipIf(SKIP)("handleStackOverflow", () => {
it("returns null for non-SO URLs", async () => {
const result = await handleStackOverflow("https://example.com", 10);
expect(result).toBeNull();
});
it("returns null for SO URLs without question ID", async () => {
const result = await handleStackOverflow("https://stackoverflow.com/", 10);
expect(result).toBeNull();
});
it("fetches a known question", async () => {
// Use a well-known question that definitely exists
const result = await handleStackOverflow(
@@ -180,38 +101,6 @@ describe.skipIf(SKIP)("handleStackOverflow", () => {
}
});
it("includes answers", async () => {
const result = await handleStackOverflow(
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
20000,
);
if (result?.content?.includes("## Answers")) {
expect(result.content).toContain("### Score:");
}
});
it("shows accepted answer marker when present", async () => {
const result = await handleStackOverflow(
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
20000,
);
// Some questions may have accepted answers
if (result?.content?.includes("(Accepted)")) {
expect(result.content).toContain("## Answers");
}
});
it("handles stackoverflow.com", async () => {
const result = await handleStackOverflow(
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array",
20000,
);
expect(result).not.toBeNull();
expect(result?.method).toBe("stackexchange");
expect(result?.content).toContain("# ");
expect(result?.content).toContain("## Question");
});
it("handles other StackExchange sites", async () => {
const result = await handleStackOverflow("https://math.stackexchange.com/questions/1000/", 20000);
// API may fail, check gracefully
@@ -222,38 +111,4 @@ describe.skipIf(SKIP)("handleStackOverflow", () => {
expect(result.notes).toContain("Fetched via Stack Exchange API");
}
});
it("extracts question ID from URL", async () => {
const result = await handleStackOverflow(
"https://stackoverflow.com/questions/1234567/some-long-question-title",
20000,
);
// Should attempt to fetch, may or may not exist
// Either returns valid result or null
if (result !== null) {
expect(result.method).toBe("stackoverflow");
}
});
it("handles URLs without trailing slash", async () => {
const result = await handleStackOverflow("https://stackoverflow.com/questions/11227809", 20000);
// API may fail, check gracefully
if (result !== null) {
expect(result.method).toBe("stackexchange");
}
});
it("includes question metadata", async () => {
const result = await handleStackOverflow(
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
20000,
);
// API may fail, check gracefully
if (result !== null) {
expect(result.content).toContain("**Score:");
expect(result.content).toContain("**Answers:");
expect(result.content).toContain("**Tags:");
expect(result.content).toContain("**Asked by:");
}
});
});
@@ -91,14 +91,6 @@ describe.skipIf(SKIP)("handleStackOverflow", () => {
});
// Test with www. prefix
it("handles www.stackoverflow.com URLs", async () => {
const result = await handleStackOverflow(
"https://www.stackoverflow.com/questions/218384/what-is-a-nullpointerexception",
20,
);
expect(result).not.toBeNull();
expect(result?.method).toBe("stackexchange");
});
// Verify response structure
it("returns complete response structure", async () => {
@@ -1,131 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { hookFetch } from "@oh-my-pi/pi-utils";
import { searchAnthropic } from "../../src/web/search/providers/anthropic";
type CapturedRequest = {
url: string;
headers: RequestInit["headers"];
body: Record<string, unknown> | null;
};
const WEB_SEARCH_BETA = "web-search-2025-03-05";
const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
function makeAnthropicResponse() {
return {
id: "msg_test_123",
model: "claude-haiku-4-5",
content: [{ type: "text", text: "Test answer" }],
usage: {
input_tokens: 12,
output_tokens: 7,
server_tool_use: { web_search_requests: 1 },
},
};
}
function getHeaderCaseInsensitive(headers: RequestInit["headers"], name: string): string | undefined {
if (!headers) return undefined;
if (headers instanceof Headers) {
return headers.get(name) ?? undefined;
}
if (Array.isArray(headers)) {
const match = headers.find(([key]) => key.toLowerCase() === name.toLowerCase());
return match?.[1];
}
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === name.toLowerCase()) {
return value as string;
}
}
return undefined;
}
describe("searchAnthropic headers", () => {
const originalSearchApiKey = process.env.ANTHROPIC_SEARCH_API_KEY;
const originalSearchBaseUrl = process.env.ANTHROPIC_SEARCH_BASE_URL;
const originalApiKey = process.env.ANTHROPIC_API_KEY;
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
let capturedRequest: CapturedRequest | null = null;
beforeEach(() => {
capturedRequest = null;
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_BASE_URL;
process.env.ANTHROPIC_SEARCH_BASE_URL = ANTHROPIC_BASE_URL;
});
afterEach(() => {
capturedRequest = null;
if (originalSearchApiKey === undefined) {
delete process.env.ANTHROPIC_SEARCH_API_KEY;
} else {
process.env.ANTHROPIC_SEARCH_API_KEY = originalSearchApiKey;
}
if (originalSearchBaseUrl === undefined) {
delete process.env.ANTHROPIC_SEARCH_BASE_URL;
} else {
process.env.ANTHROPIC_SEARCH_BASE_URL = originalSearchBaseUrl;
}
if (originalApiKey === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = originalApiKey;
}
if (originalBaseUrl === undefined) {
delete process.env.ANTHROPIC_BASE_URL;
} else {
process.env.ANTHROPIC_BASE_URL = originalBaseUrl;
}
});
function mockFetch(responseBody: unknown): Disposable {
return hookFetch((url, init) => {
capturedRequest = {
url: typeof url === "string" ? url : url.toString(),
headers: init?.headers,
body: init?.body ? JSON.parse(init.body as string) : null,
};
return new Response(JSON.stringify(responseBody), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
}
it("includes web-search beta header and sends API key in X-Api-Key mode", async () => {
process.env.ANTHROPIC_SEARCH_API_KEY = "sk-ant-api-test";
using _hook = mockFetch(makeAnthropicResponse());
await searchAnthropic({ query: "test api key mode" });
expect(capturedRequest).not.toBeNull();
expect(capturedRequest?.url).toBe(`${ANTHROPIC_BASE_URL}/v1/messages?beta=true`);
expect(getHeaderCaseInsensitive(capturedRequest?.headers, "anthropic-beta")).toContain(WEB_SEARCH_BETA);
expect(getHeaderCaseInsensitive(capturedRequest?.headers, "x-api-key")).toBe("sk-ant-api-test");
expect(getHeaderCaseInsensitive(capturedRequest?.headers, "authorization")).toBeUndefined();
expect(capturedRequest?.body?.tools).toEqual([{ type: "web_search_20250305", name: "web_search" }]);
});
it("includes web-search beta header and sends OAuth token in Authorization mode", async () => {
process.env.ANTHROPIC_SEARCH_API_KEY = "sk-ant-oat-test";
using _hook = mockFetch(makeAnthropicResponse());
await searchAnthropic({ query: "test oauth mode" });
expect(capturedRequest).not.toBeNull();
expect(getHeaderCaseInsensitive(capturedRequest?.headers, "anthropic-beta")).toContain(WEB_SEARCH_BETA);
expect(getHeaderCaseInsensitive(capturedRequest?.headers, "authorization")).toBe("Bearer sk-ant-oat-test");
expect(getHeaderCaseInsensitive(capturedRequest?.headers, "x-api-key")).toBeUndefined();
});
});
@@ -49,11 +49,6 @@ describe("buildExaRequestBody", () => {
});
});
it("includes contents.summary with the search query", () => {
const body = buildExaRequestBody({ query: "how does React work" });
expect(body.contents).toEqual({ summary: { query: "how does React work" } });
});
it("applies num_results override", () => {
const body = buildExaRequestBody({ query: "q", num_results: 5 });
expect(body.numResults).toBe(5);
@@ -132,21 +127,6 @@ describe("synthesizeAnswer", () => {
expect(answer).toBe("**A**: Summary A\n\n**B**: Summary B");
});
it("limits to MAX_ANSWER_SUMMARIES (3) results", () => {
const results = [
{ title: "A", url: "https://a.com", summary: "SA" },
{ title: "B", url: "https://b.com", summary: "SB" },
{ title: "C", url: "https://c.com", summary: "SC" },
{ title: "D", url: "https://d.com", summary: "SD" },
{ title: "E", url: "https://e.com", summary: "SE" },
];
const answer = synthesizeAnswer(results)!;
expect(answer.split("\n\n")).toHaveLength(3);
expect(answer).toContain("**A**: SA");
expect(answer).toContain("**C**: SC");
expect(answer).not.toContain("**D**");
});
it("skips results with missing summaries but includes ones that have them", () => {
const results = [
{ title: "NoSummary", url: "https://no.com", summary: null },
@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { hookFetch } from "@oh-my-pi/pi-utils";
import { AgentStorage } from "../../src/session/agent-storage";
import { getSearchProviderLabel, resolveProviderChain, SEARCH_PROVIDER_ORDER } from "../../src/web/search/provider";
import { searchTavily } from "../../src/web/search/providers/tavily";
import type { SearchProviderError } from "../../src/web/search/types";
@@ -15,13 +14,6 @@ describe("Tavily web search provider", () => {
delete process.env.TAVILY_API_KEY;
});
it("registers tavily in the provider registry and fallback order", async () => {
expect(SEARCH_PROVIDER_ORDER).toContain("tavily");
expect(getSearchProviderLabel("tavily")).toBe("Tavily");
const providers = await resolveProviderChain("tavily");
expect(providers[0]?.id).toBe("tavily");
});
it("maps Tavily responses into SearchResponse and forwards recency filters", async () => {
let requestBody: Record<string, unknown> | null = null;
@@ -35,18 +35,6 @@ function getSuccessDataSchema(parameters: Record<string, unknown>): Record<strin
}
describe("YieldTool", () => {
it("exposes top-level object parameters with required result union", () => {
const tool = new YieldTool(createSession());
const schema = tool.parameters as {
type?: string;
properties?: Record<string, unknown>;
required?: string[];
};
expect(schema.type).toBe("object");
expect(Object.keys(schema.properties ?? {})).toEqual(["result"]);
expect(schema.required).toEqual(["result"]);
});
it("accepts success payload with data", async () => {
const tool = new YieldTool(createSession());
const result = await tool.execute("call-1", { result: { data: { ok: true } } } as never);