From 98a66b73566cb1129a35dc6b6c4b236706b41f63 Mon Sep 17 00:00:00 2001 From: HazielMagallanes Date: Sat, 31 Oct 2026 20:42:36 -0300 Subject: [PATCH 1/4] fix(cli): emit OpenCode plugin hooks for the V2 setup context - keep the `server(ctx)` entry that OpenCode 1.18.x dispatches hooks from - fill `setup(ctx)` with the V2 hook registrations (execute.after, compaction) and fail open per domain, so one unavailable registration neither breaks the host nor disables the other - prefer `content` over `output` in appendResult and return the result unchanged when no text surface exists, so a tool's own output is never hidden - persist the post-compaction note as a keyed session instruction (`codebase-memory-session-note`) that survives compaction instead of editing the system prompt; hosts without that channel skip reinjection - resolve each session's directory with ctx.session.get (cached), because ctx.location is the plugin instance's location, not the session's - update the #2089 emission test in place to state the 2.x contract Fixes #2204 Refs #2077 #2089 Signed-off-by: HazielMagallanes --- src/cli/client_adapter.c | 255 ++++++++++++++++++++++++++++--------- src/cli/client_adapter.h | 16 ++- tests/test_agent_clients.c | 53 +++++++- 3 files changed, 253 insertions(+), 71 deletions(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index cfd2719c78..60cb0cd3d7 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -427,9 +427,15 @@ char *cbm_client_adapter_opencode(const char *binary_path) { sb_append(&sb, "// OpenCode already reaches every tool over MCP; this module adds the\n" "// context surfaces other clients get through hook configuration: graph\n" "// lookup after grep/glob, index-coverage notes after read, session-start\n" - "// tier routing (carried on the first tool result of each session, since\n" - "// OpenCode documents no context-output lifecycle hook), and reinjection\n" - "// after compaction via the documented experimental surface.\n"); + "// tier routing (carried on the first tool result of each session), and\n" + "// reinjection after compaction through the session context channel.\n" + "//\n" + "// Two runtimes load this module:\n" + "// - OpenCode 1.18.x dispatches the hooks returned by server(ctx).\n" + "// - OpenCode 2 ignores server and calls setup(ctx), which registers the\n" + "// same behaviour on the context's tool and session hook domains. Each\n" + "// registration fails open, and the matching server() hook yields once\n" + "// its domain has registered.\n"); sb_append(&sb, "import { spawn } from 'node:child_process';\n\n"); sb_append(&sb, "const BIN = '"); sb_append(&sb, bin); @@ -481,64 +487,193 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " });\n" "}\n\n"); + /* OpenCode 2 tool results are structured (Tool.Result: content is the + * string/parts surface the model reads, output is the tool's schema-typed + * value). Append to content first, then a string output; if neither text + * surface exists, leave the result untouched so the tool's own output is + * never hidden. The caller assigns the returned result onto event.result. */ + sb_append(&sb, + "function appendResult(result, extra) {\n" + " if (!result || typeof result !== 'object') return result;\n" + " if (typeof result.content === 'string') {\n" + " return { ...result, content: result.content + '\\n' + extra };\n" + " }\n" + " if (Array.isArray(result.content)) {\n" + " const content = result.content.slice();\n" + " for (let i = content.length - 1; i >= 0; i--) {\n" + " if (content[i] && content[i].type === 'text') {\n" + " content[i] = { ...content[i], text: content[i].text + '\\n' + extra };\n" + " return { ...result, content };\n" + " }\n" + " }\n" + " content.push({ type: 'text', text: extra });\n" + " return { ...result, content };\n" + " }\n" + " if (typeof result.output === 'string') {\n" + " return { ...result, output: result.output + '\\n' + extra };\n" + " }\n" + " return result;\n" + "}\n\n"); + + /* Per-domain registration flags: a host that dispatches both server() and + * setup() with domains must not append the note twice. The server() hook + * for a domain yields once setup() registered that same domain. */ + sb_append(&sb, "let toolHooksRegistered = false;\n" + "let sessionHooksRegistered = false;\n\n"); + sb_append( - &sb, "export default {\n" - " id: 'codebase-memory-augment',\n" - " // V2 config loader: requires id + setup|effect; no tool domain yet\n" - " // at this point, so there is nothing to register here.\n" - " setup() {},\n" - " // Server runtime: reads default.server and dispatches the hooks it returns.\n" - " server: async (ctx) => {\n" - " const dir = ctx?.directory;\n" - " const seen = new Set();\n" - " const lifecycle = () =>\n" - " augment({ hook_event_name: 'SessionStart', cwd: dir });\n" - " return {\n" - " 'tool.execute.after': async (input, output) => {\n" - " if (typeof output?.output !== 'string') return;\n" - " const pieces = [];\n" - " const sid = input?.sessionID;\n" - " if (typeof sid === 'string' && !seen.has(sid)) {\n" - " seen.add(sid);\n" - " pieces.push(await lifecycle());\n" - " }\n" - " const args = input?.args ?? {};\n" - " const search =\n" - " input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n" - " if (search) {\n" - " pieces.push(await augment({\n" - " hook_event_name: 'PreToolUse',\n" - " tool_name: search,\n" - " tool_input: args,\n" - " cwd: dir,\n" - " }));\n" - " } else if (input?.tool === 'read') {\n" - " const filePath = args.filePath ?? args.file_path ?? args.path;\n" - " if (typeof filePath === 'string' && filePath) {\n" - " pieces.push(await augment({\n" - " hook_event_name: 'PostToolUse',\n" - " tool_name: 'Read',\n" - " tool_input: { file_path: filePath },\n" - " cwd: dir,\n" - " }));\n" - " }\n" - " }\n" - " const extra = pieces.filter(Boolean).join('\\n');\n" - " if (extra) {\n" - " output.output += '\\n' + extra;\n" - " }\n" - " },\n" - " // Documented (experimental) compaction surface: output.context is the\n" - " // mutable array of context strings for the rebuilt session.\n" - " 'experimental.session.compacting': async (_input, output) => {\n" - " const note = await lifecycle();\n" - " if (note && Array.isArray(output?.context)) {\n" - " output.context.push(note);\n" - " }\n" - " },\n" - " };\n" - " },\n" - "};\n"); + &sb, + "export default {\n" + " id: 'codebase-memory-augment',\n" + " // OpenCode 1.18.x server runtime: reads default.server and dispatches\n" + " // the hooks it returns.\n" + " server: async (ctx) => {\n" + " const dir = ctx?.directory;\n" + " const seen = new Set();\n" + " const lifecycle = () =>\n" + " augment({ hook_event_name: 'SessionStart', cwd: dir });\n" + " return {\n" + " 'tool.execute.after': async (input, output) => {\n" + " if (toolHooksRegistered) return;\n" + " if (typeof output?.output !== 'string') return;\n" + " const pieces = [];\n" + " const sid = input?.sessionID;\n" + " if (typeof sid === 'string' && !seen.has(sid)) {\n" + " seen.add(sid);\n" + " pieces.push(await lifecycle());\n" + " }\n" + " const args = input?.args ?? {};\n" + " const search =\n" + " input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n" + " if (search) {\n" + " pieces.push(await augment({\n" + " hook_event_name: 'PreToolUse',\n" + " tool_name: search,\n" + " tool_input: args,\n" + " cwd: dir,\n" + " }));\n" + " } else if (input?.tool === 'read') {\n" + " const filePath = args.filePath ?? args.file_path ?? args.path;\n" + " if (typeof filePath === 'string' && filePath) {\n" + " pieces.push(await augment({\n" + " hook_event_name: 'PostToolUse',\n" + " tool_name: 'Read',\n" + " tool_input: { file_path: filePath },\n" + " cwd: dir,\n" + " }));\n" + " }\n" + " }\n" + " const extra = pieces.filter(Boolean).join('\\n');\n" + " if (extra) {\n" + " output.output += '\\n' + extra;\n" + " }\n" + " },\n" + " // Documented (experimental) compaction surface: output.context is the\n" + " // mutable array of context strings for the rebuilt session.\n" + " 'experimental.session.compacting': async (_input, output) => {\n" + " if (sessionHooksRegistered) return;\n" + " const note = await lifecycle();\n" + " if (note && Array.isArray(output?.context)) {\n" + " output.context.push(note);\n" + " }\n" + " },\n" + " };\n" + " },\n" + " // OpenCode 2 runtime: default.server is ignored and setup(ctx) registers\n" + " // the hooks on the context domains. Each registration fails open, so one\n" + " // missing domain cannot break the host or the other domain.\n" + " async setup(ctx) {\n" + " const seen = new Set();\n" + " const dirs = new Map();\n" + " const dirFor = async (sessionID) => {\n" + " if (typeof sessionID !== 'string' || sessionID === '') return undefined;\n" + " if (dirs.has(sessionID)) return dirs.get(sessionID);\n" + " try {\n" + " const session = await ctx.session.get({ sessionID });\n" + " const dir = session?.location?.directory;\n" + " if (typeof dir === 'string' && dir !== '') {\n" + " dirs.set(sessionID, dir);\n" + " return dir;\n" + " }\n" + " } catch {}\n" + " return undefined;\n" + " };\n" + " const lifecycle = (dir) =>\n" + " augment({ hook_event_name: 'SessionStart', cwd: dir });\n" + " if (ctx?.tool?.hook) {\n" + " try {\n" + " await ctx.tool.hook('execute.after', async (event) => {\n" + " if (event?.status !== 'completed') return;\n" + " const dir = await dirFor(event?.sessionID);\n" + " if (!dir) return;\n" + " const pieces = [];\n" + " if (typeof event?.sessionID === 'string' && !seen.has(event.sessionID)) {\n" + " seen.add(event.sessionID);\n" + " pieces.push(await lifecycle(dir));\n" + " }\n" + " const args = event?.input ?? {};\n" + " const search =\n" + " event?.tool === 'grep' ? 'Grep' : event?.tool === 'glob' ? 'Glob' : null;\n" + " if (search) {\n" + " pieces.push(await augment({\n" + " hook_event_name: 'PreToolUse',\n" + " tool_name: search,\n" + " tool_input: args,\n" + " cwd: dir,\n" + " }));\n" + " } else if (event?.tool === 'read') {\n" + " const filePath = args.filePath ?? args.file_path ?? args.path;\n" + " if (typeof filePath === 'string' && filePath) {\n" + " pieces.push(await augment({\n" + " hook_event_name: 'PostToolUse',\n" + " tool_name: 'Read',\n" + " tool_input: { file_path: filePath },\n" + " cwd: dir,\n" + " }));\n" + " }\n" + " }\n" + " const extra = pieces.filter(Boolean).join('\\n');\n" + " if (extra) {\n" + " event.result = appendResult(event.result, extra);\n" + " }\n" + " });\n" + " toolHooksRegistered = true;\n" + " } catch {\n" + " // Fail open: the server() tool hook stays active.\n" + " }\n" + " }\n" + " // OpenCode 2 compaction: persist the note as a keyed session\n" + " // instruction (a entry) instead of editing the system\n" + " // prompt; entries survive compaction. Hosts without the channel\n" + " // skip reinjection rather than failing the plugin.\n" + " if (ctx?.session?.hook) {\n" + " try {\n" + " await ctx.session.hook('compaction', async (event) => {\n" + " const sessionID = event?.sessionID;\n" + " if (typeof sessionID !== 'string' || sessionID === '') return;\n" + " const entry = ctx?.session?.instructions?.entry;\n" + " if (typeof entry?.put !== 'function') return;\n" + " const dir = await dirFor(sessionID);\n" + " if (!dir) return;\n" + " const note = await lifecycle(dir);\n" + " if (!note) return;\n" + " try {\n" + " await entry.put({\n" + " sessionID,\n" + " key: 'codebase-memory-session-note',\n" + " value: note,\n" + " });\n" + " } catch {\n" + " // Fail open: reinjection is best-effort.\n" + " }\n" + " });\n" + " sessionHooksRegistered = true;\n" + " } catch {\n" + " // Fail open: the server() compaction hook stays active.\n" + " }\n" + " }\n" + " },\n" + "};\n"); if (sb.failed) { free(sb.buf); diff --git a/src/cli/client_adapter.h b/src/cli/client_adapter.h index c1bfb652e6..bbe197d89e 100644 --- a/src/cli/client_adapter.h +++ b/src/cli/client_adapter.h @@ -48,11 +48,17 @@ char *cbm_client_adapter_pi(const char *binary_path); * other clients get through their own native hook configuration. OpenCode has * no such configuration; a plugin module is its only extension point. * - * NOTE for maintainers: this hooks `tool.execute.after`, whose ability to - * modify a tool's output is NOT part of OpenCode's documented plugin contract - * (only `tool.execute.before`'s argument mutation is). If OpenCode changes it, - * augmentation stops silently — no error, no signal. That risk is accepted - * deliberately and recorded here so it is not rediscovered as a mystery. + * NOTE for maintainers: the module serves two loader generations from one + * default export. OpenCode 1.18.x dispatches the hooks returned by + * `server(ctx)`; its `tool.execute.after` ability to modify a tool's output is + * NOT part of the documented plugin contract (only `tool.execute.before`'s + * argument mutation is), so if that runtime changes, augmentation stops + * silently — no error, no signal. That risk is accepted deliberately for the + * server() path and recorded here so it is not rediscovered as a mystery. + * OpenCode 2 ignores `server` and calls `setup(ctx)`, which registers + * `execute.after` and `compaction` on the context domains. Each registration + * fails open, and the post-compaction note is stored as a keyed session + * instruction (`` entry) instead of the system prompt. * * Returns NULL on allocation failure or when binary_path is NULL/empty. */ char *cbm_client_adapter_opencode(const char *binary_path); diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index 0d57723a07..a3afe14944 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1342,18 +1342,59 @@ TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction) { PASS(); } -/* #2077: OpenCode's V2 loader only reads the default export and needs an - * id plus a setup()/effect() function; the old named export had neither. */ +/* #2077/#2089/#2204: the two OpenCode runtimes read different entries of the + * default export. 1.18.x dispatches the hooks returned by `server(ctx)`; 2.x + * ignores `server` and calls `setup(ctx)`, whose context provides the tool and + * session hook domains. Registrations fail open per domain, so the matching + * server() hook yields once its domain is registered. The post-compaction note + * goes through a keyed session instruction (a entry that survives + * compaction), never the system prompt. */ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); ASSERT_NOT_NULL(js); ASSERT_NOT_NULL(strstr(js, "export default {")); ASSERT_NOT_NULL(strstr(js, "id: 'codebase-memory-augment'")); - /* Hooks live under server(), which the server runtime reads; setup() - * stays empty since the V2 config loader has no tool domain yet. */ - ASSERT_NOT_NULL(strstr(js, "setup() {}")); + /* OpenCode 1.18.x server runtime. */ ASSERT_NOT_NULL(strstr(js, "server: async (ctx) => {")); - ASSERT_NULL(strstr(js, "async setup(ctx) {")); + /* OpenCode 2 registers through the setup context; the domains exist on + * 2.x, so setup is no longer the empty placeholder #2089 pinned. */ + ASSERT_NOT_NULL(strstr(js, "async setup(ctx) {")); + ASSERT_NOT_NULL(strstr(js, "ctx.tool.hook('execute.after'")); + ASSERT_NOT_NULL(strstr(js, "ctx.session.hook('compaction'")); + ASSERT_NULL(strstr(js, "ctx.session.hook('context'")); + /* content is the surface the model reads; prefer it over output, and never + * synthesize content that would hide the tool's own result. */ + const char *content_first = strstr(js, "if (typeof result.content === 'string') {"); + const char *output_fallback = strstr(js, "if (typeof result.output === 'string') {"); + ASSERT_NOT_NULL(content_first); + ASSERT_NOT_NULL(output_fallback); + ASSERT_TRUE(content_first < output_fallback); + ASSERT_NULL(strstr(js, "if (result.content === undefined)")); + /* Compaction uses the durable session context channel, not the system + * prompt; older hosts without it skip reinjection. */ + ASSERT_NOT_NULL(strstr(js, "event?.sessionID")); + ASSERT_NOT_NULL(strstr(js, "ctx?.session?.instructions?.entry")); + /* The plugin instance's location is not the session's location in a + * multi-project server: resolve each session's directory. */ + ASSERT_NOT_NULL(strstr(js, "const dir = await dirFor(")); + ASSERT_NOT_NULL(strstr(js, "ctx.session.get({ sessionID })")); + ASSERT_NOT_NULL(strstr(js, "dirs.has(sessionID)")); + ASSERT_NOT_NULL(strstr(js, "if (!dir) return;")); + /* No fallback to the plugin instance's location: that is not the session's + * location in a multi-project server. */ + ASSERT_NULL(strstr(js, "ctx?.location?.directory")); + ASSERT_NOT_NULL(strstr(js, "key: 'codebase-memory-session-note'")); + ASSERT_NULL(strstr(js, "event.system.push")); + ASSERT_NULL(strstr(js, "pendingReinject")); + /* Registrations fail open per domain; the server() hooks yield per domain. */ + ASSERT_NOT_NULL(strstr(js, "toolHooksRegistered = true;")); + ASSERT_NOT_NULL(strstr(js, "sessionHooksRegistered = true;")); + ASSERT_NOT_NULL(strstr(js, "if (toolHooksRegistered) return;")); + ASSERT_NOT_NULL(strstr(js, "if (sessionHooksRegistered) return;")); + ASSERT_NOT_NULL(strstr(js, "catch {")); + ASSERT_NOT_NULL(strstr(js, "event.result = appendResult(event.result, extra);")); + /* The empty setup placeholder and the old named export must both be gone. */ + ASSERT_NULL(strstr(js, "setup() {}")); ASSERT_NULL(strstr(js, "export const CodebaseMemory")); free(js); PASS(); From f531be2500f46a3af132287a6fa9011e0ac27328 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 09:33:03 +0100 Subject: [PATCH 2/4] fix(cli): keep the OpenCode 2 compaction note inside the compaction The OpenCode 2 compaction hook receives the compaction request, whose messages are the input the summary is built from. Add the post-compaction session note there as a text message, mirroring the 1.18.x path that pushes it into output.context. Session instruction entries are rendered into the system array of every later request, so the note no longer goes through ctx.session.instructions.entry, and event.system stays untouched. Registration and the push still fail open. Pin it in a new agent_clients test: the push sits inside the compaction registration, carries a user text part, is guarded by an array check, and the generated module names neither a system property nor an instruction entry. The #2077 test drops the assertions that pinned the old channel. Refs #2204 Signed-off-by: Martin Vogel --- src/cli/client_adapter.c | 26 ++++++++++----------- src/cli/client_adapter.h | 4 ++-- tests/test_agent_clients.c | 47 ++++++++++++++++++++++++++++++++------ 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index 60cb0cd3d7..e5263fc530 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -428,7 +428,7 @@ char *cbm_client_adapter_opencode(const char *binary_path) { "// context surfaces other clients get through hook configuration: graph\n" "// lookup after grep/glob, index-coverage notes after read, session-start\n" "// tier routing (carried on the first tool result of each session), and\n" - "// reinjection after compaction through the session context channel.\n" + "// reinjection after compaction inside the compaction request itself.\n" "//\n" "// Two runtimes load this module:\n" "// - OpenCode 1.18.x dispatches the hooks returned by server(ctx).\n" @@ -642,26 +642,24 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " // Fail open: the server() tool hook stays active.\n" " }\n" " }\n" - " // OpenCode 2 compaction: persist the note as a keyed session\n" - " // instruction (a entry) instead of editing the system\n" - " // prompt; entries survive compaction. Hosts without the channel\n" - " // skip reinjection rather than failing the plugin.\n" + " // OpenCode 2 compaction: the hook receives the compaction request, and\n" + " // its messages are the input the summary is built from. The note is\n" + " // added there as a text message, as the server() path adds it to\n" + " // output.context, so it stays inside the compaction and out of the\n" + " // system prompt. Hosts without the hook or the array skip reinjection\n" + " // rather than failing the plugin.\n" " if (ctx?.session?.hook) {\n" " try {\n" " await ctx.session.hook('compaction', async (event) => {\n" - " const sessionID = event?.sessionID;\n" - " if (typeof sessionID !== 'string' || sessionID === '') return;\n" - " const entry = ctx?.session?.instructions?.entry;\n" - " if (typeof entry?.put !== 'function') return;\n" - " const dir = await dirFor(sessionID);\n" + " if (!Array.isArray(event?.messages)) return;\n" + " const dir = await dirFor(event?.sessionID);\n" " if (!dir) return;\n" " const note = await lifecycle(dir);\n" " if (!note) return;\n" " try {\n" - " await entry.put({\n" - " sessionID,\n" - " key: 'codebase-memory-session-note',\n" - " value: note,\n" + " event.messages.push({\n" + " role: 'user',\n" + " content: [{ type: 'text', text: note }],\n" " });\n" " } catch {\n" " // Fail open: reinjection is best-effort.\n" diff --git a/src/cli/client_adapter.h b/src/cli/client_adapter.h index bbe197d89e..3c8fc39023 100644 --- a/src/cli/client_adapter.h +++ b/src/cli/client_adapter.h @@ -57,8 +57,8 @@ char *cbm_client_adapter_pi(const char *binary_path); * server() path and recorded here so it is not rediscovered as a mystery. * OpenCode 2 ignores `server` and calls `setup(ctx)`, which registers * `execute.after` and `compaction` on the context domains. Each registration - * fails open, and the post-compaction note is stored as a keyed session - * instruction (`` entry) instead of the system prompt. + * fails open, and the post-compaction note is added as a text message to the + * compaction request's `messages` (the summary input), never to `system`. * * Returns NULL on allocation failure or when binary_path is NULL/empty. */ char *cbm_client_adapter_opencode(const char *binary_path); diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index a3afe14944..14a61e9c52 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1347,8 +1347,8 @@ TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction) { * ignores `server` and calls `setup(ctx)`, whose context provides the tool and * session hook domains. Registrations fail open per domain, so the matching * server() hook yields once its domain is registered. The post-compaction note - * goes through a keyed session instruction (a entry that survives - * compaction), never the system prompt. */ + * goes into the compaction request's messages, never the system prompt (pinned + * by the compaction test below). */ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); ASSERT_NOT_NULL(js); @@ -1370,10 +1370,8 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { ASSERT_NOT_NULL(output_fallback); ASSERT_TRUE(content_first < output_fallback); ASSERT_NULL(strstr(js, "if (result.content === undefined)")); - /* Compaction uses the durable session context channel, not the system - * prompt; older hosts without it skip reinjection. */ + /* The V2 hooks read the session id with optional chaining (fail open). */ ASSERT_NOT_NULL(strstr(js, "event?.sessionID")); - ASSERT_NOT_NULL(strstr(js, "ctx?.session?.instructions?.entry")); /* The plugin instance's location is not the session's location in a * multi-project server: resolve each session's directory. */ ASSERT_NOT_NULL(strstr(js, "const dir = await dirFor(")); @@ -1383,8 +1381,6 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { /* No fallback to the plugin instance's location: that is not the session's * location in a multi-project server. */ ASSERT_NULL(strstr(js, "ctx?.location?.directory")); - ASSERT_NOT_NULL(strstr(js, "key: 'codebase-memory-session-note'")); - ASSERT_NULL(strstr(js, "event.system.push")); ASSERT_NULL(strstr(js, "pendingReinject")); /* Registrations fail open per domain; the server() hooks yield per domain. */ ASSERT_NOT_NULL(strstr(js, "toolHooksRegistered = true;")); @@ -1400,6 +1396,42 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { PASS(); } +/* #2204: on OpenCode 2 the post-compaction note stays inside the compaction, + * at the level the 1.18.x path uses (output.context). The `compaction` hook + * receives the compaction request, whose `messages` are the input the summary + * is built from, so the note is pushed there as a text message. It must never + * reach the system prompt: not through `event.system`, and not through a + * session instruction entry, which OpenCode renders into the system array of + * every later request. */ +TEST(client_adapter_opencode_v2_compaction_note_goes_into_the_compaction_messages) { + char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); + ASSERT_NOT_NULL(js); + const char *hook = strstr(js, "ctx.session.hook('compaction'"); + const char *push = strstr(js, "event.messages.push({"); + const char *registered = strstr(js, "sessionHooksRegistered = true;"); + ASSERT_NOT_NULL(hook); + ASSERT_NOT_NULL(push); + ASSERT_NOT_NULL(registered); + /* The push lives inside the compaction registration. */ + ASSERT_TRUE(hook < push); + ASSERT_TRUE(push < registered); + /* A text message that carries the note. */ + const char *role = strstr(push, "role: 'user',"); + const char *text = strstr(push, "content: [{ type: 'text', text: note }],"); + ASSERT_NOT_NULL(role); + ASSERT_NOT_NULL(text); + ASSERT_TRUE(role < registered); + ASSERT_TRUE(text < registered); + /* Fail open: a host whose event has no messages array skips reinjection. */ + ASSERT_NOT_NULL(strstr(js, "if (!Array.isArray(event?.messages)) return;")); + /* Never the system prompt, and no session instruction entry. */ + ASSERT_NULL(strstr(js, ".system")); + ASSERT_NULL(strstr(js, "instructions")); + ASSERT_NULL(strstr(js, "entry.put")); + free(js); + PASS(); +} + /* augment() is awaited inline inside 'tool.execute.after', so a hook-augment * that never exits would stall every grep/glob/read completion in the host * session until the agent is restarted. The generated plugin must bound the @@ -1472,6 +1504,7 @@ SUITE(agent_clients) { RUN_TEST(client_adapter_opencode_sends_the_required_hook_event); RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction); RUN_TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077); + RUN_TEST(client_adapter_opencode_v2_compaction_note_goes_into_the_compaction_messages); RUN_TEST(client_adapter_opencode_bounds_a_hung_hook_augment_child); RUN_TEST(client_adapter_rejects_missing_binary_path); } From d3d0bc8452eae08e067e4d4be604c9ce72a69984 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 09:33:17 +0100 Subject: [PATCH 3/4] fix(cli): never create note-only content for an empty OpenCode 2 result Tool.Result allows content: [], and for it the array branch of appendResult pushed a text part that held only the session note, so the note became the whole content of the tool result. Add a text part only when the array already holds a part; an empty array now falls through to the string output branch, or the result is returned unchanged. Pin it in a new agent_clients test: the part-adding push sits inside the non-empty array branch, ahead of the string output fallback, and no unguarded array branch is left. Refs #2204 Signed-off-by: Martin Vogel --- src/cli/client_adapter.c | 8 ++++++-- tests/test_agent_clients.c | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index e5263fc530..e630193608 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -491,14 +491,18 @@ char *cbm_client_adapter_opencode(const char *binary_path) { * string/parts surface the model reads, output is the tool's schema-typed * value). Append to content first, then a string output; if neither text * surface exists, leave the result untouched so the tool's own output is - * never hidden. The caller assigns the returned result onto event.result. */ + * never hidden. A parts array gets a new text part only when it already + * holds a part: `content: []` is a valid result, and a part added there + * would be the note alone, so an empty array falls through to the string + * output (or the unchanged result). The caller assigns the returned result + * onto event.result. */ sb_append(&sb, "function appendResult(result, extra) {\n" " if (!result || typeof result !== 'object') return result;\n" " if (typeof result.content === 'string') {\n" " return { ...result, content: result.content + '\\n' + extra };\n" " }\n" - " if (Array.isArray(result.content)) {\n" + " if (Array.isArray(result.content) && result.content.length > 0) {\n" " const content = result.content.slice();\n" " for (let i = content.length - 1; i >= 0; i--) {\n" " if (content[i] && content[i].type === 'text') {\n" diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index 14a61e9c52..381bfda68e 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1432,6 +1432,31 @@ TEST(client_adapter_opencode_v2_compaction_note_goes_into_the_compaction_message PASS(); } +/* #2204: Tool.Result allows `content: []`. appendResult adds a text part to a + * parts array only when the array already holds a part; an empty array falls + * through to the string `output` (or the unchanged result), so the note never + * becomes the whole content of a tool result. */ +TEST(client_adapter_opencode_v2_append_result_never_creates_note_only_content) { + char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); + ASSERT_NOT_NULL(js); + const char *parts = + strstr(js, "if (Array.isArray(result.content) && result.content.length > 0) {"); + const char *add_part = strstr(js, "content.push({ type: 'text', text: extra });"); + const char *output_fallback = strstr(js, "if (typeof result.output === 'string') {"); + ASSERT_NOT_NULL(parts); + ASSERT_NOT_NULL(add_part); + ASSERT_NOT_NULL(output_fallback); + /* The only part-adding push sits inside the non-empty branch; the string + * output fallback, where `content: []` lands, comes after it. */ + ASSERT_TRUE(parts < add_part); + ASSERT_TRUE(add_part < output_fallback); + ASSERT_NULL(strstr(add_part + 1, "content.push(")); + /* No unguarded array branch is left. */ + ASSERT_NULL(strstr(js, "if (Array.isArray(result.content)) {")); + free(js); + PASS(); +} + /* augment() is awaited inline inside 'tool.execute.after', so a hook-augment * that never exits would stall every grep/glob/read completion in the host * session until the agent is restarted. The generated plugin must bound the @@ -1505,6 +1530,7 @@ SUITE(agent_clients) { RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction); RUN_TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077); RUN_TEST(client_adapter_opencode_v2_compaction_note_goes_into_the_compaction_messages); + RUN_TEST(client_adapter_opencode_v2_append_result_never_creates_note_only_content); RUN_TEST(client_adapter_opencode_bounds_a_hung_hook_augment_child); RUN_TEST(client_adapter_rejects_missing_binary_path); } From fb88365dc07936014c758745d013e0d72f8918bf Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 12:55:46 +0100 Subject: [PATCH 4/4] fix(cli): add no OpenCode 2 compaction note until one has run on a real host The V2 compaction hook pushed a plain object into the compaction request's event.messages. OpenCode's Message is a schema class at v2.2.5, and nobody has run this path on a real 2.x host. The try/catch only guards the array push, which cannot throw. If OpenCode rejects the object, that happens inside its own compaction, out of the plugin's reach, so an untested note could break compaction for every OpenCode 2 user. Losing the note costs far less. Remove the V2 compaction registration and the sessionHooksRegistered flag it needed. The V2 tool hook, proven on OpenCode 2.2.5, stays, and so does the 1.18.x experimental.session.compacting path. OpenCode 2 sessions still get the session note on the first tool result of a session. A later change can add a note after compaction together with a real 2.x run. The replacement test pins that the generated module registers no session hook and touches neither event.messages, a system property nor an instruction entry. Refs #2204 Signed-off-by: Martin Vogel --- README.md | 2 +- src/cli/client_adapter.c | 51 ++++++++-------------------------- src/cli/client_adapter.h | 7 +++-- tests/test_agent_clients.c | 56 ++++++++++++-------------------------- 4 files changed, 34 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index c4428e0675..48f73ec7e0 100644 --- a/README.md +++ b/README.md @@ -722,7 +722,7 @@ overwrite user-modified agents. | Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, skill, three read-only agents; `SessionStart` + `SubagentStart`, `PreToolUse` graph lookup for `Bash` searches | | Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` | | Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill | -| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection | +| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection on OpenCode 1.18.x | | Antigravity | Detected | `.gemini/config/mcp_config.json` | `.gemini/GEMINI.md` | | Aider | Detected | none | `CONVENTIONS.md` via `.aider.conf.yml` | | KiloCode | Detected | `.config/kilo/kilo.jsonc` | Rule + three graph-tool subagents with deny-by-default permissions | diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index e630193608..f5747c4c80 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -428,14 +428,15 @@ char *cbm_client_adapter_opencode(const char *binary_path) { "// context surfaces other clients get through hook configuration: graph\n" "// lookup after grep/glob, index-coverage notes after read, session-start\n" "// tier routing (carried on the first tool result of each session), and\n" - "// reinjection after compaction inside the compaction request itself.\n" + "// on OpenCode 1.18.x reinjection after compaction.\n" "//\n" "// Two runtimes load this module:\n" "// - OpenCode 1.18.x dispatches the hooks returned by server(ctx).\n" "// - OpenCode 2 ignores server and calls setup(ctx), which registers the\n" - "// same behaviour on the context's tool and session hook domains. Each\n" - "// registration fails open, and the matching server() hook yields once\n" - "// its domain has registered.\n"); + "// tool hook on the context's tool domain. The registration fails open,\n" + "// and the server() tool hook yields once it has registered. OpenCode 2\n" + "// gets no note after compaction: no compaction channel has been run on\n" + "// a real 2.x host yet.\n"); sb_append(&sb, "import { spawn } from 'node:child_process';\n\n"); sb_append(&sb, "const BIN = '"); sb_append(&sb, bin); @@ -519,11 +520,10 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " return result;\n" "}\n\n"); - /* Per-domain registration flags: a host that dispatches both server() and - * setup() with domains must not append the note twice. The server() hook - * for a domain yields once setup() registered that same domain. */ - sb_append(&sb, "let toolHooksRegistered = false;\n" - "let sessionHooksRegistered = false;\n\n"); + /* A host that dispatches both server() and setup() with a tool domain must + * not append the note twice: the server() tool hook yields once setup() + * registered the tool domain. */ + sb_append(&sb, "let toolHooksRegistered = false;\n\n"); sb_append( &sb, @@ -575,7 +575,6 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " // Documented (experimental) compaction surface: output.context is the\n" " // mutable array of context strings for the rebuilt session.\n" " 'experimental.session.compacting': async (_input, output) => {\n" - " if (sessionHooksRegistered) return;\n" " const note = await lifecycle();\n" " if (note && Array.isArray(output?.context)) {\n" " output.context.push(note);\n" @@ -584,8 +583,8 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " };\n" " },\n" " // OpenCode 2 runtime: default.server is ignored and setup(ctx) registers\n" - " // the hooks on the context domains. Each registration fails open, so one\n" - " // missing domain cannot break the host or the other domain.\n" + " // the tool hook on the context's tool domain. The registration fails\n" + " // open, so a missing domain cannot break the host.\n" " async setup(ctx) {\n" " const seen = new Set();\n" " const dirs = new Map();\n" @@ -646,34 +645,6 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " // Fail open: the server() tool hook stays active.\n" " }\n" " }\n" - " // OpenCode 2 compaction: the hook receives the compaction request, and\n" - " // its messages are the input the summary is built from. The note is\n" - " // added there as a text message, as the server() path adds it to\n" - " // output.context, so it stays inside the compaction and out of the\n" - " // system prompt. Hosts without the hook or the array skip reinjection\n" - " // rather than failing the plugin.\n" - " if (ctx?.session?.hook) {\n" - " try {\n" - " await ctx.session.hook('compaction', async (event) => {\n" - " if (!Array.isArray(event?.messages)) return;\n" - " const dir = await dirFor(event?.sessionID);\n" - " if (!dir) return;\n" - " const note = await lifecycle(dir);\n" - " if (!note) return;\n" - " try {\n" - " event.messages.push({\n" - " role: 'user',\n" - " content: [{ type: 'text', text: note }],\n" - " });\n" - " } catch {\n" - " // Fail open: reinjection is best-effort.\n" - " }\n" - " });\n" - " sessionHooksRegistered = true;\n" - " } catch {\n" - " // Fail open: the server() compaction hook stays active.\n" - " }\n" - " }\n" " },\n" "};\n"); diff --git a/src/cli/client_adapter.h b/src/cli/client_adapter.h index 3c8fc39023..1eec930658 100644 --- a/src/cli/client_adapter.h +++ b/src/cli/client_adapter.h @@ -56,9 +56,10 @@ char *cbm_client_adapter_pi(const char *binary_path); * silently — no error, no signal. That risk is accepted deliberately for the * server() path and recorded here so it is not rediscovered as a mystery. * OpenCode 2 ignores `server` and calls `setup(ctx)`, which registers - * `execute.after` and `compaction` on the context domains. Each registration - * fails open, and the post-compaction note is added as a text message to the - * compaction request's `messages` (the summary input), never to `system`. + * `execute.after` on the context's tool domain and fails open. It registers no + * `compaction` hook: pushing a plain object into `event.messages` would fail, + * if at all, inside OpenCode's own compaction where the plugin cannot catch it, + * so a note after compaction needs a real 2.x run first. * * Returns NULL on allocation failure or when binary_path is NULL/empty. */ char *cbm_client_adapter_opencode(const char *binary_path); diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index 381bfda68e..c92005f9fb 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1344,11 +1344,10 @@ TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction) { /* #2077/#2089/#2204: the two OpenCode runtimes read different entries of the * default export. 1.18.x dispatches the hooks returned by `server(ctx)`; 2.x - * ignores `server` and calls `setup(ctx)`, whose context provides the tool and - * session hook domains. Registrations fail open per domain, so the matching - * server() hook yields once its domain is registered. The post-compaction note - * goes into the compaction request's messages, never the system prompt (pinned - * by the compaction test below). */ + * ignores `server` and calls `setup(ctx)`, whose context provides the tool hook + * domain. The registration fails open, and the server() tool hook yields once + * it is registered. OpenCode 2 adds no note to a compaction (pinned by the + * compaction test below). */ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); ASSERT_NOT_NULL(js); @@ -1360,8 +1359,6 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { * 2.x, so setup is no longer the empty placeholder #2089 pinned. */ ASSERT_NOT_NULL(strstr(js, "async setup(ctx) {")); ASSERT_NOT_NULL(strstr(js, "ctx.tool.hook('execute.after'")); - ASSERT_NOT_NULL(strstr(js, "ctx.session.hook('compaction'")); - ASSERT_NULL(strstr(js, "ctx.session.hook('context'")); /* content is the surface the model reads; prefer it over output, and never * synthesize content that would hide the tool's own result. */ const char *content_first = strstr(js, "if (typeof result.content === 'string') {"); @@ -1382,11 +1379,9 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { * location in a multi-project server. */ ASSERT_NULL(strstr(js, "ctx?.location?.directory")); ASSERT_NULL(strstr(js, "pendingReinject")); - /* Registrations fail open per domain; the server() hooks yield per domain. */ + /* The registration fails open; the server() tool hook yields once it is in. */ ASSERT_NOT_NULL(strstr(js, "toolHooksRegistered = true;")); - ASSERT_NOT_NULL(strstr(js, "sessionHooksRegistered = true;")); ASSERT_NOT_NULL(strstr(js, "if (toolHooksRegistered) return;")); - ASSERT_NOT_NULL(strstr(js, "if (sessionHooksRegistered) return;")); ASSERT_NOT_NULL(strstr(js, "catch {")); ASSERT_NOT_NULL(strstr(js, "event.result = appendResult(event.result, extra);")); /* The empty setup placeholder and the old named export must both be gone. */ @@ -1396,38 +1391,23 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { PASS(); } -/* #2204: on OpenCode 2 the post-compaction note stays inside the compaction, - * at the level the 1.18.x path uses (output.context). The `compaction` hook - * receives the compaction request, whose `messages` are the input the summary - * is built from, so the note is pushed there as a text message. It must never - * reach the system prompt: not through `event.system`, and not through a - * session instruction entry, which OpenCode renders into the system array of - * every later request. */ -TEST(client_adapter_opencode_v2_compaction_note_goes_into_the_compaction_messages) { +/* #2204: OpenCode 2 gets no note after compaction until a compaction channel has + * run on a real 2.x host. A plain object pushed into `event.messages` would be + * rejected, if at all, inside OpenCode's own compaction, where the plugin's + * try/catch cannot reach it, so an untested channel could break compaction for + * every OpenCode 2 user. The note must not reach the system prompt either: not + * through `event.system`, and not through a session instruction entry. The + * 1.18.x server() path keeps its documented compaction surface. */ +TEST(client_adapter_opencode_v2_adds_no_note_to_the_compaction) { char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); ASSERT_NOT_NULL(js); - const char *hook = strstr(js, "ctx.session.hook('compaction'"); - const char *push = strstr(js, "event.messages.push({"); - const char *registered = strstr(js, "sessionHooksRegistered = true;"); - ASSERT_NOT_NULL(hook); - ASSERT_NOT_NULL(push); - ASSERT_NOT_NULL(registered); - /* The push lives inside the compaction registration. */ - ASSERT_TRUE(hook < push); - ASSERT_TRUE(push < registered); - /* A text message that carries the note. */ - const char *role = strstr(push, "role: 'user',"); - const char *text = strstr(push, "content: [{ type: 'text', text: note }],"); - ASSERT_NOT_NULL(role); - ASSERT_NOT_NULL(text); - ASSERT_TRUE(role < registered); - ASSERT_TRUE(text < registered); - /* Fail open: a host whose event has no messages array skips reinjection. */ - ASSERT_NOT_NULL(strstr(js, "if (!Array.isArray(event?.messages)) return;")); - /* Never the system prompt, and no session instruction entry. */ + ASSERT_NULL(strstr(js, "ctx.session.hook(")); + ASSERT_NULL(strstr(js, "event.messages")); + ASSERT_NULL(strstr(js, "sessionHooksRegistered")); ASSERT_NULL(strstr(js, ".system")); ASSERT_NULL(strstr(js, "instructions")); ASSERT_NULL(strstr(js, "entry.put")); + ASSERT_NOT_NULL(strstr(js, "'experimental.session.compacting'")); free(js); PASS(); } @@ -1529,7 +1509,7 @@ SUITE(agent_clients) { RUN_TEST(client_adapter_opencode_sends_the_required_hook_event); RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction); RUN_TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077); - RUN_TEST(client_adapter_opencode_v2_compaction_note_goes_into_the_compaction_messages); + RUN_TEST(client_adapter_opencode_v2_adds_no_note_to_the_compaction); RUN_TEST(client_adapter_opencode_v2_append_result_never_creates_note_only_content); RUN_TEST(client_adapter_opencode_bounds_a_hung_hook_augment_child); RUN_TEST(client_adapter_rejects_missing_binary_path);