chore: remove garbage tests
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { Agent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import { getBundledModel, type SimpleStreamOptions, type ThinkingBudgets } from "@oh-my-pi/pi-ai";
|
||||
import { getBundledModel, type SimpleStreamOptions } from "@oh-my-pi/pi-ai";
|
||||
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { createAssistantMessage, pushAlphaThenDoneEvent } from "./helpers";
|
||||
@@ -8,96 +8,6 @@ import { createAssistantMessage, pushAlphaThenDoneEvent } from "./helpers";
|
||||
class MockAssistantStream extends AssistantMessageEventStream {}
|
||||
|
||||
describe("Agent", () => {
|
||||
it("should create an agent instance with default state", () => {
|
||||
const agent = new Agent();
|
||||
|
||||
expect(agent.state).toBeDefined();
|
||||
expect(agent.state.systemPrompt).toEqual([]);
|
||||
expect(agent.state.model).toBeDefined();
|
||||
expect(agent.state.thinkingLevel).toBeUndefined();
|
||||
expect(agent.state.tools).toEqual([]);
|
||||
expect(agent.state.messages).toEqual([]);
|
||||
expect(agent.state.isStreaming).toBe(false);
|
||||
expect(agent.state.streamMessage).toBe(null);
|
||||
expect(agent.state.pendingToolCalls).toEqual(new Set());
|
||||
expect(agent.state.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create an agent instance with custom initial state", () => {
|
||||
const customModel = getBundledModel("openai", "gpt-4o-mini");
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: ["You are a helpful assistant."],
|
||||
model: customModel,
|
||||
thinkingLevel: ThinkingLevel.Low,
|
||||
},
|
||||
});
|
||||
|
||||
expect(agent.state.systemPrompt).toEqual(["You are a helpful assistant."]);
|
||||
expect(agent.state.model).toBe(customModel);
|
||||
expect(agent.state.thinkingLevel).toBe(ThinkingLevel.Low);
|
||||
});
|
||||
|
||||
it("should subscribe to events", () => {
|
||||
const agent = new Agent();
|
||||
|
||||
let eventCount = 0;
|
||||
const unsubscribe = agent.subscribe(_event => {
|
||||
eventCount++;
|
||||
});
|
||||
|
||||
// No initial event on subscribe
|
||||
expect(eventCount).toBe(0);
|
||||
|
||||
// State mutators don't emit events
|
||||
agent.setSystemPrompt(["Test prompt"]);
|
||||
expect(eventCount).toBe(0);
|
||||
expect(agent.state.systemPrompt).toEqual(["Test prompt"]);
|
||||
|
||||
// Unsubscribe should work
|
||||
unsubscribe();
|
||||
agent.setSystemPrompt(["Another prompt"]);
|
||||
expect(eventCount).toBe(0); // Should not increase
|
||||
});
|
||||
|
||||
it("should update state with mutators", () => {
|
||||
const agent = new Agent();
|
||||
|
||||
// Test setSystemPrompt
|
||||
agent.setSystemPrompt(["Custom prompt"]);
|
||||
expect(agent.state.systemPrompt).toEqual(["Custom prompt"]);
|
||||
|
||||
// Test setModel
|
||||
const newModel = getBundledModel("google", "gemini-2.5-flash");
|
||||
agent.setModel(newModel);
|
||||
expect(agent.state.model).toBe(newModel);
|
||||
|
||||
// Test setThinkingLevel
|
||||
agent.setThinkingLevel(ThinkingLevel.High);
|
||||
expect(agent.state.thinkingLevel).toBe(ThinkingLevel.High);
|
||||
|
||||
// Test setTools
|
||||
const tools = [{ name: "test", description: "test tool" } as any];
|
||||
agent.setTools(tools);
|
||||
expect(agent.state.tools).toBe(tools);
|
||||
|
||||
// Test replaceMessages
|
||||
const messages = [{ role: "user" as const, content: "Hello", timestamp: Date.now() }];
|
||||
agent.replaceMessages(messages);
|
||||
expect(agent.state.messages).toEqual(messages);
|
||||
expect(agent.state.messages).not.toBe(messages); // Should be a copy
|
||||
|
||||
// Test appendMessage
|
||||
const newMessage = createAssistantMessage([{ type: "text", text: "Hi" }]);
|
||||
agent.appendMessage(newMessage);
|
||||
expect(agent.state.messages).toHaveLength(2);
|
||||
expect(agent.state.messages[1]).toBe(newMessage);
|
||||
|
||||
// Test clearMessages
|
||||
agent.clearMessages();
|
||||
expect(agent.state.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("should support steering message queueing", async () => {
|
||||
const agent = new Agent();
|
||||
|
||||
@@ -108,13 +18,6 @@ describe("Agent", () => {
|
||||
expect(agent.state.messages).not.toContainEqual(message);
|
||||
});
|
||||
|
||||
it("should handle abort controller", () => {
|
||||
const agent = new Agent();
|
||||
|
||||
// Should not throw even if nothing is running
|
||||
expect(() => agent.abort()).not.toThrow();
|
||||
});
|
||||
|
||||
it("continue() should process queued follow-up messages after an assistant turn", async () => {
|
||||
const agent = new Agent({
|
||||
streamFn: () => {
|
||||
@@ -323,63 +226,6 @@ describe("Agent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards sessionId and thinkingBudgets to streamFn options", async () => {
|
||||
let receivedSessionId: string | undefined;
|
||||
let receivedBudgets: ThinkingBudgets | undefined;
|
||||
|
||||
const agent = new Agent({
|
||||
sessionId: "session-abc",
|
||||
thinkingBudgets: { minimal: 64, low: 256 },
|
||||
streamFn: (_model, _context, options) => {
|
||||
receivedSessionId = options?.sessionId;
|
||||
receivedBudgets = options?.thinkingBudgets;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const message = createAssistantMessage([{ type: "text", text: "ok" }]);
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
await agent.prompt("hello");
|
||||
expect(receivedSessionId).toBe("session-abc");
|
||||
expect(receivedBudgets).toEqual({ minimal: 64, low: 256 });
|
||||
|
||||
agent.sessionId = "session-def";
|
||||
agent.thinkingBudgets = { medium: 512 };
|
||||
|
||||
await agent.prompt("hello again");
|
||||
expect(receivedSessionId).toBe("session-def");
|
||||
expect(receivedBudgets).toEqual({ medium: 512 });
|
||||
});
|
||||
|
||||
it("forwards onPayload to streamFn options", async () => {
|
||||
let receivedOnPayload: SimpleStreamOptions["onPayload"] | undefined;
|
||||
|
||||
const agent = new Agent({
|
||||
onPayload: async (payload, model) => ({ payload, provider: model?.provider }),
|
||||
streamFn: (_model, _context, options) => {
|
||||
receivedOnPayload = options?.onPayload;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const message = createAssistantMessage([{ type: "text", text: "ok" }]);
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
await agent.prompt("hello");
|
||||
expect(receivedOnPayload).toBeDefined();
|
||||
|
||||
const replacementPayload = await receivedOnPayload?.({ request: true }, getBundledModel("openai", "gpt-4o-mini"));
|
||||
expect(replacementPayload).toEqual({
|
||||
payload: { request: true },
|
||||
provider: "openai",
|
||||
});
|
||||
});
|
||||
|
||||
it("re-reads thinking level for each model call within a run", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
type Details = { value: string };
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors";
|
||||
import { alibabaCodingPlanModelManagerOptions } from "../src/provider-models/openai-compat";
|
||||
import { getEnvApiKey } from "../src/stream";
|
||||
|
||||
const originalAlibabaApiKey = Bun.env.ALIBABA_CODING_PLAN_API_KEY;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalAlibabaApiKey === undefined) {
|
||||
delete Bun.env.ALIBABA_CODING_PLAN_API_KEY;
|
||||
return;
|
||||
}
|
||||
Bun.env.ALIBABA_CODING_PLAN_API_KEY = originalAlibabaApiKey;
|
||||
});
|
||||
|
||||
describe("alibaba-coding-plan provider support", () => {
|
||||
test("resolves ALIBABA_CODING_PLAN_API_KEY from environment", () => {
|
||||
Bun.env.ALIBABA_CODING_PLAN_API_KEY = "alibaba-test-key";
|
||||
expect(getEnvApiKey("alibaba-coding-plan")).toBe("alibaba-test-key");
|
||||
});
|
||||
|
||||
test("registers built-in descriptor and default model", () => {
|
||||
const descriptor = PROVIDER_DESCRIPTORS.find(item => item.providerId === "alibaba-coding-plan");
|
||||
expect(descriptor).toBeDefined();
|
||||
expect(descriptor?.defaultModel).toBe("qwen3.5-plus");
|
||||
expect(DEFAULT_MODEL_PER_PROVIDER["alibaba-coding-plan"]).toBe("qwen3.5-plus");
|
||||
});
|
||||
|
||||
test("builds model manager options with alibaba-coding-plan defaults", () => {
|
||||
const options = alibabaCodingPlanModelManagerOptions();
|
||||
expect(options.providerId).toBe("alibaba-coding-plan");
|
||||
expect(options.fetchDynamicModels).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
buildAnthropicClientOptions,
|
||||
buildAnthropicHeaders,
|
||||
buildAnthropicSystemBlocks,
|
||||
claudeCodeHeaders,
|
||||
claudeCodeSystemInstruction,
|
||||
claudeCodeVersion,
|
||||
generateClaudeCloakingUserId,
|
||||
isClaudeCloakingUserId,
|
||||
@@ -83,21 +81,6 @@ function captureAnthropicPayload(
|
||||
}
|
||||
|
||||
describe("Anthropic request fingerprint alignment", () => {
|
||||
it("uses updated Claude Code header defaults", () => {
|
||||
const headers = buildAnthropicHeaders({
|
||||
apiKey: "sk-ant-oat-test",
|
||||
isOAuth: true,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
expect(headers["Anthropic-Beta"]).toContain("context-management-2025-06-27");
|
||||
expect(headers["Anthropic-Beta"]).toContain("prompt-caching-scope-2026-01-05");
|
||||
expect(headers["Anthropic-Beta"]).not.toContain("fine-grained-tool-streaming-2025-05-14");
|
||||
expect(headers["User-Agent"]).toBe(`claude-cli/${claudeCodeVersion} (external, cli)`);
|
||||
expect(claudeCodeHeaders["X-Stainless-Package-Version"]).toBe("0.74.0");
|
||||
expect("X-Stainless-Helper-Method" in claudeCodeHeaders).toBe(false);
|
||||
});
|
||||
|
||||
it("maps Stainless OS and arch values from explicit inputs", () => {
|
||||
expect(mapStainlessOs("darwin")).toBe("MacOS");
|
||||
expect(mapStainlessOs("windows")).toBe("Windows");
|
||||
@@ -124,29 +107,6 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
expect(headers["X-Stainless-Arch"]).toBe(mapStainlessArch(process.arch));
|
||||
});
|
||||
|
||||
it("injects billing header and Claude Agent SDK identity block", () => {
|
||||
const blocks = buildAnthropicSystemBlocks(["Stay concise."], {
|
||||
includeClaudeCodeInstruction: true,
|
||||
extraInstructions: ["Use citations when possible"],
|
||||
});
|
||||
|
||||
expect(blocks).toBeDefined();
|
||||
expect(blocks?.[0]?.text.startsWith(`x-anthropic-billing-header: cc_version=${claudeCodeVersion}.`)).toBe(true);
|
||||
expect(blocks?.[0]?.text).toMatch(/cc_entrypoint=cli; cch=[0-9a-f]{5};$/);
|
||||
expect(blocks?.[1]).toEqual({
|
||||
type: "text",
|
||||
text: claudeCodeSystemInstruction,
|
||||
});
|
||||
expect(blocks?.[2]).toEqual({
|
||||
type: "text",
|
||||
text: "Use citations when possible",
|
||||
});
|
||||
expect(blocks?.[3]).toEqual({
|
||||
type: "text",
|
||||
text: "Stay concise.",
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches cache_control only to the last emitted system block when cacheControl is set", () => {
|
||||
const blocks = buildAnthropicSystemBlocks(["Stay concise."], {
|
||||
includeClaudeCodeInstruction: true,
|
||||
@@ -607,21 +567,6 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
expect(strictNames).toEqual(["python"]);
|
||||
});
|
||||
|
||||
it("drops fine-grained tool-streaming beta from default Anthropic client options", () => {
|
||||
const options = buildAnthropicClientOptions({
|
||||
model: ANTHROPIC_MODEL,
|
||||
apiKey: "sk-ant-oat-test",
|
||||
extraBetas: [],
|
||||
stream: true,
|
||||
interleavedThinking: false,
|
||||
dynamicHeaders: {},
|
||||
});
|
||||
|
||||
const beta = options.defaultHeaders["Anthropic-Beta"];
|
||||
expect(beta).toContain("context-management-2025-06-27");
|
||||
expect(beta).not.toContain("fine-grained-tool-streaming-2025-05-14");
|
||||
});
|
||||
|
||||
it("adds legacy fine-grained tool-streaming beta only for tool requests on incompatible models", () => {
|
||||
const incompatibleModel: Model<"anthropic-messages"> = {
|
||||
...ANTHROPIC_MODEL,
|
||||
|
||||
@@ -14,14 +14,6 @@ afterEach(() => {
|
||||
|
||||
describe("custom API registry", () => {
|
||||
const streamSimple: CustomStreamSimpleFn = () => ({}) as unknown as AssistantMessageEventStream;
|
||||
test("registers and resolves a custom API provider", () => {
|
||||
registerCustomApi("custom-provider", streamSimple, "ext-a");
|
||||
|
||||
const provider = getCustomApi("custom-provider");
|
||||
expect(provider).toBeDefined();
|
||||
expect(provider?.streamSimple).toBe(streamSimple);
|
||||
expect(provider?.sourceId).toBe("ext-a");
|
||||
});
|
||||
|
||||
test("rejects registrations that collide with built-in API names", () => {
|
||||
expect(() => registerCustomApi("openai-responses", streamSimple)).toThrow(
|
||||
|
||||
@@ -107,11 +107,6 @@ function makeUnionTool(strict: boolean): Tool {
|
||||
}
|
||||
|
||||
describe("supportsFreeformApplyPatch", () => {
|
||||
test("absent flag returns false", () => {
|
||||
// No runtime auto-detection — requires generated model metadata.
|
||||
expect(supportsFreeformApplyPatch(makeModel())).toBe(false);
|
||||
});
|
||||
|
||||
test("applyPatchToolType: freeform enables", () => {
|
||||
expect(supportsFreeformApplyPatch(makeModel({ applyPatchToolType: "freeform" }))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,763 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-ai/models";
|
||||
import { complete } from "@oh-my-pi/pi-ai/stream";
|
||||
import type { Api, AssistantMessage, Context, Model, OptionsForApi, UserMessage } from "@oh-my-pi/pi-ai/types";
|
||||
import { e2eApiKey, resolveApiKey } from "./oauth";
|
||||
|
||||
// Resolve OAuth tokens at module level (async, runs before tests)
|
||||
const oauthTokens = await Promise.all([
|
||||
resolveApiKey("anthropic"),
|
||||
resolveApiKey("github-copilot"),
|
||||
resolveApiKey("google-gemini-cli"),
|
||||
resolveApiKey("google-antigravity"),
|
||||
resolveApiKey("openai-codex"),
|
||||
]);
|
||||
const [anthropicOAuthToken, githubCopilotToken, geminiCliToken, antigravityToken, openaiCodexToken] = oauthTokens;
|
||||
|
||||
async function testEmptyMessage<TApi extends Api>(llm: Model<TApi>, options: OptionsForApi<TApi> = {}) {
|
||||
// Test with completely empty content array
|
||||
const emptyMessage: UserMessage = {
|
||||
role: "user",
|
||||
content: [],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
messages: [emptyMessage],
|
||||
};
|
||||
|
||||
const response = await complete(llm, context, options);
|
||||
|
||||
// Should either handle gracefully or return an error
|
||||
expect(response).toBeDefined();
|
||||
expect(response.role).toBe("assistant");
|
||||
// Should handle empty string gracefully
|
||||
if (response.stopReason === "error") {
|
||||
expect(response.errorMessage).toBeDefined();
|
||||
} else {
|
||||
expect(response.content).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
async function testEmptyStringMessage<TApi extends Api>(llm: Model<TApi>, options: OptionsForApi<TApi> = {}) {
|
||||
// Test with empty string content
|
||||
const context: Context = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await complete(llm, context, options);
|
||||
|
||||
expect(response).toBeDefined();
|
||||
expect(response.role).toBe("assistant");
|
||||
|
||||
// Should handle empty string gracefully
|
||||
if (response.stopReason === "error") {
|
||||
expect(response.errorMessage).toBeDefined();
|
||||
} else {
|
||||
expect(response.content).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
async function testWhitespaceOnlyMessage<TApi extends Api>(llm: Model<TApi>, options: OptionsForApi<TApi> = {}) {
|
||||
// Test with whitespace-only content
|
||||
const context: Context = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: " \n\t ",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await complete(llm, context, options);
|
||||
|
||||
expect(response).toBeDefined();
|
||||
expect(response.role).toBe("assistant");
|
||||
|
||||
// Should handle whitespace-only gracefully
|
||||
if (response.stopReason === "error") {
|
||||
expect(response.errorMessage).toBeDefined();
|
||||
} else {
|
||||
expect(response.content).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
async function testEmptyAssistantMessage<TApi extends Api>(llm: Model<TApi>, options: OptionsForApi<TApi> = {}) {
|
||||
// Test with empty assistant message in conversation flow
|
||||
// User -> Empty Assistant -> User
|
||||
const emptyAssistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: llm.api,
|
||||
provider: llm.provider,
|
||||
model: llm.id,
|
||||
usage: {
|
||||
input: 10,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 10,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hello, how are you?",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
emptyAssistant,
|
||||
{
|
||||
role: "user",
|
||||
content: "Please respond this time.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await complete(llm, context, options);
|
||||
|
||||
expect(response).toBeDefined();
|
||||
expect(response.role).toBe("assistant");
|
||||
|
||||
// Should handle empty assistant message in context gracefully
|
||||
if (response.stopReason === "error") {
|
||||
expect(response.errorMessage).toBeDefined();
|
||||
} else {
|
||||
expect(response.content).toBeDefined();
|
||||
expect(response.content.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
describe("AI Providers Empty Message Tests", () => {
|
||||
describe.skipIf(!e2eApiKey("GEMINI_API_KEY"))("Google Provider Empty Messages", () => {
|
||||
const llm = getBundledModel("google", "gemini-2.5-flash");
|
||||
|
||||
it(
|
||||
"should handle empty content array",
|
||||
async () => {
|
||||
await testEmptyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty string content",
|
||||
async () => {
|
||||
await testEmptyStringMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle whitespace-only content",
|
||||
async () => {
|
||||
await testWhitespaceOnlyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
await testEmptyAssistantMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions Provider Empty Messages", () => {
|
||||
const llm = getBundledModel("openai", "gpt-4o-mini");
|
||||
|
||||
it(
|
||||
"should handle empty content array",
|
||||
async () => {
|
||||
await testEmptyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty string content",
|
||||
async () => {
|
||||
await testEmptyStringMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle whitespace-only content",
|
||||
async () => {
|
||||
await testWhitespaceOnlyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
await testEmptyAssistantMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider Empty Messages", () => {
|
||||
const llm = getBundledModel("openai", "gpt-5-mini");
|
||||
|
||||
it(
|
||||
"should handle empty content array",
|
||||
async () => {
|
||||
await testEmptyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty string content",
|
||||
async () => {
|
||||
await testEmptyStringMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle whitespace-only content",
|
||||
async () => {
|
||||
await testWhitespaceOnlyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
await testEmptyAssistantMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Anthropic Provider Empty Messages", () => {
|
||||
const llm = getBundledModel("anthropic", "claude-haiku-4-5-20251001");
|
||||
|
||||
it(
|
||||
"should handle empty content array",
|
||||
async () => {
|
||||
await testEmptyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty string content",
|
||||
async () => {
|
||||
await testEmptyStringMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle whitespace-only content",
|
||||
async () => {
|
||||
await testWhitespaceOnlyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
await testEmptyAssistantMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(!e2eApiKey("XAI_API_KEY"))("xAI Provider Empty Messages", () => {
|
||||
const llm = getBundledModel("xai", "grok-3");
|
||||
|
||||
it(
|
||||
"should handle empty content array",
|
||||
async () => {
|
||||
await testEmptyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty string content",
|
||||
async () => {
|
||||
await testEmptyStringMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle whitespace-only content",
|
||||
async () => {
|
||||
await testWhitespaceOnlyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
await testEmptyAssistantMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(!e2eApiKey("GROQ_API_KEY"))("Groq Provider Empty Messages", () => {
|
||||
const llm = getBundledModel("groq", "openai/gpt-oss-20b");
|
||||
|
||||
it(
|
||||
"should handle empty content array",
|
||||
async () => {
|
||||
await testEmptyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty string content",
|
||||
async () => {
|
||||
await testEmptyStringMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle whitespace-only content",
|
||||
async () => {
|
||||
await testWhitespaceOnlyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
await testEmptyAssistantMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(!e2eApiKey("CEREBRAS_API_KEY"))("Cerebras Provider Empty Messages", () => {
|
||||
const llm = getBundledModel("cerebras", "gpt-oss-120b");
|
||||
|
||||
it(
|
||||
"should handle empty content array",
|
||||
async () => {
|
||||
await testEmptyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty string content",
|
||||
async () => {
|
||||
await testEmptyStringMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle whitespace-only content",
|
||||
async () => {
|
||||
await testWhitespaceOnlyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
await testEmptyAssistantMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(!e2eApiKey("ZAI_API_KEY"))("zAI Provider Empty Messages", () => {
|
||||
const llm = getBundledModel("zai", "glm-4.5-air");
|
||||
|
||||
it(
|
||||
"should handle empty content array",
|
||||
async () => {
|
||||
await testEmptyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty string content",
|
||||
async () => {
|
||||
await testEmptyStringMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle whitespace-only content",
|
||||
async () => {
|
||||
await testWhitespaceOnlyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
await testEmptyAssistantMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(!e2eApiKey("MISTRAL_API_KEY"))("Mistral Provider Empty Messages", () => {
|
||||
const llm = getBundledModel("mistral", "devstral-medium-latest");
|
||||
|
||||
it(
|
||||
"should handle empty content array",
|
||||
async () => {
|
||||
await testEmptyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty string content",
|
||||
async () => {
|
||||
await testEmptyStringMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle whitespace-only content",
|
||||
async () => {
|
||||
await testWhitespaceOnlyMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
await testEmptyAssistantMessage(llm);
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe("Anthropic OAuth Provider Empty Messages", () => {
|
||||
const llm = getBundledModel("anthropic", "claude-haiku-4-5-20251001");
|
||||
|
||||
it.skipIf(!anthropicOAuthToken)(
|
||||
"should handle empty content array",
|
||||
async () => {
|
||||
await testEmptyMessage(llm, { apiKey: anthropicOAuthToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!anthropicOAuthToken)(
|
||||
"should handle empty string content",
|
||||
async () => {
|
||||
await testEmptyStringMessage(llm, { apiKey: anthropicOAuthToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!anthropicOAuthToken)(
|
||||
"should handle whitespace-only content",
|
||||
async () => {
|
||||
await testWhitespaceOnlyMessage(llm, { apiKey: anthropicOAuthToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!anthropicOAuthToken)(
|
||||
"should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
await testEmptyAssistantMessage(llm, { apiKey: anthropicOAuthToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe("GitHub Copilot Provider Empty Messages", () => {
|
||||
it.skipIf(!githubCopilotToken)(
|
||||
"gpt-4o - should handle empty content array",
|
||||
async () => {
|
||||
const llm = getBundledModel("github-copilot", "gpt-4o");
|
||||
await testEmptyMessage(llm, { apiKey: githubCopilotToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!githubCopilotToken)(
|
||||
"gpt-4o - should handle empty string content",
|
||||
async () => {
|
||||
const llm = getBundledModel("github-copilot", "gpt-4o");
|
||||
await testEmptyStringMessage(llm, { apiKey: githubCopilotToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!githubCopilotToken)(
|
||||
"gpt-4o - should handle whitespace-only content",
|
||||
async () => {
|
||||
const llm = getBundledModel("github-copilot", "gpt-4o");
|
||||
await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!githubCopilotToken)(
|
||||
"gpt-4o - should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
const llm = getBundledModel("github-copilot", "gpt-4o");
|
||||
await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!githubCopilotToken)(
|
||||
"claude-sonnet-4 - should handle empty content array",
|
||||
async () => {
|
||||
const llm = getBundledModel("github-copilot", "claude-sonnet-4");
|
||||
await testEmptyMessage(llm, { apiKey: githubCopilotToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!githubCopilotToken)(
|
||||
"claude-sonnet-4 - should handle empty string content",
|
||||
async () => {
|
||||
const llm = getBundledModel("github-copilot", "claude-sonnet-4");
|
||||
await testEmptyStringMessage(llm, { apiKey: githubCopilotToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!githubCopilotToken)(
|
||||
"claude-sonnet-4 - should handle whitespace-only content",
|
||||
async () => {
|
||||
const llm = getBundledModel("github-copilot", "claude-sonnet-4");
|
||||
await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!githubCopilotToken)(
|
||||
"claude-sonnet-4 - should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
const llm = getBundledModel("github-copilot", "claude-sonnet-4");
|
||||
await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe("Google Gemini CLI Provider Empty Messages", () => {
|
||||
it.skipIf(!geminiCliToken)(
|
||||
"gemini-2.5-flash - should handle empty content array",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash");
|
||||
await testEmptyMessage(llm, { apiKey: geminiCliToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!geminiCliToken)(
|
||||
"gemini-2.5-flash - should handle empty string content",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash");
|
||||
await testEmptyStringMessage(llm, { apiKey: geminiCliToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!geminiCliToken)(
|
||||
"gemini-2.5-flash - should handle whitespace-only content",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash");
|
||||
await testWhitespaceOnlyMessage(llm, { apiKey: geminiCliToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!geminiCliToken)(
|
||||
"gemini-2.5-flash - should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash");
|
||||
await testEmptyAssistantMessage(llm, { apiKey: geminiCliToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe("Google Antigravity Provider Empty Messages", () => {
|
||||
it.skipIf(!antigravityToken)(
|
||||
"gemini-3-flash - should handle empty content array",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-antigravity", "gemini-3-flash");
|
||||
await testEmptyMessage(llm, { apiKey: antigravityToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"gemini-3-flash - should handle empty string content",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-antigravity", "gemini-3-flash");
|
||||
await testEmptyStringMessage(llm, { apiKey: antigravityToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"gemini-3-flash - should handle whitespace-only content",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-antigravity", "gemini-3-flash");
|
||||
await testWhitespaceOnlyMessage(llm, { apiKey: antigravityToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"gemini-3-flash - should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-antigravity", "gemini-3-flash");
|
||||
await testEmptyAssistantMessage(llm, { apiKey: antigravityToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"claude-sonnet-4-5 - should handle empty content array",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5");
|
||||
await testEmptyMessage(llm, { apiKey: antigravityToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"claude-sonnet-4-5 - should handle empty string content",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5");
|
||||
await testEmptyStringMessage(llm, { apiKey: antigravityToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"claude-sonnet-4-5 - should handle whitespace-only content",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5");
|
||||
await testWhitespaceOnlyMessage(llm, { apiKey: antigravityToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"claude-sonnet-4-5 - should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5");
|
||||
await testEmptyAssistantMessage(llm, { apiKey: antigravityToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"gpt-oss-120b-medium - should handle empty content array",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium");
|
||||
await testEmptyMessage(llm, { apiKey: antigravityToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"gpt-oss-120b-medium - should handle empty string content",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium");
|
||||
await testEmptyStringMessage(llm, { apiKey: antigravityToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"gpt-oss-120b-medium - should handle whitespace-only content",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium");
|
||||
await testWhitespaceOnlyMessage(llm, { apiKey: antigravityToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"gpt-oss-120b-medium - should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium");
|
||||
await testEmptyAssistantMessage(llm, { apiKey: antigravityToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe("OpenAI Codex Provider Empty Messages", () => {
|
||||
it.skipIf(!openaiCodexToken)(
|
||||
"gpt-5.2-codex - should handle empty content array",
|
||||
async () => {
|
||||
const llm = getBundledModel("openai-codex", "gpt-5.2-codex");
|
||||
await testEmptyMessage(llm, { apiKey: openaiCodexToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!openaiCodexToken)(
|
||||
"gpt-5.2-codex - should handle empty string content",
|
||||
async () => {
|
||||
const llm = getBundledModel("openai-codex", "gpt-5.2-codex");
|
||||
await testEmptyStringMessage(llm, { apiKey: openaiCodexToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!openaiCodexToken)(
|
||||
"gpt-5.2-codex - should handle whitespace-only content",
|
||||
async () => {
|
||||
const llm = getBundledModel("openai-codex", "gpt-5.2-codex");
|
||||
await testWhitespaceOnlyMessage(llm, { apiKey: openaiCodexToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
|
||||
it.skipIf(!openaiCodexToken)(
|
||||
"gpt-5.2-codex - should handle empty assistant message in conversation",
|
||||
async () => {
|
||||
const llm = getBundledModel("openai-codex", "gpt-5.2-codex");
|
||||
await testEmptyAssistantMessage(llm, { apiKey: openaiCodexToken });
|
||||
},
|
||||
{ retry: 3, timeout: 30000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { getBundledModel } from "../src/models";
|
||||
|
||||
describe("Copilot Claude model routing", () => {
|
||||
it("routes claude-sonnet-4 via anthropic-messages API", () => {
|
||||
const model = getBundledModel("github-copilot", "claude-sonnet-4");
|
||||
expect(model).toBeDefined();
|
||||
expect(model.api).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
it("routes claude-sonnet-4.5 via anthropic-messages API", () => {
|
||||
const model = getBundledModel("github-copilot", "claude-sonnet-4.5");
|
||||
expect(model).toBeDefined();
|
||||
expect(model.api).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
it("routes claude-haiku-4.5 via anthropic-messages API", () => {
|
||||
const model = getBundledModel("github-copilot", "claude-haiku-4.5");
|
||||
expect(model).toBeDefined();
|
||||
expect(model.api).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
it("routes claude-opus-4.5 via anthropic-messages API", () => {
|
||||
const model = getBundledModel("github-copilot", "claude-opus-4.5");
|
||||
expect(model).toBeDefined();
|
||||
expect(model.api).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
it("does not have compat block on Claude models (completions-API-specific)", () => {
|
||||
const sonnet = getBundledModel("github-copilot", "claude-sonnet-4");
|
||||
expect("compat" in sonnet).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves static Copilot headers on Claude models", () => {
|
||||
const model = getBundledModel("github-copilot", "claude-sonnet-4");
|
||||
expect(model.headers).toBeDefined();
|
||||
expect(model.headers?.["User-Agent"]).toContain("opencode");
|
||||
});
|
||||
|
||||
it("keeps non-Claude Copilot models on their existing APIs", () => {
|
||||
const gpt4o = getBundledModel("github-copilot", "gpt-4o");
|
||||
expect(gpt4o).toBeDefined();
|
||||
expect(gpt4o.api).toBe("openai-completions");
|
||||
|
||||
const gpt5 = getBundledModel("github-copilot", "gpt-5");
|
||||
expect(gpt5).toBeDefined();
|
||||
expect(gpt5.api).toBe("openai-responses");
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getModelMapping } from "../src/providers/gitlab-duo";
|
||||
|
||||
describe("gitlab duo model mapping", () => {
|
||||
test("resolves Duo alias IDs", () => {
|
||||
const mapping = getModelMapping("duo-chat-gpt-5-codex");
|
||||
expect(mapping).toBeDefined();
|
||||
expect(mapping?.model).toBe("gpt-5-codex");
|
||||
});
|
||||
|
||||
test("resolves canonical model IDs", () => {
|
||||
const mapping = getModelMapping("gpt-5-codex");
|
||||
expect(mapping).toBeDefined();
|
||||
expect(mapping?.model).toBe("gpt-5-codex");
|
||||
});
|
||||
|
||||
test("returns undefined for unknown IDs", () => {
|
||||
expect(getModelMapping("totally-unknown-model")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { Effort } from "@oh-my-pi/pi-ai";
|
||||
import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking";
|
||||
import { hookFetch } from "@oh-my-pi/pi-utils";
|
||||
import { getBundledModel } from "../src/models";
|
||||
import { streamSimple } from "../src/stream";
|
||||
import type { Context, Model } from "../src/types";
|
||||
|
||||
@@ -48,10 +47,6 @@ describe("google-gemini-cli Gemini 3.x thinking mapping", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("includes gemini-3.1-pro-preview in bundled google-gemini-cli models", () => {
|
||||
expect(getBundledModel("google-gemini-cli", "gemini-3.1-pro-preview")?.id).toBe("gemini-3.1-pro-preview");
|
||||
});
|
||||
it("uses thinkingLevel for gemini-3.1-pro-preview when the effort is supported", async () => {
|
||||
let requestBody: string | undefined;
|
||||
using _hook = hookFetch((_input, init) => {
|
||||
|
||||
@@ -16,12 +16,6 @@ const OPENCODE_GO_BASE = "https://opencode.ai/zen/go/v1";
|
||||
describe("opencode-go resolver routes 404-ing ids to openai-completions (issue #887)", () => {
|
||||
const descriptor = MODELS_DEV_PROVIDER_DESCRIPTORS.find(d => d.providerId === "opencode-go");
|
||||
|
||||
test("descriptor exists and exposes resolveApi", () => {
|
||||
expect(descriptor).toBeDefined();
|
||||
expect(descriptor?.modelsDevKey).toBe("opencode-go");
|
||||
expect(descriptor?.resolveApi).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
// Per upstream models.dev (verified 2026-05-02 against
|
||||
// https://models.dev/api.json["opencode-go"].models), these three ids carry
|
||||
// `provider.npm = "@ai-sdk/anthropic"`. The naive @ai-sdk/anthropic rule
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors";
|
||||
import { kiloModelManagerOptions } from "../src/provider-models/openai-compat";
|
||||
import { getEnvApiKey } from "../src/stream";
|
||||
import { getOAuthProviders } from "../src/utils/oauth";
|
||||
|
||||
const originalKiloApiKey = Bun.env.KILO_API_KEY;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalKiloApiKey === undefined) {
|
||||
delete Bun.env.KILO_API_KEY;
|
||||
return;
|
||||
}
|
||||
Bun.env.KILO_API_KEY = originalKiloApiKey;
|
||||
});
|
||||
|
||||
describe("kilo provider support", () => {
|
||||
test("resolves KILO_API_KEY from environment", () => {
|
||||
Bun.env.KILO_API_KEY = "kilo-test-key";
|
||||
expect(getEnvApiKey("kilo")).toBe("kilo-test-key");
|
||||
});
|
||||
|
||||
test("registers built-in descriptor and default model", () => {
|
||||
const descriptor = PROVIDER_DESCRIPTORS.find(item => item.providerId === "kilo");
|
||||
expect(descriptor).toBeDefined();
|
||||
expect(descriptor?.defaultModel).toBe("anthropic/claude-sonnet-4.5");
|
||||
expect(descriptor?.catalogDiscovery?.envVars).toContain("KILO_API_KEY");
|
||||
expect(descriptor?.catalogDiscovery?.allowUnauthenticated).toBe(true);
|
||||
expect(DEFAULT_MODEL_PER_PROVIDER.kilo).toBe("anthropic/claude-sonnet-4.5");
|
||||
});
|
||||
|
||||
test("registers Kilo in OAuth provider selector", () => {
|
||||
const provider = getOAuthProviders().find(item => item.id === "kilo");
|
||||
expect(provider?.name).toBe("Kilo Gateway");
|
||||
});
|
||||
test("builds model manager options with kilo defaults", () => {
|
||||
const options = kiloModelManagerOptions();
|
||||
expect(options.providerId).toBe("kilo");
|
||||
expect(options.fetchDynamicModels).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
requireSupportedEffort,
|
||||
} from "@oh-my-pi/pi-ai/model-thinking";
|
||||
import type { Api, Model, Provider } from "@oh-my-pi/pi-ai/types";
|
||||
import { getBundledModel } from "../src/models";
|
||||
import MODELS from "../src/models.json" with { type: "json" };
|
||||
|
||||
function createModel<TApi extends Api>(overrides: {
|
||||
id: string;
|
||||
@@ -133,52 +131,6 @@ describe("model thinking metadata", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundled GPT-5.4 model metadata", () => {
|
||||
it("stores raw GPT-5.4 mini/nano catalog metadata for OpenAI, OpenAI Codex, and Copilot", () => {
|
||||
const openAiMini = MODELS.openai["gpt-5.4-mini"];
|
||||
const openAiNano = MODELS.openai["gpt-5.4-nano"];
|
||||
const openAiCodexMini = MODELS["openai-codex"]["gpt-5.4-mini"];
|
||||
const openAiCodexNano = MODELS["openai-codex"]["gpt-5.4-nano"];
|
||||
const copilotMini = MODELS["github-copilot"]["gpt-5.4-mini"];
|
||||
|
||||
expect(openAiMini?.thinking).toEqual({ mode: "effort", minLevel: "low", maxLevel: "xhigh" });
|
||||
expect(openAiNano?.thinking).toEqual({ mode: "effort", minLevel: "low", maxLevel: "xhigh" });
|
||||
expect(openAiCodexMini?.thinking).toEqual({ mode: "effort", minLevel: "low", maxLevel: "xhigh" });
|
||||
expect(openAiCodexNano?.thinking).toEqual({ mode: "effort", minLevel: "low", maxLevel: "xhigh" });
|
||||
expect(copilotMini?.thinking).toEqual({ mode: "effort", minLevel: "low", maxLevel: "xhigh" });
|
||||
expect(openAiCodexMini?.api).toBe("openai-codex-responses");
|
||||
expect(openAiCodexNano?.api).toBe("openai-codex-responses");
|
||||
expect(openAiCodexMini?.contextWindow).toBe(272000);
|
||||
expect(openAiCodexNano?.contextWindow).toBe(272000);
|
||||
expect(openAiCodexMini?.preferWebsockets).toBe(true);
|
||||
expect(openAiCodexNano?.preferWebsockets).toBe(true);
|
||||
expect(openAiCodexMini?.priority).toBe(1);
|
||||
expect(openAiCodexNano?.priority).toBe(2);
|
||||
});
|
||||
|
||||
it("exposes xhigh support for bundled GPT-5.4 mini/nano runtime models across supported providers", () => {
|
||||
const openAiMini = getBundledModel("openai", "gpt-5.4-mini");
|
||||
const openAiNano = getBundledModel("openai", "gpt-5.4-nano");
|
||||
const openAiCodexMini = getBundledModel("openai-codex", "gpt-5.4-mini");
|
||||
const openAiCodexNano = getBundledModel("openai-codex", "gpt-5.4-nano");
|
||||
const copilotMini = getBundledModel("github-copilot", "gpt-5.4-mini");
|
||||
|
||||
expect(openAiCodexMini.contextWindow).toBe(272000);
|
||||
expect(openAiCodexNano.contextWindow).toBe(272000);
|
||||
expect(requireSupportedEffort(openAiMini, Effort.XHigh)).toBe(Effort.XHigh);
|
||||
expect(requireSupportedEffort(openAiNano, Effort.XHigh)).toBe(Effort.XHigh);
|
||||
expect(requireSupportedEffort(openAiCodexMini, Effort.XHigh)).toBe(Effort.XHigh);
|
||||
expect(requireSupportedEffort(openAiCodexNano, Effort.XHigh)).toBe(Effort.XHigh);
|
||||
expect(requireSupportedEffort(copilotMini, Effort.XHigh)).toBe(Effort.XHigh);
|
||||
});
|
||||
|
||||
it("does not bundle GitHub Copilot GPT-5.4 nano", () => {
|
||||
const copilotModels = MODELS["github-copilot"] as Record<string, unknown>;
|
||||
expect(copilotModels["gpt-5.4-nano"]).toBeUndefined();
|
||||
expect(getBundledModel("github-copilot", "gpt-5.4-nano")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated model policies", () => {
|
||||
it("refreshes thinking metadata and applies parsed catalog corrections", () => {
|
||||
const models: Model<Api>[] = [
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from "bun:test";
|
||||
import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors";
|
||||
import { ollamaCloudModelManagerOptions } from "../src/provider-models/ollama";
|
||||
import { completeSimple, getEnvApiKey, stream, streamSimple } from "../src/stream";
|
||||
import type { Context, Model, Tool } from "../src/types";
|
||||
import { getOAuthProviders } from "../src/utils/oauth";
|
||||
|
||||
const originalApiKey = Bun.env.OLLAMA_CLOUD_API_KEY;
|
||||
const originalFetch = global.fetch;
|
||||
@@ -64,18 +62,6 @@ describe("ollama-cloud provider support", () => {
|
||||
expect(getEnvApiKey("ollama-cloud")).toBe("ollama-cloud-test-key");
|
||||
});
|
||||
|
||||
test("registers built-in descriptor, default model, and oauth selector entry", () => {
|
||||
const descriptor = PROVIDER_DESCRIPTORS.find(item => item.providerId === "ollama-cloud");
|
||||
expect(descriptor).toBeDefined();
|
||||
expect(descriptor?.defaultModel).toBe("gpt-oss:120b");
|
||||
expect(descriptor?.catalogDiscovery?.envVars).toContain("OLLAMA_CLOUD_API_KEY");
|
||||
expect(descriptor?.catalogDiscovery?.allowUnauthenticated).toBeUndefined();
|
||||
expect(DEFAULT_MODEL_PER_PROVIDER["ollama-cloud"]).toBe("gpt-oss:120b");
|
||||
|
||||
const provider = getOAuthProviders().find(item => item.id === "ollama-cloud");
|
||||
expect(provider?.name).toBe("Ollama Cloud");
|
||||
});
|
||||
|
||||
test("discovers ollama-cloud models from native cloud endpoints", async () => {
|
||||
global.fetch = vi.fn(async (input, init) => {
|
||||
const url = String(input);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { getBundledModel } from "../src/models";
|
||||
import {
|
||||
normalizeOpenAIResponsesPromptCacheKey,
|
||||
type OpenAIResponsesOptions,
|
||||
streamOpenAIResponses,
|
||||
} from "../src/providers/openai-responses";
|
||||
import { type OpenAIResponsesOptions, streamOpenAIResponses } from "../src/providers/openai-responses";
|
||||
import type { Context, Model } from "../src/types";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
@@ -115,23 +111,4 @@ describe("openai-responses cache affinity", () => {
|
||||
expect(captured.clientRequestId).toBeNull();
|
||||
expect(captured.body?.prompt_cache_key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes long prompt cache keys while preserving ordered system prompts", async () => {
|
||||
const longSessionId = "session-".repeat(20);
|
||||
const expectedCacheKey = normalizeOpenAIResponsesPromptCacheKey(longSessionId);
|
||||
if (!expectedCacheKey) throw new Error("Expected normalized prompt cache key");
|
||||
const captured = await captureOpenAIResponseHeaders({ sessionId: longSessionId });
|
||||
|
||||
expect(captured.sessionId).toBe(expectedCacheKey);
|
||||
expect(captured.clientRequestId).toBe(expectedCacheKey);
|
||||
expect(captured.body?.prompt_cache_key).toBe(expectedCacheKey);
|
||||
expect(expectedCacheKey?.length).toBeLessThanOrEqual(64);
|
||||
|
||||
const input = captured.body?.input;
|
||||
expect(Array.isArray(input)).toBe(true);
|
||||
expect((input as Array<{ role?: string; content?: string }>).slice(0, 2)).toEqual([
|
||||
{ role: "developer", content: "stable system" },
|
||||
{ role: "developer", content: "stable durable context" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,14 +48,6 @@ describe("parseRateLimitReason", () => {
|
||||
});
|
||||
|
||||
describe("calculateRateLimitBackoffMs", () => {
|
||||
it("returns 30 minutes for QUOTA_EXHAUSTED", () => {
|
||||
expect(calculateRateLimitBackoffMs("QUOTA_EXHAUSTED")).toBe(30 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("returns 30s for RATE_LIMIT_EXCEEDED", () => {
|
||||
expect(calculateRateLimitBackoffMs("RATE_LIMIT_EXCEEDED")).toBe(30_000);
|
||||
});
|
||||
|
||||
it("returns 45–75s range for MODEL_CAPACITY_EXHAUSTED (jitter)", () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const ms = calculateRateLimitBackoffMs("MODEL_CAPACITY_EXHAUSTED");
|
||||
@@ -63,12 +55,4 @@ describe("calculateRateLimitBackoffMs", () => {
|
||||
expect(ms).toBeLessThanOrEqual(75_000);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 20s for SERVER_ERROR", () => {
|
||||
expect(calculateRateLimitBackoffMs("SERVER_ERROR")).toBe(20_000);
|
||||
});
|
||||
|
||||
it("returns conservative fallback for UNKNOWN", () => {
|
||||
expect(calculateRateLimitBackoffMs("UNKNOWN")).toBe(30 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import MODELS from "@oh-my-pi/pi-ai/models.json" with { type: "json" };
|
||||
import { complete } from "@oh-my-pi/pi-ai/stream";
|
||||
import type { Model } from "@oh-my-pi/pi-ai/types";
|
||||
import { e2eApiKey } from "./oauth";
|
||||
|
||||
describe.skipIf(!e2eApiKey("OPENCODE_API_KEY"))("OpenCode Models Smoke Test", () => {
|
||||
const providers = [
|
||||
{ key: "opencode-zen", label: "OpenCode Zen" },
|
||||
{ key: "opencode-go", label: "OpenCode Go" },
|
||||
] as const;
|
||||
|
||||
providers.forEach(({ key, label }) => {
|
||||
const providerModels = Object.values(MODELS[key]);
|
||||
providerModels.forEach(model => {
|
||||
it(`${label}: ${model.id}`, async () => {
|
||||
const response = await complete(model as unknown as Model, {
|
||||
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
|
||||
});
|
||||
|
||||
expect(response.content).toBeTruthy();
|
||||
expect(response.stopReason).toBe("stop");
|
||||
}, 60000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -228,18 +228,4 @@ describe("AsyncJobManager", () => {
|
||||
expect(manager.getAllJobs()).toHaveLength(0);
|
||||
expect(manager.hasPendingDeliveries()).toBe(false);
|
||||
});
|
||||
|
||||
test("uses custom id when provided and deduplicates collisions", 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" });
|
||||
|
||||
expect(first).toBe("2-CheckRustCrate");
|
||||
expect(second).toBe("2-CheckRustCrate-2");
|
||||
|
||||
await manager.waitForAll();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -85,29 +85,6 @@ describe("BtwController", () => {
|
||||
expect(controller.hasActiveRequest()).toBe(true);
|
||||
});
|
||||
|
||||
it("streams text deltas through onTextDelta into the panel", async () => {
|
||||
const deltas: string[] = [];
|
||||
const runEphemeralTurn = vi.fn(async (args: RunEphemeralTurnArgs) => {
|
||||
args.onTextDelta?.("Hel");
|
||||
args.onTextDelta?.("lo");
|
||||
return { replyText: "Hello", assistantMessage: createAssistantMessage("Hello") };
|
||||
});
|
||||
const ctx = makeCtx(makeFakeSession(runEphemeralTurn));
|
||||
const controller = new BtwController(ctx);
|
||||
|
||||
await controller.start("Hi?");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// Use the captured deltas to verify the callback is wired through.
|
||||
const callArg = runEphemeralTurn.mock.calls[0]?.[0];
|
||||
expect(callArg).toBeDefined();
|
||||
callArg?.onTextDelta?.("X");
|
||||
deltas.push("X");
|
||||
expect(deltas).toEqual(["X"]);
|
||||
expect(controller.hasActiveRequest()).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces a previous request by aborting it before issuing the next runEphemeralTurn", async () => {
|
||||
const signals: AbortSignal[] = [];
|
||||
let firstRelease!: () => void;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
|
||||
import type { TextContent, UserMessage } from "@oh-my-pi/pi-ai";
|
||||
import { Container } from "@oh-my-pi/pi-tui";
|
||||
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();
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,9 +3,12 @@ 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 {
|
||||
addArtifactsDirSource,
|
||||
InternalUrlRouter,
|
||||
resetInternalUrlStateForTests,
|
||||
setLocalOptions,
|
||||
} 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";
|
||||
|
||||
@@ -24,10 +27,17 @@ describe("SearchTool internal URL resolution", () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "grep-test-"));
|
||||
artifactsDir = path.join(tmpDir, "artifacts");
|
||||
await fs.mkdir(artifactsDir);
|
||||
|
||||
resetInternalUrlStateForTests();
|
||||
InternalUrlRouter.resetForTests();
|
||||
|
||||
addArtifactsDirSource(() => artifactsDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
resetInternalUrlStateForTests();
|
||||
InternalUrlRouter.resetForTests();
|
||||
});
|
||||
|
||||
function createSession(overrides: Partial<ToolSession> = {}): ToolSession {
|
||||
@@ -41,18 +51,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 +71,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 +87,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 +113,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 +129,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 +148,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 });
|
||||
setLocalOptions({ getArtifactsDir: () => artifactsDir, getSessionId: () => "session" });
|
||||
|
||||
const session = createSession({ hasEditTool: true });
|
||||
const tool = new SearchTool(session);
|
||||
|
||||
const result = await tool.execute("test-call", {
|
||||
@@ -187,8 +169,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 +184,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);
|
||||
|
||||
Vendored
-4
@@ -1150,8 +1150,6 @@ export interface ShellExecuteOptions {
|
||||
env?: Record<string, string>
|
||||
/** Environment variables to apply once per session. */
|
||||
sessionEnv?: Record<string, string>
|
||||
/** Run the command attached to a PTY. */
|
||||
pty?: boolean
|
||||
/** Timeout in milliseconds before cancelling the command. */
|
||||
timeoutMs?: number
|
||||
/** Optional snapshot file to source on session creation. */
|
||||
@@ -1180,8 +1178,6 @@ export interface ShellRunOptions {
|
||||
cwd?: string
|
||||
/** Environment variables to apply for this command only. */
|
||||
env?: Record<string, string>
|
||||
/** Run the command attached to a PTY. */
|
||||
pty?: boolean
|
||||
/** Timeout in milliseconds before cancelling the command. */
|
||||
timeoutMs?: number
|
||||
/** Abort signal for cancelling the operation. */
|
||||
|
||||
@@ -16,7 +16,7 @@ import * as path from "node:path";
|
||||
const nativeDir = path.join(import.meta.dir, "..", "native");
|
||||
const indexJsPath = path.join(nativeDir, "index.js");
|
||||
const indexDtsPath = path.join(nativeDir, "index.d.ts");
|
||||
const packageJsonPath = path.join(import.meta.dir, "..", "package.json");
|
||||
const _packageJsonPath = path.join(import.meta.dir, "..", "package.json");
|
||||
|
||||
const PUBLIC_SYMBOL_RE = /^export declare (?:class|function|enum) (\w+)/gm;
|
||||
|
||||
@@ -39,12 +39,6 @@ function esmExportsName(js: string, name: string): boolean {
|
||||
}
|
||||
|
||||
describe("issue 892: pi-natives public surface", () => {
|
||||
it("routes ESM package consumers to the generated ESM loader", async () => {
|
||||
const packageJson = await Bun.file(packageJsonPath).json();
|
||||
expect(packageJson.type).toBe("module");
|
||||
expect(packageJson.exports["."].import).toBe("./native/index.js");
|
||||
});
|
||||
|
||||
it("declares every public .d.ts symbol as an explicit ESM export", async () => {
|
||||
const [js, symbols] = await Promise.all([Bun.file(indexJsPath).text(), readPublicSymbols()]);
|
||||
expect(symbols.length).toBeGreaterThan(0);
|
||||
@@ -52,15 +46,4 @@ describe("issue 892: pi-natives public surface", () => {
|
||||
const missing = symbols.filter(name => !esmExportsName(js, name));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("exports ProcessStatus with the runtime shape consumers depend on", async () => {
|
||||
// Mirror the failing consumer's package import (packages/utils/src/procmgr.ts,
|
||||
// packages/coding-agent/src/tools/browser/attach.ts).
|
||||
const mod = await import("@oh-my-pi/pi-natives");
|
||||
const processStatusValues: Record<"Running" | "Exited", string> = {
|
||||
Running: mod.ProcessStatus.Running,
|
||||
Exited: mod.ProcessStatus.Exited,
|
||||
};
|
||||
expect(processStatusValues).toEqual({ Running: "running", Exited: "exited" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,54 +28,12 @@ describe("StdinBuffer", () => {
|
||||
}
|
||||
|
||||
describe("Regular Characters", () => {
|
||||
it("should pass through regular characters immediately", () => {
|
||||
processInput("a");
|
||||
expect(emittedSequences).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("should pass through multiple regular characters", () => {
|
||||
processInput("abc");
|
||||
expect(emittedSequences).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("should handle unicode characters", () => {
|
||||
processInput("hello \u4e16\u754c");
|
||||
expect(emittedSequences).toEqual(["h", "e", "l", "l", "o", " ", "\u4e16", "\u754c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complete Escape Sequences", () => {
|
||||
it("should pass through complete mouse SGR sequences", () => {
|
||||
const mouseSeq = "\x1b[<35;20;5m";
|
||||
processInput(mouseSeq);
|
||||
expect(emittedSequences).toEqual([mouseSeq]);
|
||||
});
|
||||
|
||||
it("should pass through complete arrow key sequences", () => {
|
||||
const upArrow = "\x1b[A";
|
||||
processInput(upArrow);
|
||||
expect(emittedSequences).toEqual([upArrow]);
|
||||
});
|
||||
|
||||
it("should pass through complete function key sequences", () => {
|
||||
const f1 = "\x1b[11~";
|
||||
processInput(f1);
|
||||
expect(emittedSequences).toEqual([f1]);
|
||||
});
|
||||
|
||||
it("should pass through meta key sequences", () => {
|
||||
const metaA = "\x1ba";
|
||||
processInput(metaA);
|
||||
expect(emittedSequences).toEqual([metaA]);
|
||||
});
|
||||
|
||||
it("should pass through SS3 sequences", () => {
|
||||
const ss3 = "\x1bOA";
|
||||
processInput(ss3);
|
||||
expect(emittedSequences).toEqual([ss3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Partial Escape Sequences", () => {
|
||||
it("should buffer incomplete mouse SGR sequence", () => {
|
||||
processInput("\x1b");
|
||||
@@ -130,21 +88,6 @@ describe("StdinBuffer", () => {
|
||||
});
|
||||
|
||||
describe("Mixed Content", () => {
|
||||
it("should handle characters followed by escape sequence", () => {
|
||||
processInput("abc\x1b[A");
|
||||
expect(emittedSequences).toEqual(["a", "b", "c", "\x1b[A"]);
|
||||
});
|
||||
|
||||
it("should handle escape sequence followed by characters", () => {
|
||||
processInput("\x1b[Aabc");
|
||||
expect(emittedSequences).toEqual(["\x1b[A", "a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("should handle multiple complete sequences", () => {
|
||||
processInput("\x1b[A\x1b[B\x1b[C");
|
||||
expect(emittedSequences).toEqual(["\x1b[A", "\x1b[B", "\x1b[C"]);
|
||||
});
|
||||
|
||||
it("should handle partial sequence with preceding characters", () => {
|
||||
processInput("abc\x1b[<35");
|
||||
expect(emittedSequences).toEqual(["a", "b", "c"]);
|
||||
@@ -156,18 +99,6 @@ describe("StdinBuffer", () => {
|
||||
});
|
||||
|
||||
describe("Kitty Keyboard Protocol", () => {
|
||||
it("should handle Kitty CSI u press events", () => {
|
||||
// Press 'a' in Kitty protocol
|
||||
processInput("\x1b[97u");
|
||||
expect(emittedSequences).toEqual(["\x1b[97u"]);
|
||||
});
|
||||
|
||||
it("should handle Kitty CSI u release events", () => {
|
||||
// Release 'a' in Kitty protocol
|
||||
processInput("\x1b[97;1:3u");
|
||||
expect(emittedSequences).toEqual(["\x1b[97;1:3u"]);
|
||||
});
|
||||
|
||||
it("should handle batched Kitty press and release", () => {
|
||||
// Press 'a', release 'a' batched together (common over SSH)
|
||||
processInput("\x1b[97u\x1b[97;1:3u");
|
||||
@@ -180,29 +111,12 @@ describe("StdinBuffer", () => {
|
||||
expect(emittedSequences).toEqual(["\x1b[97u", "\x1b[97;1:3u", "\x1b[98u", "\x1b[98;1:3u"]);
|
||||
});
|
||||
|
||||
it("should handle Kitty arrow keys with event type", () => {
|
||||
// Up arrow press with event type
|
||||
processInput("\x1b[1;1:1A");
|
||||
expect(emittedSequences).toEqual(["\x1b[1;1:1A"]);
|
||||
});
|
||||
|
||||
it("should handle Kitty functional keys with event type", () => {
|
||||
// Delete key release
|
||||
processInput("\x1b[3;1:3~");
|
||||
expect(emittedSequences).toEqual(["\x1b[3;1:3~"]);
|
||||
});
|
||||
|
||||
it("should handle plain characters mixed with Kitty sequences", () => {
|
||||
// Plain 'a' followed by Kitty release
|
||||
processInput("a\x1b[97;1:3u");
|
||||
expect(emittedSequences).toEqual(["a", "\x1b[97;1:3u"]);
|
||||
});
|
||||
|
||||
it("should handle Kitty sequence followed by plain characters", () => {
|
||||
processInput("\x1b[97ua");
|
||||
expect(emittedSequences).toEqual(["\x1b[97u", "a"]);
|
||||
});
|
||||
|
||||
it("should handle rapid typing simulation with Kitty protocol", () => {
|
||||
// Simulates typing "hi" quickly with releases interleaved
|
||||
processInput("\x1b[104u\x1b[104;1:3u\x1b[105u\x1b[105;1:3u");
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { formatContent } from "../src/formatter";
|
||||
|
||||
describe("formatContent", () => {
|
||||
it("pins .js files to the flow parser (no fallback to babel-ts)", async () => {
|
||||
const tsOnlySyntaxInJs = "namespace Foo { export const value = 1; }\n";
|
||||
const result = await formatContent("fixture.js", tsOnlySyntaxInJs);
|
||||
|
||||
expect(result.didFormat).toBe(false);
|
||||
expect(result.formatted).toBe(tsOnlySyntaxInJs);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getProjectDir, setProjectDir } from "../src/dirs";
|
||||
import { Snowflake } from "../src/snowflake";
|
||||
import { getDefaultTabWidth, getIndentation, setDefaultTabWidth } from "../src/tab-spacing";
|
||||
import { getIndentation, setDefaultTabWidth } from "../src/tab-spacing";
|
||||
|
||||
describe("spacing", () => {
|
||||
let tempDir = "";
|
||||
@@ -24,15 +24,6 @@ describe("spacing", () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses configurable default tab width", () => {
|
||||
expect(getDefaultTabWidth()).toBe(3);
|
||||
expect(" ".repeat(getIndentation())).toBe(" ");
|
||||
|
||||
setDefaultTabWidth(5);
|
||||
expect(getDefaultTabWidth()).toBe(5);
|
||||
expect(" ".repeat(getIndentation())).toBe(" ");
|
||||
});
|
||||
|
||||
it("resolves editorconfig rules for file path and falls back to default", async () => {
|
||||
const filePath = path.join(tempDir, "src", "feature.ts");
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
Reference in New Issue
Block a user