Files
thedotmack__claude-mem/tests/integrations/opencode-plugin-contract.test.ts
Rod Boev 82a18e9292 fix(opencode): stamp platformSource on worker posts (refs #3678) (#3767)
* fix(opencode): stamp platform source on worker posts (#3678)

* fix(opencode): scope platform source stamping to issue slice (#3678)
2026-09-10 20:40:33 -07:00

385 lines
16 KiB
TypeScript

import { describe, it, expect } from "bun:test";
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ClaudeMemPlugin,
parseSearchResponse,
REGISTERED_OPENCODE_HOOKS,
REAL_OPENCODE_EVENT_TYPES,
} from "../../src/integrations/opencode-plugin/index";
import { normalizePlatformSource } from "../../src/shared/platform-source";
/**
* Regression guard for plan-08 (OpenCode event-contract correctness).
*
* The old plugin subscribed to bus event names that do not exist in OpenCode
* (`session.created`, `message.updated`, `session.compacted`, `file.edited`,
* `session.deleted` on a `(name, payload)` switch) and parsed `data.items`
* instead of the worker's real `data.content` blocks — so it captured nothing
* and search always returned "No results". These tests fail CI if either
* contract regresses.
*/
// The real OpenCode plugin hook names. Anything the plugin returns as a hook
// key must be in this allowlist; a future typo (e.g. "session.created") fails.
const REAL_OPENCODE_HOOK_NAMES = new Set<string>([
"tool.execute.after",
"chat.message",
"event",
"experimental.session.compacting",
"tool.execute.before",
"permission.ask",
"auth",
"config",
// `tool` is the custom-tool registration map, part of the plugin return shape.
"tool",
]);
// Bus event names the old code used that DO NOT exist in OpenCode's contract.
const PHANTOM_BUS_EVENT_NAMES = [
"session.created",
"message.updated",
"session.compacted",
"file.edited",
];
const pluginCtx = {
client: {},
project: { name: "test-project", path: "/tmp/x" },
directory: "/tmp/x",
worktree: "/tmp/x",
serverUrl: new URL("http://127.0.0.1:1234"),
$: {},
};
describe("OpenCode plugin event contract", () => {
it("reads the worker port from persisted settings without importing worker-utils", () => {
const source = readFileSync(
"src/integrations/opencode-plugin/index.ts",
"utf8",
);
expect(source).not.toContain('from "../../shared/worker-utils.js"');
expect(source).toContain('SettingsDefaultsManager.loadFromFile(settingsPath).CLAUDE_MEM_WORKER_PORT');
});
it("uses the persisted worker port in OpenCode worker requests", async () => {
const dataDir = mkdtempSync(join(tmpdir(), "claude-mem-opencode-settings-"));
const originalDataDir = process.env.CLAUDE_MEM_DATA_DIR;
const originalPort = process.env.CLAUDE_MEM_WORKER_PORT;
process.env.CLAUDE_MEM_DATA_DIR = dataDir;
delete process.env.CLAUDE_MEM_WORKER_PORT;
writeFileSync(
join(dataDir, "settings.json"),
JSON.stringify({ CLAUDE_MEM_WORKER_PORT: "45678" }),
);
const originalFetch = globalThis.fetch;
const seenUrls: string[] = [];
globalThis.fetch = (async (url: string | URL | Request) => {
seenUrls.push(String(url));
return new Response(JSON.stringify({ status: "queued" }), { status: 200 });
}) as typeof fetch;
try {
const { ClaudeMemPlugin: ReloadedPlugin } = await import(
`../../src/integrations/opencode-plugin/index.ts?opencode-settings-${Date.now()}`
);
const plugin = await ReloadedPlugin(pluginCtx);
await plugin["tool.execute.after"](
{ tool: "read", sessionID: "ses_45678", callID: "c1" },
{ title: "Read", output: "file contents", metadata: {}, args: { path: "/a" } },
);
expect(seenUrls.some((url) => url.startsWith("http://127.0.0.1:45678/"))).toBe(true);
} finally {
globalThis.fetch = originalFetch;
if (originalDataDir === undefined) delete process.env.CLAUDE_MEM_DATA_DIR;
else process.env.CLAUDE_MEM_DATA_DIR = originalDataDir;
if (originalPort === undefined) delete process.env.CLAUDE_MEM_WORKER_PORT;
else process.env.CLAUDE_MEM_WORKER_PORT = originalPort;
rmSync(dataDir, { recursive: true, force: true });
}
});
it("only registers hooks that are part of OpenCode's real contract", async () => {
const plugin = await ClaudeMemPlugin(pluginCtx);
const hookKeys = Object.keys(plugin);
for (const key of hookKeys) {
expect(
REAL_OPENCODE_HOOK_NAMES.has(key),
`hook "${key}" is not a real OpenCode hook name`,
).toBe(true);
}
// The exported allowlist of hooks we bind to must itself be real.
for (const hook of REGISTERED_OPENCODE_HOOKS) {
expect(REAL_OPENCODE_HOOK_NAMES.has(hook)).toBe(true);
}
// The capture-critical hooks must be present.
expect(hookKeys).toContain("tool.execute.after");
expect(hookKeys).toContain("chat.message");
expect(hookKeys).toContain("experimental.session.compacting");
expect(hookKeys).toContain("event");
});
it("does not register the phantom bus event names as hooks", async () => {
const plugin = await ClaudeMemPlugin(pluginCtx);
const hookKeys = Object.keys(plugin);
for (const phantom of PHANTOM_BUS_EVENT_NAMES) {
expect(hookKeys).not.toContain(phantom);
}
});
it("only reacts to real bus event types", () => {
// session.idle / session.deleted are real OpenCode bus events; the phantom
// names must never appear in the reacted-to allowlist.
expect(REAL_OPENCODE_EVENT_TYPES).toContain("session.idle");
expect(REAL_OPENCODE_EVENT_TYPES).toContain("session.deleted");
for (const phantom of PHANTOM_BUS_EVENT_NAMES) {
expect(REAL_OPENCODE_EVENT_TYPES as readonly string[]).not.toContain(phantom);
}
});
it("posts observations to the worker via tool.execute.after", async () => {
const posts: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
posts.push({
url: String(url),
body: init?.body ? JSON.parse(String(init.body)) : null,
});
return new Response(JSON.stringify({ status: "queued" }), { status: 200 });
}) as typeof fetch;
try {
const plugin = await ClaudeMemPlugin(pluginCtx);
const toolAfter = plugin["tool.execute.after"];
await toolAfter(
{
tool: "read",
sessionID: "ses_input_only",
callID: "c1",
// Matches the issue-author's captured OpenCode payload: args are on input.
args: { path: "/a" },
},
{ title: "Read", output: "file contents", metadata: {} },
);
const initPost = posts.find((p) => p.url.includes("/api/sessions/init"));
const obsPost = posts.find((p) => p.url.includes("/api/sessions/observations"));
expect(initPost, "tool.execute.after should lazily init the session").toBeTruthy();
expect(obsPost, "tool.execute.after should POST an observation").toBeTruthy();
const obsBody = obsPost!.body as Record<string, unknown>;
expect(obsBody.tool_name).toBe("read");
expect(obsBody.tool_input).toEqual({ path: "/a" });
expect(obsBody.tool_response).toBe("file contents");
expect(obsBody.platformSource).toBe(normalizePlatformSource("opencode"));
} finally {
globalThis.fetch = originalFetch;
}
});
it("stamps every session-write POST and leaves GET and deletion unchanged", async () => {
const requests: Array<{ method: string; url: string; body: Record<string, unknown> | null }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
requests.push({
method: init?.method || "GET",
url: String(url),
body: init?.body ? JSON.parse(String(init.body)) : null,
});
return new Response(JSON.stringify({ content: [{ type: "text", text: "No observations found" }] }), {
status: 200,
});
}) as typeof fetch;
try {
const plugin = await ClaudeMemPlugin(pluginCtx);
const expectedPlatformSource = normalizePlatformSource("opencode");
const postHookInvocations: Record<string, () => Promise<void>> = {
"tool.execute.after": () => plugin["tool.execute.after"](
{ tool: "read", sessionID: "ses_contract_tool", callID: "c1" },
{ title: "Read", output: "tool output", metadata: {}, args: {} },
),
"chat.message": () => plugin["chat.message"](
{},
{
message: { role: "assistant", sessionID: "ses_contract_chat" },
parts: [{ type: "text", text: "assistant output" }],
},
),
"experimental.session.compacting": () => plugin["experimental.session.compacting"]({ sessionID: "ses_contract_compact" }),
event: () => plugin.event({ event: { type: "session.idle", properties: { sessionID: "ses_contract_idle" } } }),
};
for (const hook of REGISTERED_OPENCODE_HOOKS) {
const invoke = postHookInvocations[hook];
expect(invoke, `registered hook "${hook}" must have a POST contract case`).toBeDefined();
await invoke!();
}
const posts = requests.filter((request) => request.method === "POST");
expect(posts).toHaveLength(8);
expect(posts.map((request) => request.url)).toEqual([
expect.stringContaining("/api/sessions/init"),
expect.stringContaining("/api/sessions/observations"),
expect.stringContaining("/api/sessions/init"),
expect.stringContaining("/api/sessions/observations"),
expect.stringContaining("/api/sessions/init"),
expect.stringContaining("/api/sessions/summarize"),
expect.stringContaining("/api/sessions/init"),
expect.stringContaining("/api/sessions/summarize"),
]);
for (const post of posts) {
expect(post.body?.platformSource).toBe(expectedPlatformSource);
}
const postCountBeforeSearchAndDeletion = posts.length;
await plugin.tool.claude_mem_search.execute({ query: "auth" });
await plugin.event({ event: { type: "session.deleted", properties: { sessionID: "ses_contract_idle" } } });
expect(requests.filter((request) => request.method === "POST")).toHaveLength(
postCountBeforeSearchAndDeletion,
);
expect(requests.at(-1)?.method).toBe("GET");
} finally {
globalThis.fetch = originalFetch;
}
});
it("prefers input args when both hook payloads contain arguments", async () => {
const posts: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
posts.push({ url: String(url), body: init?.body ? JSON.parse(String(init.body)) : null });
return new Response("{}", { status: 200 });
}) as typeof fetch;
try {
const plugin = await ClaudeMemPlugin(pluginCtx);
await plugin["tool.execute.after"](
{ tool: "write", sessionID: "ses_precedence", callID: "c2", args: { path: "/input" } },
{ title: "Write", output: "ok", metadata: {}, args: { path: "/output" } },
);
const obsPost = posts.find((p) => p.url.includes("/api/sessions/observations"));
expect((obsPost!.body as Record<string, unknown>).tool_input).toEqual({ path: "/input" });
} finally {
globalThis.fetch = originalFetch;
}
});
it("retains output args as the fallback when input args are absent", async () => {
const posts: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
posts.push({ url: String(url), body: init?.body ? JSON.parse(String(init.body)) : null });
return new Response("{}", { status: 200 });
}) as typeof fetch;
try {
const plugin = await ClaudeMemPlugin(pluginCtx);
await plugin["tool.execute.after"](
{ tool: "read", sessionID: "ses_output_fallback", callID: "c3" },
{ title: "Read", output: "ok", metadata: {}, args: { path: "/fallback" } },
);
const obsPost = posts.find((p) => p.url.includes("/api/sessions/observations"));
expect((obsPost!.body as Record<string, unknown>).tool_input).toEqual({ path: "/fallback" });
} finally {
globalThis.fetch = originalFetch;
}
});
it("uses an empty object when neither hook payload contains args", async () => {
const posts: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
posts.push({ url: String(url), body: init?.body ? JSON.parse(String(init.body)) : null });
return new Response("{}", { status: 200 });
}) as typeof fetch;
try {
const plugin = await ClaudeMemPlugin(pluginCtx);
await plugin["tool.execute.after"](
{ tool: "list", sessionID: "ses_empty_fallback", callID: "c4" },
{ title: "List", output: "ok", metadata: {} },
);
const obsPost = posts.find((p) => p.url.includes("/api/sessions/observations"));
expect((obsPost!.body as Record<string, unknown>).tool_input).toEqual({});
expect((obsPost!.body as Record<string, unknown>).tool_name).toBe("list");
expect((obsPost!.body as Record<string, unknown>).tool_response).toBe("ok");
expect((obsPost!.body as Record<string, unknown>).cwd).toBe("/tmp/x");
} finally {
globalThis.fetch = originalFetch;
}
});
it("keeps the selected empty input object when output args are also present", async () => {
const posts: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
posts.push({ url: String(url), body: init?.body ? JSON.parse(String(init.body)) : null });
return new Response("{}", { status: 200 });
}) as typeof fetch;
try {
const plugin = await ClaudeMemPlugin(pluginCtx);
await plugin["tool.execute.after"](
{ tool: "read", sessionID: "ses_empty_input", callID: "c5", args: {} },
{ title: "Read", output: "ok", metadata: {}, args: { path: "/output" } },
);
const obsPost = posts.find((p) => p.url.includes("/api/sessions/observations"));
expect((obsPost!.body as Record<string, unknown>).tool_input).toEqual({});
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe("OpenCode search client response-shape contract", () => {
it("parses the worker's real data.content blocks and returns the rows", () => {
// This is exactly what SearchManager.searchObservations returns on a hit.
const workerResponse = JSON.stringify({
content: [
{
type: "text",
text:
'Found 2 observation(s) matching "auth"\n\n| # | Title |\n|---|---|\n1. Added login flow\n2. Fixed token refresh',
},
],
});
const rendered = parseSearchResponse(workerResponse, "auth");
expect(rendered).toContain("Found 2 observation(s)");
expect(rendered).toContain("Added login flow");
expect(rendered).toContain("Fixed token refresh");
expect(rendered).not.toContain("No results");
});
it("does NOT parse the old data.items shape (regression guard)", () => {
// The pre-fix worker contract was wrongly assumed to be { items: [...] }.
// A client that still reads data.items would render rows here; the real
// client reads data.content, so this is correctly reported as no results.
const oldShape = JSON.stringify({
items: [{ title: "should-not-render" }, { title: "also-not" }],
});
const rendered = parseSearchResponse(oldShape, "auth");
expect(rendered).toContain("No results");
expect(rendered).not.toContain("should-not-render");
});
it("returns a clear no-results message for the worker's empty-content shape", () => {
const emptyResponse = JSON.stringify({
content: [{ type: "text", text: 'No observations found matching "zzz"' }],
});
const rendered = parseSearchResponse(emptyResponse, "zzz");
expect(rendered).toContain("No observations found");
});
});