Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions apps/mcp/src/__tests__/plugin-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { describe, expect, it, vi } from "vitest";
import { PluginRegistry } from "../plugin-loader.js";
import type { PacaConfig } from "../types/index.js";

const config: PacaConfig = {
apiKey: "test-key",
baseURL: "http://localhost:8080",
};

// ---------------------------------------------------------------------------
// PluginRegistry.getToolContext
// ---------------------------------------------------------------------------

describe("PluginRegistry.getToolContext", () => {
it("returns an empty array when no plugin is loaded", async () => {
const registry = new PluginRegistry([]);
const sections = await registry.getToolContext(
"get_task",
{ projectId: "p1", taskId: "t1" },
config,
);
expect(sections).toEqual([]);
});

it("never calls a plugin that didn't declare a hook for this toolId", async () => {
const getToolContext = vi.fn().mockResolvedValue("should not be called");
const registry = new PluginRegistry([
{
pluginId: "com.paca.no-hook",
entry: { tools: [], handleToolCall: vi.fn(), getToolContext },
toolContextHooks: [], // implements the method but never declared it
},
]);
const sections = await registry.getToolContext(
"get_task",
{ projectId: "p1", taskId: "t1" },
config,
);
expect(sections).toEqual([]);
expect(getToolContext).not.toHaveBeenCalled();
});

it("only calls plugins that declared a hook for the requested toolId", async () => {
const taskHook = vi.fn().mockResolvedValue("## GitHub\nBranch: feat/t1");
const sprintOnlyHook = vi.fn().mockResolvedValue("should not run for get_task");
const registry = new PluginRegistry([
{
pluginId: "com.paca.github",
entry: { tools: [], handleToolCall: vi.fn(), getToolContext: taskHook },
toolContextHooks: ["get_task"],
},
{
pluginId: "com.paca.other",
entry: {
tools: [],
handleToolCall: vi.fn(),
getToolContext: sprintOnlyHook,
},
toolContextHooks: ["list_sprints"],
},
]);

const sections = await registry.getToolContext(
"get_task",
{ projectId: "p1", taskId: "t1" },
config,
);
expect(sections).toEqual([
{ pluginId: "com.paca.github", text: "## GitHub\nBranch: feat/t1" },
]);
expect(taskHook).toHaveBeenCalledTimes(1);
expect(sprintOnlyHook).not.toHaveBeenCalled();
});

it("collects text from every declared plugin that returns a non-empty section", async () => {
const registry = new PluginRegistry([
{
pluginId: "com.paca.github",
entry: {
tools: [],
handleToolCall: vi.fn(),
getToolContext: vi.fn().mockResolvedValue("## GitHub\nBranch: feat/t1"),
},
toolContextHooks: ["get_task"],
},
{
pluginId: "com.paca.checklist",
entry: {
tools: [],
handleToolCall: vi.fn(),
getToolContext: vi.fn().mockResolvedValue("## Checklist\n- [ ] Item"),
},
toolContextHooks: ["get_task"],
},
]);
const sections = await registry.getToolContext(
"get_task",
{ projectId: "p1", taskId: "t1" },
config,
);
expect(sections).toEqual([
{ pluginId: "com.paca.github", text: "## GitHub\nBranch: feat/t1" },
{ pluginId: "com.paca.checklist", text: "## Checklist\n- [ ] Item" },
]);
});

it("omits plugins that resolve to null (nothing to contribute)", async () => {
const registry = new PluginRegistry([
{
pluginId: "com.paca.github",
entry: {
tools: [],
handleToolCall: vi.fn(),
getToolContext: vi.fn().mockResolvedValue(null),
},
toolContextHooks: ["get_task"],
},
]);
const sections = await registry.getToolContext(
"get_task",
{ projectId: "p1", taskId: "t1" },
config,
);
expect(sections).toEqual([]);
});

it("logs and contributes nothing when a declared plugin's module has no getToolContext", async () => {
const registry = new PluginRegistry([
{
pluginId: "com.paca.mismatched",
entry: { tools: [], handleToolCall: vi.fn() }, // no getToolContext impl
toolContextHooks: ["get_task"], // but manifest declares it
},
]);
const sections = await registry.getToolContext(
"get_task",
{ projectId: "p1", taskId: "t1" },
config,
);
expect(sections).toEqual([]);
});

it("swallows a throwing plugin without affecting the others", async () => {
const registry = new PluginRegistry([
{
pluginId: "com.paca.broken",
entry: {
tools: [],
handleToolCall: vi.fn(),
getToolContext: vi.fn().mockRejectedValue(new Error("boom")),
},
toolContextHooks: ["get_task"],
},
{
pluginId: "com.paca.bdd",
entry: {
tools: [],
handleToolCall: vi.fn(),
getToolContext: vi.fn().mockResolvedValue("## BDD\nScenario: X"),
},
toolContextHooks: ["get_task"],
},
]);
const sections = await registry.getToolContext(
"get_task",
{ projectId: "p1", taskId: "t1" },
config,
);
expect(sections).toEqual([
{ pluginId: "com.paca.bdd", text: "## BDD\nScenario: X" },
]);
});

it("passes toolId, args, and per-plugin context to getToolContext", async () => {
const getToolContext = vi.fn().mockResolvedValue("## GitHub\n...");
const registry = new PluginRegistry([
{
pluginId: "com.paca.github",
entry: { tools: [], handleToolCall: vi.fn(), getToolContext },
toolContextHooks: ["get_task"],
},
]);
await registry.getToolContext(
"get_task",
{ projectId: "p1", taskId: "t1" },
config,
);
expect(getToolContext).toHaveBeenCalledWith(
"get_task",
{ projectId: "p1", taskId: "t1" },
{
pluginId: "com.paca.github",
baseURL: config.baseURL,
apiKey: config.apiKey,
},
);
});
});
74 changes: 74 additions & 0 deletions apps/mcp/src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import type { PluginContextSection } from "../plugin-loader.js";
import { mergePluginContext } from "../server.js";

// ---------------------------------------------------------------------------
// mergePluginContext
// ---------------------------------------------------------------------------
//
// Regression coverage for the bug described in the getToolContext PR: a
// plugin's contributed text was originally appended as a separate trailing
// content block, which agents were observed treating as unrelated and
// ignoring (e.g. calling github_list_task_branches right after get_task
// despite the branch already being in a second block). The fix merges into
// the last existing text block instead.

describe("mergePluginContext", () => {
const section = (text: string, pluginId = "com.paca.github") =>
[{ pluginId, text }] satisfies PluginContextSection[];

it("merges a single section into the last text block", () => {
const result = {
content: [{ type: "text", text: "# Task: Fix login bug" }],
};
const merged = mergePluginContext(
result,
section("## GitHub\nBranch: feat/t1"),
);
expect(merged.content).toHaveLength(1);
expect(merged.content[0]).toEqual({
type: "text",
text: "# Task: Fix login bug\n\n## GitHub\nBranch: feat/t1",
});
});

it("joins multiple sections in the given order before merging", () => {
const result = { content: [{ type: "text", text: "# Task" }] };
const merged = mergePluginContext(result, [
{ pluginId: "com.paca.github", text: "## GitHub" },
{ pluginId: "com.paca.checklist", text: "## Checklist" },
]);
expect(merged.content[0].text).toBe("# Task\n\n## GitHub\n\n## Checklist");
});

it("appends a new text block when content is empty", () => {
const result = { content: [] };
const merged = mergePluginContext(result, section("## GitHub"));
expect(merged.content).toEqual([{ type: "text", text: "## GitHub" }]);
});

it("appends a new text block when the last block isn't type text", () => {
const result = {
content: [{ type: "image", data: "base64...", mimeType: "image/png" }],
};
const merged = mergePluginContext(result, section("## GitHub"));
expect(merged.content).toHaveLength(2);
expect(merged.content[1]).toEqual({ type: "text", text: "## GitHub" });
});

it("does not mutate the original result's content array", () => {
const originalContent = [{ type: "text", text: "# Task" }];
const result = { content: originalContent };
mergePluginContext(result, section("## GitHub"));
expect(originalContent).toEqual([{ type: "text", text: "# Task" }]);
});

it("preserves other fields on the result (e.g. isError: false)", () => {
const result = {
content: [{ type: "text", text: "# Task" }],
isError: false,
};
const merged = mergePluginContext(result, section("## GitHub"));
expect(merged.isError).toBe(false);
});
});
Loading