From 1a321b8c2c8fe801989e8cf90eae563e91059283 Mon Sep 17 00:00:00 2001 From: GRIVN Date: Sat, 3 Oct 2026 18:45:01 +0800 Subject: [PATCH] fix: support OpenCode v2 plugin lifecycle Export one shared definition for OpenCode v2 setup and v1.18.29+ server hooks. Inject recall into model-facing content, reuse it per session and user turn across tool continuations, and bound cached state and subprocess work. Resolve npm installations to their native executable so forced timeouts reap the memory command, including Windows installs. Validated go build, make test, focused plugin runtime and timeout tests, and 258 CLI E2E checks. Official OpenCode 2.0.18 and 1.18.29 runs against an isolated local model fixture verify plugin loading, recall, shell environment, and v2 compaction without paid providers. Document the supported versions and required setup refresh. --- README.md | 5 + docs/USAGE.md | 25 +++ docs/zh/README.md | 4 + docs/zh/USAGE.md | 22 +++ .../memory/setup/assets/opencode/mnemon.js | 132 +++++++++++--- internal/memory/setup/opencode_test.go | 6 +- test/memory/opencode/plugin.test.mjs | 168 ++++++++++++++++++ test/memory/opencode/plugin_test.go | 22 +++ test/memory/opencode/timeout.test.mjs | 65 +++++++ 9 files changed, 426 insertions(+), 23 deletions(-) create mode 100644 test/memory/opencode/plugin.test.mjs create mode 100644 test/memory/opencode/plugin_test.go create mode 100644 test/memory/opencode/timeout.test.mjs diff --git a/README.md b/README.md index dd292d51c..a8b5e8bc6 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,11 @@ generated guide through `opencode.json` instructions, and installs a native plugin in `.opencode/plugins/`. The plugin injects recall context before chat requests and adds Mnemon guidance to session compaction. +The plugin supports OpenCode v2 and v1.18.29 or newer. After updating Mnemon, +rerun setup with the same scope (add `--global` for a user-wide installation), +then restart OpenCode to replace an older plugin. See the +[OpenCode setup notes](docs/USAGE.md#opencode) for runtime requirements. + ### [OpenClaw](https://github.com/openclaw/openclaw) ```bash diff --git a/docs/USAGE.md b/docs/USAGE.md index 765ac460c..54454146f 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -92,6 +92,31 @@ mnemon setup --eject --target claude-code | `--eject` | `false` | Remove mnemon integrations | | `--yes` | `false` | Auto-confirm all prompts | +### OpenCode + +The installed plugin supports OpenCode v2 (verified with 2.0.18) and v1.18.29 +or newer. Earlier v1 releases may require function exports instead of its shared +`server`/`setup` entrypoint; upgrade OpenCode before refreshing the integration. + +After updating Mnemon, run `mnemon setup --target opencode --yes` again in the +project, or add `--global` if the original installation was user-wide. Restart +OpenCode afterward. Updating the Mnemon binary alone does not replace an +already installed `.opencode/plugins/mnemon.js` (or +`~/.config/opencode/plugins/mnemon.js`). + +Keep `mnemon` on OpenCode's `PATH`. The plugin runs the CLI through Node's +child-process API, which both OpenCode runtimes provide; a separate Bun +installation is unnecessary. Native executables and standard npm installations +are supported on Unix and Windows. npm installations resolve directly to their +platform binary so a timeout also stops the memory command. + +Recall is added to the latest user message before each model request. Tool +continuations reuse that turn's recall without duplicating it; a new user turn +refreshes it. The cache is isolated by session, retains at most 128 sessions, +and is cleared on plugin unload. Compaction receives the durable-memory +guidance, and shell commands receive `MNEMON_OPENCODE=1`. Missing or failed CLI +calls leave the conversation usable; each call has a five-second timeout. + --- ## Memory CLI Commands diff --git a/docs/zh/README.md b/docs/zh/README.md index ff26ac1e6..309ca7eb5 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -210,6 +210,10 @@ OpenCode 会将 mnemon skill 部署到 `.opencode/skills/`,通过 `.opencode/plugins/` 安装原生 plugin。该 plugin 会在聊天请求前注入 recall context,并在 session compaction 中加入 Mnemon guidance。 +该 plugin 支持 OpenCode v2 和 v1.18.29 及以上版本。更新 Mnemon 后, +请按原安装范围重新运行 setup(用户级安装加 `--global`),再重启 OpenCode +以替换旧 plugin。运行环境要求见 [OpenCode 配置说明](USAGE.md#opencode)。 + ### [OpenClaw](https://github.com/openclaw/openclaw) ```bash diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 98682e265..b45a5c505 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -87,6 +87,28 @@ mnemon setup --eject --target claude-code | `--eject` | `false` | 移除 mnemon 集成 | | `--yes` | `false` | 自动确认所有提示 | +### OpenCode + +安装的 plugin 支持 OpenCode v2(已验证 2.0.18)和 v1.18.29 及以上版本。 +更早的 v1 加载器可能要求函数导出,无法使用共用的 `server`/`setup` 入口, +请先升级 OpenCode。 + +更新 Mnemon 后,在项目中重新运行 `mnemon setup --target opencode --yes`; +若原来是用户级安装,则加 `--global`。完成后重启 OpenCode。只更新 Mnemon +二进制不会替换已安装的 `.opencode/plugins/mnemon.js` 或 +`~/.config/opencode/plugins/mnemon.js`。 + +请确保 OpenCode 的 `PATH` 能找到 `mnemon`。该 plugin 使用两种 OpenCode +运行时均提供的 Node 子进程 API,无需单独安装 Bun。Unix 和 Windows 均支持 +原生可执行文件和标准 npm 安装。npm 安装会直接定位对应平台的二进制, +确保超时时能终止记忆命令。 + +每次模型请求前,recall 会加入最新的用户消息。工具续调用复用本轮 recall, +不会重复插入;新一轮用户消息会刷新 recall。缓存按 session 隔离,最多保留 +128 个 session,并在 plugin 卸载时清空。Compaction 会收到持久记忆指引, +shell 命令会收到 `MNEMON_OPENCODE=1`。CLI 不可用或调用失败不会阻断对话, +每次调用的超时上限为五秒。 + --- ## Memory CLI 命令 diff --git a/internal/memory/setup/assets/opencode/mnemon.js b/internal/memory/setup/assets/opencode/mnemon.js index 37c339086..2fd010a78 100644 --- a/internal/memory/setup/assets/opencode/mnemon.js +++ b/internal/memory/setup/assets/opencode/mnemon.js @@ -1,15 +1,63 @@ +import { spawnSync } from "node:child_process" +import { existsSync, readFileSync, realpathSync } from "node:fs" +import { createRequire } from "node:module" +import path from "node:path" +import process from "node:process" + const MAX_RECALL_CHARS = 4000 +const MAX_QUERY_CHARS = 8000 +const MAX_SESSIONS = 128 +const CONTEXT_MARKER = "" +const COMPACTION_GUIDANCE = `## Mnemon Memory + +Before compaction completes, preserve durable preferences, decisions, insights, facts, or context with mnemon remember/link when they will improve future continuity. Do not store secrets, credentials, or short-lived operational noise.` + +function npmBinary(entry) { + // Bypass npm's JS launcher so timeout kills the native process itself. Read + // its installed target registry instead of keeping a second platform list. + const root = path.dirname(path.dirname(entry)) + const targets = JSON.parse(readFileSync(path.join(root, "targets.json"), "utf8")) + const target = targets.find((item) => item.platform === process.platform && item.arch === process.arch) + if (!target) throw new Error("Unsupported Mnemon platform") + const require = createRequire(entry) + const native = path.dirname(require.resolve(`${target.alias}/package.json`)) + return path.join(native, target.binary) +} + +function mnemonCommand() { + for (const directory of (process.env.PATH || "").split(path.delimiter)) { + if (!directory) continue + const binary = path.join(directory, process.platform === "win32" ? "mnemon.exe" : "mnemon") + if (existsSync(binary)) { + const resolved = realpathSync(binary) + return resolved.endsWith(`${path.sep}bin${path.sep}mnemon.js`) ? npmBinary(resolved) : binary + } + if (process.platform === "win32") { + // Node cannot execute npm's .cmd shim directly. Locate the package + // without invoking a shell or interpreting the user's recall text. + for (const modules of [path.join(directory, "node_modules"), path.dirname(directory)]) { + const entry = path.join(modules, "@mnemon-dev", "mnemon", "bin", "mnemon.js") + if (existsSync(entry)) return npmBinary(entry) + } + } + } + return "mnemon" +} function runMnemon(args, options = {}) { try { - const proc = Bun.spawnSync(["mnemon", ...args], { - stdout: "pipe", - stderr: "pipe", + const proc = spawnSync(mnemonCommand(), args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], env: process.env, cwd: options.cwd || process.cwd(), + timeout: 5000, + killSignal: "SIGKILL", + maxBuffer: 256 * 1024, + windowsHide: true, }) - if (!proc.success) return "" - return new TextDecoder().decode(proc.stdout).trim() + if (proc.error || proc.status !== 0) return "" + return proc.stdout.trim() } catch { return "" } @@ -22,34 +70,73 @@ function textFromPart(part) { return "" } -function lastUserMessage(output) { +function lastUserMessage(output, partKey) { const messages = Array.isArray(output?.messages) ? output.messages : [] for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] const role = msg?.info?.role || msg?.role if (role !== "user") continue - const parts = Array.isArray(msg.parts) ? msg.parts : [] - return { msg, parts, text: parts.map(textFromPart).filter(Boolean).join("\n") } + if (!Array.isArray(msg[partKey])) continue + return { msg, parts: msg[partKey] } } return null } -function prependText(parts, text) { - if (!Array.isArray(parts) || text.trim() === "") return - parts.unshift({ type: "text", text }) -} - function buildRecallContext(query, cwd) { const status = runMnemon(["status"], { cwd }) const recall = query.trim() === "" ? "" : runMnemon(["recall", query, "--limit", "5"], { cwd }) const sections = [] - if (status) sections.push(`Status:\n${status}`) + if (status) sections.push(`Status:\n${status.slice(0, MAX_RECALL_CHARS)}`) if (recall) sections.push(`Relevant recall:\n${recall.slice(0, MAX_RECALL_CHARS)}`) sections.push("Use mnemon when it materially improves continuity. After responding, decide whether durable preferences, decisions, insights, facts, or context should be stored with mnemon remember/link.") return `\n\n\n${sections.join("\n\n")}\n\n\n` } -export const MnemonPlugin = async ({ directory, client }) => { +function recallInjector(directory) { + // OpenCode rebuilds model messages for tool continuations. Reuse the recall + // for the latest user message, but attach it to every fresh outgoing copy. + const sessions = new Map() + return { + inject(sessionID, messageID, parts) { + const text = parts.map(textFromPart).filter(Boolean).join("\n") + if (text.includes(CONTEXT_MARKER)) return + const query = text.slice(0, MAX_QUERY_CHARS) + const cached = sessionID && messageID ? sessions.get(sessionID) : undefined + const context = cached?.messageID === messageID && cached?.query === query + ? cached.context + : buildRecallContext(query, directory) + if (sessionID && messageID) { + sessions.delete(sessionID) + sessions.set(sessionID, { messageID, query, context }) + if (sessions.size > MAX_SESSIONS) sessions.delete(sessions.keys().next().value) + } + parts.unshift({ type: "text", text: context }) + }, + clear() { sessions.clear() }, + } +} + +async function setup(ctx) { + const recall = recallInjector(ctx.location.directory) + await ctx.shell.hook("create.before", (event) => { + event.env.MNEMON_OPENCODE = "1" + }) + await ctx.session.hook("context", (event) => { + const current = lastUserMessage(event, "content") + if (current) recall.inject(event.sessionID, current.msg.id, current.parts) + }) + await ctx.session.hook("compaction", (event) => { + if (!event.system.some((part) => part.type === "text" && part.text === COMPACTION_GUIDANCE)) { + event.system.push({ type: "text", text: COMPACTION_GUIDANCE }) + } + }) + // Registrations belong to OpenCode's plugin scope. No event loop or timer is + // needed; cap cached sessions and release their text when the plugin unloads. + return () => recall.clear() +} + +async function server({ directory, client }) { + const recall = recallInjector(directory) await client?.app?.log?.({ body: { service: "mnemon", @@ -65,18 +152,14 @@ export const MnemonPlugin = async ({ directory, client }) => { }, "experimental.chat.messages.transform": async (_input, output) => { - const current = lastUserMessage(output) + const current = lastUserMessage(output, "parts") if (!current) return - const marker = "" - if (current.text.includes(marker)) return - prependText(current.parts, buildRecallContext(current.text, directory)) + recall.inject(current.msg.info?.sessionID, current.msg.info?.id, current.parts) }, "experimental.session.compacting": async (_input, output) => { if (!Array.isArray(output.context)) output.context = [] - output.context.push(`## Mnemon Memory - -Before compaction completes, preserve durable preferences, decisions, insights, facts, or context with mnemon remember/link when they will improve future continuity. Do not store secrets, credentials, or short-lived operational noise.`) + if (!output.context.includes(COMPACTION_GUIDANCE)) output.context.push(COMPACTION_GUIDANCE) }, event: async ({ event }) => { @@ -89,5 +172,10 @@ Before compaction completes, preserve durable preferences, decisions, insights, }, }) }, + dispose: () => recall.clear(), } } + +// One export avoids double registration. OpenCode 1.18.29+ calls server(); +// OpenCode 2 calls setup() and ignores the legacy adapter. +export default { id: "mnemon", setup, server } diff --git a/internal/memory/setup/opencode_test.go b/internal/memory/setup/opencode_test.go index b3f797c91..c1d3f0e80 100644 --- a/internal/memory/setup/opencode_test.go +++ b/internal/memory/setup/opencode_test.go @@ -36,7 +36,11 @@ func TestOpenCodeWriteSkillAndPlugin(t *testing.T) { if err != nil { t.Fatalf("read plugin: %v", err) } - for _, want := range []string{"experimental.chat.messages.transform", "experimental.session.compacting", "shell.env"} { + for _, want := range []string{ + `export default { id: "mnemon", setup, server }`, + `ctx.session.hook("context"`, `ctx.session.hook("compaction"`, `ctx.shell.hook("create.before"`, + "experimental.chat.messages.transform", "experimental.session.compacting", "shell.env", + } { if !strings.Contains(string(plugin), want) { t.Fatalf("plugin missing %q: %s", want, string(plugin)) } diff --git a/test/memory/opencode/plugin.test.mjs b/test/memory/opencode/plugin.test.mjs new file mode 100644 index 000000000..b1deb4c73 --- /dev/null +++ b/test/memory/opencode/plugin.test.mjs @@ -0,0 +1,168 @@ +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import path from "node:path" +import { mock, test } from "node:test" + +const source = await readFile(new URL("../../../internal/memory/setup/assets/opencode/mnemon.js", import.meta.url), "utf8") +let generation = 0 + +async function loadPlugin(t, { platform = "linux", files = [], env = {}, links = {} } = {}) { + const calls = [] + let result = (args) => args[0] === "status" ? "status fixture" : `recall fixture: ${args[1]}` + mock.module("node:child_process", { namedExports: { spawnSync(command, args, options) { + calls.push({ command, args, options }) + return { status: 0, stdout: result(args) } + } } }) + const paths = platform === "win32" ? path.win32 : path.posix + const nativeRoot = platform === "win32" ? "C:\\native" : "/native" + const binary = platform === "win32" ? "bin/mnemon.exe" : "bin/mnemon" + mock.module("node:fs", { namedExports: { + existsSync: (file) => files.includes(file), + realpathSync: (file) => links[file] || file, + readFileSync: () => JSON.stringify([{ platform, arch: "x64", alias: "@mnemon-dev/fixture", binary }]), + } }) + mock.module("node:module", { namedExports: { createRequire: () => ({ resolve: (target) => { + assert.equal(target, "@mnemon-dev/fixture/package.json") + return paths.join(nativeRoot, "package.json") + } }) } }) + mock.module("node:path", { defaultExport: platform === "win32" ? path.win32 : path.posix }) + mock.module("node:process", { defaultExport: { platform, arch: "x64", env, cwd: () => "/fallback" } }) + t.after(() => mock.restoreAll()) + const module = await import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}#${++generation}`) + assert.deepEqual(Object.keys(module), ["default"], "only one loader entrypoint") + const plugin = module.default + assert.equal(plugin.id, "mnemon") + assert.equal(typeof plugin.server, "function") + const hooks = new Map() + const register = (domain) => async (name, callback) => { + const key = `${domain}.${name}` + assert.equal(hooks.has(key), false, `duplicate hook ${key}`) + hooks.set(key, callback) + return { dispose: async () => hooks.delete(key) } + } + const cleanup = await plugin.setup({ location: { directory: "/project" }, session: { hook: register("session") }, shell: { hook: register("shell") } }) + assert.deepEqual([...hooks.keys()], ["shell.create.before", "session.context", "session.compaction"]) + return { plugin, hooks, calls, cleanup, setResult(fn) { result = fn } } +} + +function context(sessionID = "session-a", id = "user-a", text = "Orion release color") { + return { sessionID, system: [], messages: [ + { id: "older", role: "user", content: [{ type: "text", text: "previous prompt" }] }, + { id, role: "user", content: [{ type: "image", url: "fixture" }, { type: "text", text }], metadata: { kept: true } }, + { id: "assistant", role: "assistant", content: [{ type: "text", text: "tool continuation" }] }, + ] } +} + +test("v2 attaches once to the last user message and reuses recall for fresh continuation copies", async (t) => { + const p = await loadPlugin(t) + const hook = p.hooks.get("session.context") + const event = context() + hook(event) + const current = event.messages[1] + assert.match(current.content[0].text, /recall fixture: Orion release color/) + assert.equal(current.content.length, 3) + assert.deepEqual(current.metadata, { kept: true }) + assert.equal(event.messages[0].content.length, 1) + assert.equal(event.messages[2].content.length, 1) + hook(event) + assert.equal(current.content.length, 3) + const fresh = context() + hook(fresh) + assert.equal(fresh.messages[1].content[0].text, current.content[0].text) + assert.equal(p.calls.length, 2, "one status and recall per user turn") + assert.deepEqual(p.calls[1].args, ["recall", "Orion release color", "--limit", "5"]) + assert.equal(p.calls[0].options.cwd, "/project") + assert.equal(p.calls[0].options.timeout, 5000) + assert.equal(p.calls[0].options.killSignal, "SIGKILL") + assert.equal(p.calls[0].options.maxBuffer, 256 * 1024) + assert.equal(p.calls[0].options.windowsHide, true) + assert.equal(p.calls[0].options.shell, undefined) + hook(context("session-b", "user-a")) + hook(context("session-a", "user-b", "next query")) + assert.equal(p.calls.length, 6, "sessions and new user messages get their own recall") + await p.cleanup() + hook(context("session-a", "user-b", "next query")) + assert.equal(p.calls.length, 8, "unload releases cached text") +}) + +test("recall cache and CLI input/output are bounded", async (t) => { + const p = await loadPlugin(t) + const hook = p.hooks.get("session.context") + for (let i = 0; i < 129; i++) hook(context(`session-${i}`)) + hook(context("session-128")) + assert.equal(p.calls.length, 258) + hook(context("session-0")) + assert.equal(p.calls.length, 260, "oldest session is evicted") + p.setResult(() => "x".repeat(10000)) + const event = context("long", "long", "q".repeat(20000)) + hook(event) + assert.equal(p.calls.at(-1).args[1].length, 8000) + assert.ok(event.messages[1].content[0].text.length < 8500) +}) + +test("v2 shell and compaction hooks use native event shapes", async (t) => { + const p = await loadPlugin(t) + const shell = { env: { EXISTING: "keep" } } + p.hooks.get("shell.create.before")(shell) + assert.deepEqual(shell.env, { EXISTING: "keep", MNEMON_OPENCODE: "1" }) + const event = { system: [{ type: "text", text: "keep system" }], messages: [] } + const hook = p.hooks.get("session.compaction") + hook(event); hook(event) + assert.equal(event.system.length, 2) + assert.equal(event.system[1].type, "text") + assert.match(event.system[1].text, /mnemon remember\/link/) + assert.match(event.system[1].text, /Do not store secrets/) +}) + +test("v1 server adapter preserves legacy hooks and message parts", async (t) => { + const p = await loadPlugin(t) + const hooks = await p.plugin.server({ directory: "/legacy" }) + const output = { messages: [{ info: { role: "user", id: "u1", sessionID: "s1" }, parts: [{ type: "text", text: "legacy prompt" }] }] } + await hooks["experimental.chat.messages.transform"]({}, output) + await hooks["experimental.chat.messages.transform"]({}, output) + assert.equal(output.messages[0].parts.length, 2) + assert.match(output.messages[0].parts[0].text, /recall fixture: legacy prompt/) + assert.equal(p.calls[0].options.cwd, "/legacy") + const shell = {} + await hooks["shell.env"]({}, shell) + assert.equal(shell.env.MNEMON_OPENCODE, "1") + const compaction = {} + await hooks["experimental.session.compacting"]({}, compaction) + await hooks["experimental.session.compacting"]({}, compaction) + assert.equal(compaction.context.length, 1) + assert.match(compaction.context[0], /mnemon remember\/link/) + await hooks.dispose() +}) + +test("missing CLI and malformed messages do not break the model request", async (t) => { + const p = await loadPlugin(t) + const hook = p.hooks.get("session.context") + p.setResult(() => { throw new Error("ENOENT") }) + for (const messages of [undefined, [], [{ role: "assistant", content: [] }], [{ role: "user" }]]) hook({ messages }) + assert.equal(p.calls.length, 0) + const event = context() + hook(event) + assert.match(event.messages[1].content[0].text, /Use mnemon/) + assert.doesNotMatch(event.messages[1].content[0].text, /Status:|Relevant recall:/) +}) + +for (const [name, entry, command] of [ + ["native", "C:\\bin\\mnemon.exe", "C:\\bin\\mnemon.exe"], + ["npm global", "C:\\bin\\node_modules\\@mnemon-dev\\mnemon\\bin\\mnemon.js", "C:\\native\\bin\\mnemon.exe"], + ["npm local", "C:\\project\\node_modules\\@mnemon-dev\\mnemon\\bin\\mnemon.js", "C:\\native\\bin\\mnemon.exe"], +]) { + test(`Windows ${name} keeps recall text out of shell syntax`, async (t) => { + const p = await loadPlugin(t, { platform: "win32", files: [entry], env: { PATH: "C:\\bin;C:\\project\\node_modules\\.bin" } }) + const query = 'Orion "quote" & echo %TOKEN% $(command)' + p.hooks.get("session.context")(context("s", "u", query)) + assert.equal(p.calls[1].command, command) + assert.deepEqual(p.calls[1].args, ["recall", query, "--limit", "5"]) + assert.equal(p.calls[1].options.shell, undefined) + }) +} + +test("Unix npm symlinks resolve to the native child without an intermediate launcher", async (t) => { + const p = await loadPlugin(t, { files: ["/bin/mnemon"], env: { PATH: "/bin" }, links: { "/bin/mnemon": "/npm/@mnemon-dev/mnemon/bin/mnemon.js" } }) + p.hooks.get("session.context")(context()) + assert.equal(p.calls[0].command, "/native/bin/mnemon") +}) diff --git a/test/memory/opencode/plugin_test.go b/test/memory/opencode/plugin_test.go new file mode 100644 index 000000000..4cf80b6cc --- /dev/null +++ b/test/memory/opencode/plugin_test.go @@ -0,0 +1,22 @@ +package opencode_test + +import ( + "context" + "os/exec" + "testing" + "time" +) + +// JavaScript runtime tests belong to the explicit integration tier. +func TestPluginRuntime(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("OpenCode plugin runtime tests require Node.js 22.3 or newer") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, node, "--experimental-test-module-mocks", "--test", "plugin.test.mjs", "timeout.test.mjs") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("OpenCode plugin runtime: %v\n%s", err, output) + } +} diff --git a/test/memory/opencode/timeout.test.mjs b/test/memory/opencode/timeout.test.mjs new file mode 100644 index 000000000..bf96278c2 --- /dev/null +++ b/test/memory/opencode/timeout.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict" +import { spawnSync } from "node:child_process" +import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { test } from "node:test" + +for (const npm of [false, true]) { + test(`timeout kills a SIGTERM-ignoring ${npm ? "npm" : "native"} command`, { skip: process.platform === "win32" }, async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "mnemon-opencode-timeout-")) + const bin = path.join(root, "bin") + const pidFile = path.join(root, "pid") + try { + await mkdir(bin) + let native = path.join(bin, "mnemon") + if (npm) { + const modules = path.join(root, "node_modules") + const pkg = path.join(modules, "@mnemon-dev", "mnemon") + const runtime = path.join(modules, "fixture-runtime") + await mkdir(path.join(pkg, "bin"), { recursive: true }) + await mkdir(runtime, { recursive: true }) + await writeFile(path.join(pkg, "targets.json"), JSON.stringify([ + { platform: process.platform, arch: process.arch, alias: "fixture-runtime", binary: "mnemon" }, + ])) + await writeFile(path.join(runtime, "package.json"), '{"name":"fixture-runtime"}') + const entry = path.join(pkg, "bin", "mnemon.js") + await writeFile(entry, 'throw new Error("must run the native child directly")') + await symlink(entry, native) + native = path.join(runtime, "mnemon") + } + await writeFile(native, `#!/usr/bin/env node +if (process.argv[2] === "status") { console.log("{}"); process.exit(0) } +process.on("SIGTERM", () => {}) +require("node:fs").writeFileSync(process.env.MNEMON_TEST_PID, String(process.pid)) +setInterval(() => {}, 1000) +`, { mode: 0o755 }) + await copyFile(new URL("../../../internal/memory/setup/assets/opencode/mnemon.js", import.meta.url), path.join(root, "plugin.mjs")) + await writeFile(path.join(root, "worker.mjs"), `import plugin from "./plugin.mjs" +import { writeFileSync } from "node:fs" +let context +await plugin.setup({ location: { directory: process.cwd() }, shell: { hook: async () => {} }, session: { hook: async (name, fn) => { if (name === "context") context = fn } } }) +const event = { sessionID: "s", messages: [{ id: "u", role: "user", content: [{ type: "text", text: "query" }] }] } +await context(event) +writeFileSync("result.json", JSON.stringify(event)) +`) + const started = Date.now() + // Ignored stdio keeps this outer deadline independent of inherited pipes + // if a broken plugin leaves the fixture process running. + const child = spawnSync(process.execPath, [path.join(root, "worker.mjs")], { + cwd: root, + env: { ...process.env, PATH: bin + path.delimiter + process.env.PATH, MNEMON_TEST_PID: pidFile }, + stdio: "ignore", timeout: 9000, killSignal: "SIGKILL", + }) + assert.equal(child.status, 0, `plugin exceeded its deadline: ${child.error}`) + assert.ok(Date.now() - started < 8000, "five-second timeout must remain bounded") + const result = JSON.parse(await readFile(path.join(root, "result.json"), "utf8")) + assert.match(result.messages[0].content[0].text, //) + const pid = Number(await readFile(pidFile, "utf8")) + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }, "native child must be reaped") + } finally { + try { process.kill(Number(await readFile(pidFile, "utf8")), "SIGKILL") } catch {} + await rm(root, { recursive: true, force: true }) + } + }) +}