mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-09-20 04:23:02 +08:00
82a18e9292
* fix(opencode): stamp platform source on worker posts (#3678) * fix(opencode): scope platform source stamping to issue slice (#3678)
385 lines
16 KiB
TypeScript
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");
|
|
});
|
|
});
|