Merge branch 'main' into fix/plugin-directory-extensions
This commit is contained in:
@@ -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),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
+94
-1
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user