diff --git a/README.md b/README.md index c4428e067..48f73ec7e 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 e63019360..f5747c4c8 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 3c8fc3902..1eec93065 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 381bfda68..c92005f9f 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);