Files
thedotmack--claude-mem/tests/integrations/opencode-plugin-contract.test.ts
wehub-resource-sync f9447f8e5f
CI / typecheck · build · test · bundle-size (push) Failing after 1s
CI / clean-room dependency closure smoke (push) Failing after 1s
CI / server-runtime e2e (docker · pg + valkey) (push) Failing after 2s
Deploy Install Scripts / deploy (push) Failing after 2s
Windows / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:07:03 +08:00

166 lines
6.2 KiB
TypeScript

import { describe, it, expect } from "bun:test";
import {
ClaudeMemPlugin,
parseSearchResponse,
REGISTERED_OPENCODE_HOOKS,
REAL_OPENCODE_EVENT_TYPES,
} from "../../src/integrations/opencode-plugin/index";
/**
* 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("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_1", callID: "c1" },
{ title: "Read", output: "file contents", metadata: {}, args: { path: "/a" } },
);
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_response).toBe("file contents");
} 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");
});
});