From 80c79e1ef20d92bc1a431bdd9152134759eafe60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:36:51 +0300 Subject: [PATCH 01/14] chore: probe V2 compact command registry --- .../workflows/probe-v2-compact-command.yml | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/probe-v2-compact-command.yml diff --git a/.github/workflows/probe-v2-compact-command.yml b/.github/workflows/probe-v2-compact-command.yml new file mode 100644 index 0000000..738d230 --- /dev/null +++ b/.github/workflows/probe-v2-compact-command.yml @@ -0,0 +1,58 @@ +name: Probe V2 compact command + +on: + push: + branches: [feat-v2-compact-runtime-20260814] + paths: + - .github/workflows/probe-v2-compact-command.yml + +permissions: + contents: read + +jobs: + probe: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + ref: feat-v2-compact-runtime-20260814 + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Isolate OpenCode 2 paths + shell: bash + run: | + echo "HOME=$RUNNER_TEMP/opencode2-home" >> "$GITHUB_ENV" + echo "XDG_CONFIG_HOME=$RUNNER_TEMP/opencode2-home/.config" >> "$GITHUB_ENV" + echo "XDG_DATA_HOME=$RUNNER_TEMP/opencode2-home/.local/share" >> "$GITHUB_ENV" + echo "XDG_STATE_HOME=$RUNNER_TEMP/opencode2-home/.local/state" >> "$GITHUB_ENV" + - run: npm install -g --allow-scripts=@opencode-ai/cli @opencode-ai/cli@next + - name: Prepare empty project + shell: bash + run: | + project="$RUNNER_TEMP/opencode-loop-v2-compact-probe" + mkdir -p "$project" "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" + git init --quiet "$project" + git -C "$project" config user.email "opencode-loop-ci@example.invalid" + git -C "$project" config user.name "OpenCode Loop CI" + touch "$project/README.md" + git -C "$project" add README.md + git -C "$project" commit --quiet -m "init" + echo "OPENCODE_LOOP_V2_PROJECT=$project" >> "$GITHUB_ENV" + - name: Inspect command registry + shell: bash + run: | + out="$RUNNER_TEMP/opencode2-command-registry.json" + opencode2 api get /api/command -H "x-opencode-directory: $OPENCODE_LOOP_V2_PROJECT" > "$out" + cat "$out" + node - <<'NODE' "$out" + const fs = require('node:fs') + const file = process.argv[2] + const value = JSON.parse(fs.readFileSync(file, 'utf8')) + const rows = Array.isArray(value) ? value : value?.data || value?.commands || [] + const ids = rows.map((item) => String(item?.id || item?.name || item?.command || '')).filter(Boolean) + console.log('COMMAND_IDS=' + JSON.stringify(ids)) + console.log('COMPACT_MATCHES=' + JSON.stringify(rows.filter((item) => /compact/i.test(JSON.stringify(item))))) + NODE From 92560d68f44c1b97109e658ec47c3acecd03da27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:38:12 +0300 Subject: [PATCH 02/14] chore: probe V2 session wrapper shapes --- .../workflows/probe-v2-compact-command.yml | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/.github/workflows/probe-v2-compact-command.yml b/.github/workflows/probe-v2-compact-command.yml index 738d230..af160c9 100644 --- a/.github/workflows/probe-v2-compact-command.yml +++ b/.github/workflows/probe-v2-compact-command.yml @@ -1,5 +1,6 @@ name: Probe V2 compact command +# Temporary branch-only probe; rerun with session wrapper inspection. on: push: branches: [feat-v2-compact-runtime-20260814] @@ -29,22 +30,45 @@ jobs: echo "XDG_DATA_HOME=$RUNNER_TEMP/opencode2-home/.local/share" >> "$GITHUB_ENV" echo "XDG_STATE_HOME=$RUNNER_TEMP/opencode2-home/.local/state" >> "$GITHUB_ENV" - run: npm install -g --allow-scripts=@opencode-ai/cli @opencode-ai/cli@next - - name: Prepare empty project + - name: Prepare probe project shell: bash run: | project="$RUNNER_TEMP/opencode-loop-v2-compact-probe" - mkdir -p "$project" "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" + plugin_dir="$project/.opencode/plugins" + marker="$project/v2-compact-wrapper-marker.json" + mkdir -p "$plugin_dir" "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" git init --quiet "$project" git -C "$project" config user.email "opencode-loop-ci@example.invalid" git -C "$project" config user.name "OpenCode Loop CI" touch "$project/README.md" - git -C "$project" add README.md + cat > "$plugin_dir/compact-wrapper-probe.js" <<'EOF' + import { writeFile } from "node:fs/promises" + + const marker = new URL("../../v2-compact-wrapper-marker.json", import.meta.url) + const source = (fn) => typeof fn === "function" ? String(fn).slice(0, 2400) : "" + + export default { + id: "bybrawe.opencode-loop.v2.compact-wrapper-probe", + async setup(ctx) { + await writeFile(marker, JSON.stringify({ + methods: Object.fromEntries(Object.keys(ctx?.session || {}).sort().map((key) => [key, typeof ctx.session[key]])), + create: source(ctx?.session?.create), + command: source(ctx?.session?.command), + prompt: source(ctx?.session?.prompt), + synthetic: source(ctx?.session?.synthetic), + }, null, 2), "utf8") + }, + } + EOF + git -C "$project" add . git -C "$project" commit --quiet -m "init" echo "OPENCODE_LOOP_V2_PROJECT=$project" >> "$GITHUB_ENV" - - name: Inspect command registry + echo "OPENCODE_LOOP_V2_MARKER=$marker" >> "$GITHUB_ENV" + - name: Inspect command registry and session wrappers shell: bash run: | out="$RUNNER_TEMP/opencode2-command-registry.json" + rm -f "$OPENCODE_LOOP_V2_MARKER" opencode2 api get /api/command -H "x-opencode-directory: $OPENCODE_LOOP_V2_PROJECT" > "$out" cat "$out" node - <<'NODE' "$out" @@ -56,3 +80,13 @@ jobs: console.log('COMMAND_IDS=' + JSON.stringify(ids)) console.log('COMPACT_MATCHES=' + JSON.stringify(rows.filter((item) => /compact/i.test(JSON.stringify(item))))) NODE + for attempt in $(seq 1 50); do + if [ -f "$OPENCODE_LOOP_V2_MARKER" ]; then + echo "--- SESSION WRAPPERS ---" + cat "$OPENCODE_LOOP_V2_MARKER" + exit 0 + fi + sleep 0.2 + done + echo "wrapper probe marker missing" >&2 + exit 1 From b35d76bd15aca0ba5e03eb8b7ee840d7ac84697b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:39:33 +0300 Subject: [PATCH 03/14] chore: probe real V2 compact command dispatch --- .../workflows/probe-v2-compact-command.yml | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/.github/workflows/probe-v2-compact-command.yml b/.github/workflows/probe-v2-compact-command.yml index af160c9..148feaf 100644 --- a/.github/workflows/probe-v2-compact-command.yml +++ b/.github/workflows/probe-v2-compact-command.yml @@ -1,6 +1,6 @@ name: Probe V2 compact command -# Temporary branch-only probe; rerun with session wrapper inspection. +# Temporary branch-only probe; rerun with real session.command canaries. on: push: branches: [feat-v2-compact-runtime-20260814] @@ -46,16 +46,43 @@ jobs: const marker = new URL("../../v2-compact-wrapper-marker.json", import.meta.url) const source = (fn) => typeof fn === "function" ? String(fn).slice(0, 2400) : "" + const safe = (value) => { + try { return JSON.parse(JSON.stringify(value)) } catch { return String(value) } + } + async function callWithTimeout(label, fn) { + try { + const value = await Promise.race([ + Promise.resolve().then(fn), + new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out`)), 5_000)), + ]) + return { ok: true, value: safe(value) } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + } export default { id: "bybrawe.opencode-loop.v2.compact-wrapper-probe", async setup(ctx) { + const created = await callWithTimeout("session.create", () => ctx.session.create()) + const sessionID = created.ok + ? String(created.value?.id || created.value?.data?.id || created.value?.sessionID || "") + : "" + const compact = sessionID + ? await callWithTimeout("session.command compact", () => ctx.session.command({ sessionID, id: "compact" })) + : { ok: false, error: "session create returned no id" } + const review = sessionID + ? await callWithTimeout("session.command review", () => ctx.session.command({ sessionID, id: "review" })) + : { ok: false, error: "session create returned no id" } + await writeFile(marker, JSON.stringify({ methods: Object.fromEntries(Object.keys(ctx?.session || {}).sort().map((key) => [key, typeof ctx.session[key]])), - create: source(ctx?.session?.create), - command: source(ctx?.session?.command), - prompt: source(ctx?.session?.prompt), - synthetic: source(ctx?.session?.synthetic), + createSource: source(ctx?.session?.create), + commandSource: source(ctx?.session?.command), + created, + sessionID, + compact, + review, }, null, 2), "utf8") }, } @@ -64,7 +91,7 @@ jobs: git -C "$project" commit --quiet -m "init" echo "OPENCODE_LOOP_V2_PROJECT=$project" >> "$GITHUB_ENV" echo "OPENCODE_LOOP_V2_MARKER=$marker" >> "$GITHUB_ENV" - - name: Inspect command registry and session wrappers + - name: Inspect command registry and command canaries shell: bash run: | out="$RUNNER_TEMP/opencode2-command-registry.json" @@ -80,13 +107,13 @@ jobs: console.log('COMMAND_IDS=' + JSON.stringify(ids)) console.log('COMPACT_MATCHES=' + JSON.stringify(rows.filter((item) => /compact/i.test(JSON.stringify(item))))) NODE - for attempt in $(seq 1 50); do + for attempt in $(seq 1 80); do if [ -f "$OPENCODE_LOOP_V2_MARKER" ]; then - echo "--- SESSION WRAPPERS ---" + echo "--- SESSION COMMAND CANARIES ---" cat "$OPENCODE_LOOP_V2_MARKER" exit 0 fi sleep 0.2 done - echo "wrapper probe marker missing" >&2 + echo "command canary marker missing" >&2 exit 1 From 83ef497ab9c3e6eb6859bf546914c6462a73bba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:41:10 +0300 Subject: [PATCH 04/14] chore: probe V2 command field semantics --- .../workflows/probe-v2-compact-command.yml | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/probe-v2-compact-command.yml b/.github/workflows/probe-v2-compact-command.yml index 148feaf..f54efb6 100644 --- a/.github/workflows/probe-v2-compact-command.yml +++ b/.github/workflows/probe-v2-compact-command.yml @@ -1,6 +1,6 @@ name: Probe V2 compact command -# Temporary branch-only probe; rerun with real session.command canaries. +# Temporary branch-only probe; rerun with command-field canaries. on: push: branches: [feat-v2-compact-runtime-20260814] @@ -45,7 +45,6 @@ jobs: import { writeFile } from "node:fs/promises" const marker = new URL("../../v2-compact-wrapper-marker.json", import.meta.url) - const source = (fn) => typeof fn === "function" ? String(fn).slice(0, 2400) : "" const safe = (value) => { try { return JSON.parse(JSON.stringify(value)) } catch { return String(value) } } @@ -68,21 +67,22 @@ jobs: const sessionID = created.ok ? String(created.value?.id || created.value?.data?.id || created.value?.sessionID || "") : "" - const compact = sessionID - ? await callWithTimeout("session.command compact", () => ctx.session.command({ sessionID, id: "compact" })) + const compactByCommand = sessionID + ? await callWithTimeout("session.command command=compact", () => ctx.session.command({ sessionID, command: "compact" })) : { ok: false, error: "session create returned no id" } - const review = sessionID - ? await callWithTimeout("session.command review", () => ctx.session.command({ sessionID, id: "review" })) + const reviewByCommand = sessionID + ? await callWithTimeout("session.command command=review", () => ctx.session.command({ sessionID, command: "review", arguments: "--quick" })) + : { ok: false, error: "session create returned no id" } + const compactByName = sessionID + ? await callWithTimeout("session.command name=compact", () => ctx.session.command({ sessionID, name: "compact" })) : { ok: false, error: "session create returned no id" } await writeFile(marker, JSON.stringify({ - methods: Object.fromEntries(Object.keys(ctx?.session || {}).sort().map((key) => [key, typeof ctx.session[key]])), - createSource: source(ctx?.session?.create), - commandSource: source(ctx?.session?.command), created, sessionID, - compact, - review, + compactByCommand, + reviewByCommand, + compactByName, }, null, 2), "utf8") }, } From fedbc6a9e17af8b3a71d398ea64f6bb54a2334b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:42:56 +0300 Subject: [PATCH 05/14] fix: use V2 command field instead of message id --- src/source/opencode2/host-contract.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/source/opencode2/host-contract.js b/src/source/opencode2/host-contract.js index 67d104c..fa76508 100644 --- a/src/source/opencode2/host-contract.js +++ b/src/source/opencode2/host-contract.js @@ -18,12 +18,12 @@ function normalizePrompt(input) { function normalizeCommand(input) { const sessionID = String(input?.sessionID || "").trim() - const id = String(input?.id || "").trim().replace(/^\/+/, "") + const command = String(input?.command || "").trim().replace(/^\/+/, "") if (!sessionID) throw new TypeError("command requires a session ID") - if (!id) throw new TypeError("command requires an ID") + if (!command) throw new TypeError("command requires a command name") return Object.freeze({ sessionID, - id, + command, arguments: input?.arguments === undefined ? undefined : String(input.arguments), agent: input?.agent, model: input?.model, From 955c8a6764aad2432b925ab933c04ee7979404da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:43:10 +0300 Subject: [PATCH 06/14] fix: send V2 command name in command field --- src/source/opencode2/runtime-adapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/source/opencode2/runtime-adapter.js b/src/source/opencode2/runtime-adapter.js index 3876208..c34df3b 100644 --- a/src/source/opencode2/runtime-adapter.js +++ b/src/source/opencode2/runtime-adapter.js @@ -11,7 +11,7 @@ function promptRequest(request) { function commandRequest(request) { const value = { sessionID: request.sessionID, - id: request.id, + command: request.command, } if (request.arguments !== undefined) value.arguments = request.arguments if (request.agent !== undefined) value.agent = request.agent From b3907a793ce2999dee42060f14534085b1244ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:43:49 +0300 Subject: [PATCH 07/14] fix: dispatch V2 loop commands with command field --- src/source/opencode2/prompt-runtime.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/source/opencode2/prompt-runtime.js b/src/source/opencode2/prompt-runtime.js index 5cb3fa6..0962eb0 100644 --- a/src/source/opencode2/prompt-runtime.js +++ b/src/source/opencode2/prompt-runtime.js @@ -158,10 +158,10 @@ export function createOpenCode2PromptRuntime(options = {}) { async function dispatchJob(scope, job) { const kind = actionKind(job?.action, job) if (kind === "command") { - const [id, argumentsText] = commandParts(job.action) + const [command, argumentsText] = commandParts(job.action) const request = { sessionID: scope.sessionID, - id, + command, arguments: argumentsText || undefined, } await options.command(request) From 21abb5aa5c024c87492c9fcbcc3ea102d49584f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:44:14 +0300 Subject: [PATCH 08/14] test: lock V2 command field dispatch shape --- scripts/v2-command-runtime-test.mjs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/v2-command-runtime-test.mjs b/scripts/v2-command-runtime-test.mjs index 0bf665c..9bd7be9 100644 --- a/scripts/v2-command-runtime-test.mjs +++ b/scripts/v2-command-runtime-test.mjs @@ -34,8 +34,9 @@ try { const first = await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory }) assert.equal(first.dispatched, true) assert.equal(first.kind, "command") - assert.deepEqual(first.request, { sessionID, id: "review", arguments: "--quick" }) - assert.deepEqual(commands, [{ sessionID, id: "review", arguments: "--quick" }]) + assert.deepEqual(first.request, { sessionID, command: "review", arguments: "--quick" }) + assert.deepEqual(commands, [{ sessionID, command: "review", arguments: "--quick" }]) + assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false, "OpenCode 2 id is a msg_ message ID, never the command name") assert.equal(prompts.length, 0) let state = await readState() assert.equal(state.jobs[0].runCount, 1) @@ -43,7 +44,7 @@ try { const second = await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory }) assert.equal(second.dispatched, true) - assert.deepEqual(commands[1], { sessionID, id: "review", arguments: "--quick" }) + assert.deepEqual(commands[1], { sessionID, command: "review", arguments: "--quick" }) state = await readState() assert.equal(state.jobs[0].runCount, 2) assert.equal(state.jobs[0].enabled, false) @@ -52,6 +53,17 @@ try { assert.equal(third.dispatched, false) assert.equal(commands.length, 2) + const compact = await runtime.onEvent({ + kind: "command", + action: "executed", + name: "loop", + sessionID, + directory, + arguments: '0s --command "/compact"', + }) + assert.equal(compact.accepted, false) + assert.ok(compact.blockers.includes("kind"), "shared actionKind keeps /compact on the dedicated compact path") + const shell = await runtime.onEvent({ kind: "command", action: "executed", From 0ee3149b537df3a7446ef7cae1f429076e562ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:44:39 +0300 Subject: [PATCH 09/14] test: enforce V2 command field contract --- scripts/v2-host-contract-test.mjs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/scripts/v2-host-contract-test.mjs b/scripts/v2-host-contract-test.mjs index aa1cabd..e53917d 100644 --- a/scripts/v2-host-contract-test.mjs +++ b/scripts/v2-host-contract-test.mjs @@ -24,7 +24,7 @@ const host = createOpenCode2HostContract({ }) await assert.rejects(host.prompt({ sessionID: "ses_a", text: "early" }), /not started/) -await assert.rejects(host.command({ sessionID: "ses_a", id: "review" }), /not started/) +await assert.rejects(host.command({ sessionID: "ses_a", command: "review" }), /not started/) assert.equal(await host.start(), true) assert.equal(await host.start(), false) assert.equal(typeof listener, "function") @@ -43,25 +43,27 @@ assert.equal(prompts[0].sessionID, "ses_a") assert.equal(prompts[0].text, "continue") assert.equal(prompts[0].runtime, runtime) -const commandResult = await host.command({ sessionID: "ses_a", id: "/review", arguments: "--quick" }) +const commandResult = await host.command({ sessionID: "ses_a", command: "/review", arguments: "--quick" }) assert.deepEqual(commandResult, { accepted: true, kind: "command" }) assert.equal(commands.length, 1) assert.equal(commands[0].sessionID, "ses_a") -assert.equal(commands[0].id, "review") +assert.equal(commands[0].command, "review") +assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false) assert.equal(commands[0].arguments, "--quick") assert.equal(commands[0].runtime, runtime) await assert.rejects(host.prompt({ sessionID: "", text: "missing" }), /session ID/) await assert.rejects(host.prompt({ sessionID: "ses_a", text: " " }), /requires text/) -await assert.rejects(host.command({ sessionID: "", id: "review" }), /session ID/) -await assert.rejects(host.command({ sessionID: "ses_a", id: "" }), /requires an ID/) +await assert.rejects(host.command({ sessionID: "", command: "review" }), /session ID/) +await assert.rejects(host.command({ sessionID: "ses_a", command: "" }), /requires a command name/) +await assert.rejects(host.command({ sessionID: "ses_a", id: "review" }), /requires a command name/) const promptOnly = createOpenCode2HostContract({ subscribe: async (_callback) => ({ unsubscribe: async () => {} }), sendPrompt: async () => ({ accepted: true }), }) await promptOnly.start() -await assert.rejects(promptOnly.command({ sessionID: "ses_a", id: "review" }), /session\.command capability is unavailable/) +await assert.rejects(promptOnly.command({ sessionID: "ses_a", command: "review" }), /session\.command capability is unavailable/) await promptOnly.dispose() await listener({ @@ -72,7 +74,7 @@ assert.equal(host.isHostDisposed(), true) assert.deepEqual(host.runtimeManager.entries(), []) assert.ok(events.some(({ event }) => event.kind === "server" && event.action === "disposed")) await assert.rejects(host.prompt({ sessionID: "ses_a", text: "late" }), /unavailable/) -await assert.rejects(host.command({ sessionID: "ses_a", id: "review" }), /unavailable/) +await assert.rejects(host.command({ sessionID: "ses_a", command: "review" }), /unavailable/) assert.equal(await host.dispose(), true) assert.equal(await host.dispose(), false) From b11a26c9a620f7f1dff4cdce3156dccf539106fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:45:06 +0300 Subject: [PATCH 10/14] test: lock corrected V2 command adapter shape --- scripts/v2-command-adapter-test.mjs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/scripts/v2-command-adapter-test.mjs b/scripts/v2-command-adapter-test.mjs index acbdbb8..6761df9 100644 --- a/scripts/v2-command-adapter-test.mjs +++ b/scripts/v2-command-adapter-test.mjs @@ -54,12 +54,10 @@ async function waitFor(predicate, description, timeoutMs = 2_000) { } const adapter = createOpenCode2RuntimeAdapter(ctx) await adapter.start() - const result = await adapter.command({ sessionID: "ses_adapter", id: "/review", arguments: "--quick" }) + const result = await adapter.command({ sessionID: "ses_adapter", command: "/review", arguments: "--quick" }) assert.deepEqual(result, { accepted: true }) - assert.equal(commands.length, 1) - assert.equal(commands[0].sessionID, "ses_adapter") - assert.equal(commands[0].id, "review") - assert.equal(commands[0].arguments, "--quick") + assert.deepEqual(commands, [{ sessionID: "ses_adapter", command: "review", arguments: "--quick" }]) + assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false) await adapter.dispose() } @@ -70,7 +68,7 @@ async function waitFor(predicate, description, timeoutMs = 2_000) { session: { prompt: async () => ({ accepted: true }) }, }) await adapter.start() - await assert.rejects(adapter.command({ sessionID: "ses_adapter", id: "review" }), /session\.command capability is unavailable/) + await assert.rejects(adapter.command({ sessionID: "ses_adapter", command: "review" }), /session\.command capability is unavailable/) await adapter.dispose() } @@ -105,7 +103,7 @@ async function waitFor(predicate, description, timeoutMs = 2_000) { }) events.push({ directory, payload: { type: "session.idle", properties: { sessionID } } }) await waitFor(() => commands.length === 1, "V2 command dispatch") - assert.deepEqual(commands[0], { sessionID, id: "review", arguments: "--quick" }) + assert.deepEqual(commands[0], { sessionID, command: "review", arguments: "--quick" }) } finally { await cleanup?.() await rm(directory, { recursive: true, force: true }) From daf680a9065c487c2997397f0ba4d645657ae4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:45:44 +0300 Subject: [PATCH 11/14] test: probe real V2 command field semantics --- .../fixtures/opencode2-real-adapter-probe.js | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/scripts/fixtures/opencode2-real-adapter-probe.js b/scripts/fixtures/opencode2-real-adapter-probe.js index d20dbe4..b30885e 100644 --- a/scripts/fixtures/opencode2-real-adapter-probe.js +++ b/scripts/fixtures/opencode2-real-adapter-probe.js @@ -1,5 +1,7 @@ import { writeFile } from "node:fs/promises" +const COMMAND_SENTINEL = "__opencode_loop_missing_command_probe__" + export default { id: "bybrawe.opencode-loop.v2.real-adapter-probe", async setup(ctx) { @@ -19,6 +21,35 @@ export default { .map((key) => [key, typeof ctx.session[key]]), ) + let commandFieldProbe = { matched: false, error: "session command probe did not run" } + if (typeof ctx?.session?.create === "function" && typeof ctx?.session?.command === "function") { + try { + const created = await ctx.session.create() + const sessionID = String(created?.id || created?.data?.id || created?.sessionID || "") + if (!sessionID) throw new Error("session.create returned no session id") + try { + await ctx.session.command({ sessionID, command: COMMAND_SENTINEL }) + commandFieldProbe = { + matched: false, + sessionID, + error: "missing command unexpectedly succeeded", + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + commandFieldProbe = { + matched: message.includes(`Command not found: ${COMMAND_SENTINEL}`), + sessionID, + error: message, + } + } + } catch (error) { + commandFieldProbe = { + matched: false, + error: error instanceof Error ? error.message : String(error), + } + } + } + await writeFile(marker, JSON.stringify({ id: plugin.id, activated: true, @@ -28,6 +59,7 @@ export default { sessionCommand: typeof ctx?.session?.command === "function", sessionShell: typeof ctx?.session?.shell === "function", sessionMethods, + commandFieldProbe, toolTransform: typeof ctx?.tool?.transform === "function", }, null, 2), "utf8") From 76b23e905e7a7ac838bf54762ebdd6971d073402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:46:03 +0300 Subject: [PATCH 12/14] test: require real V2 command field semantics --- .github/workflows/opencode2-real-adapter.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/opencode2-real-adapter.yml b/.github/workflows/opencode2-real-adapter.yml index f02270e..7ebb022 100644 --- a/.github/workflows/opencode2-real-adapter.yml +++ b/.github/workflows/opencode2-real-adapter.yml @@ -62,10 +62,10 @@ jobs: if [ ! -f "$OPENCODE_LOOP_V2_MARKER" ]; then opencode2 api get /api/plugin -H "x-opencode-directory: $OPENCODE_LOOP_V2_PROJECT" > "$RUNNER_TEMP/opencode2-real-plugin.json" 2>&1 || true fi - for attempt in $(seq 1 50); do + for attempt in $(seq 1 80); do if [ -f "$OPENCODE_LOOP_V2_MARKER" ]; then cat "$OPENCODE_LOOP_V2_MARKER" - node -e 'const fs=require("node:fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if(value.id!=="bybrawe.opencode-loop.v2.experimental" || value.activated!==true || value.commandTransform!==true || value.eventSubscribe!==true || value.sessionPrompt!==true || value.sessionCommand!==true) process.exit(1)' "$OPENCODE_LOOP_V2_MARKER" + node -e 'const fs=require("node:fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if(value.id!=="bybrawe.opencode-loop.v2.experimental" || value.activated!==true || value.commandTransform!==true || value.eventSubscribe!==true || value.sessionPrompt!==true || value.sessionCommand!==true || value.commandFieldProbe?.matched!==true) process.exit(1)' "$OPENCODE_LOOP_V2_MARKER" exit 0 fi sleep 0.2 From 14e8a0c411f0e02e3accd953781861e66fcd0588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:46:49 +0300 Subject: [PATCH 13/14] chore: validate corrected V2 command transport --- .../workflows/probe-v2-compact-command.yml | 97 ++++++------------- 1 file changed, 27 insertions(+), 70 deletions(-) diff --git a/.github/workflows/probe-v2-compact-command.yml b/.github/workflows/probe-v2-compact-command.yml index f54efb6..a593982 100644 --- a/.github/workflows/probe-v2-compact-command.yml +++ b/.github/workflows/probe-v2-compact-command.yml @@ -1,6 +1,6 @@ -name: Probe V2 compact command +name: Validate corrected V2 command transport -# Temporary branch-only probe; rerun with command-field canaries. +# Temporary branch-only validation; removed before PR. on: push: branches: [feat-v2-compact-runtime-20260814] @@ -11,9 +11,9 @@ permissions: contents: read jobs: - probe: + validate: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v6 with: @@ -22,6 +22,7 @@ jobs: - uses: actions/setup-node@v6 with: node-version: "24" + cache: npm - name: Isolate OpenCode 2 paths shell: bash run: | @@ -29,91 +30,47 @@ jobs: echo "XDG_CONFIG_HOME=$RUNNER_TEMP/opencode2-home/.config" >> "$GITHUB_ENV" echo "XDG_DATA_HOME=$RUNNER_TEMP/opencode2-home/.local/share" >> "$GITHUB_ENV" echo "XDG_STATE_HOME=$RUNNER_TEMP/opencode2-home/.local/state" >> "$GITHUB_ENV" + - run: npm ci + - run: npm run check + - run: node scripts/v2-host-contract-test.mjs + - run: node scripts/v2-command-runtime-test.mjs + - run: node scripts/v2-command-adapter-test.mjs + - run: npm test + - run: git diff --check - run: npm install -g --allow-scripts=@opencode-ai/cli @opencode-ai/cli@next - - name: Prepare probe project + - run: npm install --no-save --package-lock=false @opencode-ai/plugin@next + - name: Prepare real-adapter probe project shell: bash run: | - project="$RUNNER_TEMP/opencode-loop-v2-compact-probe" + project="$RUNNER_TEMP/opencode-loop-v2-command-field-probe" plugin_dir="$project/.opencode/plugins" - marker="$project/v2-compact-wrapper-marker.json" mkdir -p "$plugin_dir" "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" + cp "$GITHUB_WORKSPACE/scripts/fixtures/opencode2-real-adapter-probe.js" "$plugin_dir/opencode-loop-v2-real.js" git init --quiet "$project" git -C "$project" config user.email "opencode-loop-ci@example.invalid" git -C "$project" config user.name "OpenCode Loop CI" - touch "$project/README.md" - cat > "$plugin_dir/compact-wrapper-probe.js" <<'EOF' - import { writeFile } from "node:fs/promises" - - const marker = new URL("../../v2-compact-wrapper-marker.json", import.meta.url) - const safe = (value) => { - try { return JSON.parse(JSON.stringify(value)) } catch { return String(value) } - } - async function callWithTimeout(label, fn) { - try { - const value = await Promise.race([ - Promise.resolve().then(fn), - new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out`)), 5_000)), - ]) - return { ok: true, value: safe(value) } - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } - } - - export default { - id: "bybrawe.opencode-loop.v2.compact-wrapper-probe", - async setup(ctx) { - const created = await callWithTimeout("session.create", () => ctx.session.create()) - const sessionID = created.ok - ? String(created.value?.id || created.value?.data?.id || created.value?.sessionID || "") - : "" - const compactByCommand = sessionID - ? await callWithTimeout("session.command command=compact", () => ctx.session.command({ sessionID, command: "compact" })) - : { ok: false, error: "session create returned no id" } - const reviewByCommand = sessionID - ? await callWithTimeout("session.command command=review", () => ctx.session.command({ sessionID, command: "review", arguments: "--quick" })) - : { ok: false, error: "session create returned no id" } - const compactByName = sessionID - ? await callWithTimeout("session.command name=compact", () => ctx.session.command({ sessionID, name: "compact" })) - : { ok: false, error: "session create returned no id" } - - await writeFile(marker, JSON.stringify({ - created, - sessionID, - compactByCommand, - reviewByCommand, - compactByName, - }, null, 2), "utf8") - }, - } - EOF git -C "$project" add . git -C "$project" commit --quiet -m "init" + plugin_url="$(node --input-type=module -e 'import { pathToFileURL } from "node:url"; console.log(pathToFileURL(process.argv[1]).href)' "$GITHUB_WORKSPACE/src/source/opencode2/experimental.js")" + echo "OPENCODE_LOOP_V2_PLUGIN_URL=$plugin_url" >> "$GITHUB_ENV" + echo "OPENCODE_LOOP_V2_MARKER=$project/v2-real-adapter-marker.json" >> "$GITHUB_ENV" echo "OPENCODE_LOOP_V2_PROJECT=$project" >> "$GITHUB_ENV" - echo "OPENCODE_LOOP_V2_MARKER=$marker" >> "$GITHUB_ENV" - - name: Inspect command registry and command canaries + - name: Verify real command-field semantics shell: bash run: | - out="$RUNNER_TEMP/opencode2-command-registry.json" rm -f "$OPENCODE_LOOP_V2_MARKER" - opencode2 api get /api/command -H "x-opencode-directory: $OPENCODE_LOOP_V2_PROJECT" > "$out" - cat "$out" - node - <<'NODE' "$out" - const fs = require('node:fs') - const file = process.argv[2] - const value = JSON.parse(fs.readFileSync(file, 'utf8')) - const rows = Array.isArray(value) ? value : value?.data || value?.commands || [] - const ids = rows.map((item) => String(item?.id || item?.name || item?.command || '')).filter(Boolean) - console.log('COMMAND_IDS=' + JSON.stringify(ids)) - console.log('COMPACT_MATCHES=' + JSON.stringify(rows.filter((item) => /compact/i.test(JSON.stringify(item))))) - NODE + opencode2 api get /api/command -H "x-opencode-directory: $OPENCODE_LOOP_V2_PROJECT" > "$RUNNER_TEMP/opencode2-command-field.json" 2>&1 || true + if [ ! -f "$OPENCODE_LOOP_V2_MARKER" ]; then + opencode2 api get /api/plugin -H "x-opencode-directory: $OPENCODE_LOOP_V2_PROJECT" > "$RUNNER_TEMP/opencode2-plugin-field.json" 2>&1 || true + fi for attempt in $(seq 1 80); do if [ -f "$OPENCODE_LOOP_V2_MARKER" ]; then - echo "--- SESSION COMMAND CANARIES ---" cat "$OPENCODE_LOOP_V2_MARKER" + node -e 'const fs=require("node:fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if(value.sessionCommand!==true || value.commandFieldProbe?.matched!==true) process.exit(1)' "$OPENCODE_LOOP_V2_MARKER" exit 0 fi sleep 0.2 done - echo "command canary marker missing" >&2 + cat "$RUNNER_TEMP/opencode2-command-field.json" >&2 || true + cat "$RUNNER_TEMP/opencode2-plugin-field.json" >&2 || true exit 1 From 19249fe3a91e775016b00ef1f0b0f7a9f4e30081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 16:48:52 +0300 Subject: [PATCH 14/14] chore: remove temporary V2 command transport validation --- .../workflows/probe-v2-compact-command.yml | 76 ------------------- 1 file changed, 76 deletions(-) delete mode 100644 .github/workflows/probe-v2-compact-command.yml diff --git a/.github/workflows/probe-v2-compact-command.yml b/.github/workflows/probe-v2-compact-command.yml deleted file mode 100644 index a593982..0000000 --- a/.github/workflows/probe-v2-compact-command.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Validate corrected V2 command transport - -# Temporary branch-only validation; removed before PR. -on: - push: - branches: [feat-v2-compact-runtime-20260814] - paths: - - .github/workflows/probe-v2-compact-command.yml - -permissions: - contents: read - -jobs: - validate: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v6 - with: - ref: feat-v2-compact-runtime-20260814 - persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: npm - - name: Isolate OpenCode 2 paths - shell: bash - run: | - echo "HOME=$RUNNER_TEMP/opencode2-home" >> "$GITHUB_ENV" - echo "XDG_CONFIG_HOME=$RUNNER_TEMP/opencode2-home/.config" >> "$GITHUB_ENV" - echo "XDG_DATA_HOME=$RUNNER_TEMP/opencode2-home/.local/share" >> "$GITHUB_ENV" - echo "XDG_STATE_HOME=$RUNNER_TEMP/opencode2-home/.local/state" >> "$GITHUB_ENV" - - run: npm ci - - run: npm run check - - run: node scripts/v2-host-contract-test.mjs - - run: node scripts/v2-command-runtime-test.mjs - - run: node scripts/v2-command-adapter-test.mjs - - run: npm test - - run: git diff --check - - run: npm install -g --allow-scripts=@opencode-ai/cli @opencode-ai/cli@next - - run: npm install --no-save --package-lock=false @opencode-ai/plugin@next - - name: Prepare real-adapter probe project - shell: bash - run: | - project="$RUNNER_TEMP/opencode-loop-v2-command-field-probe" - plugin_dir="$project/.opencode/plugins" - mkdir -p "$plugin_dir" "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" - cp "$GITHUB_WORKSPACE/scripts/fixtures/opencode2-real-adapter-probe.js" "$plugin_dir/opencode-loop-v2-real.js" - git init --quiet "$project" - git -C "$project" config user.email "opencode-loop-ci@example.invalid" - git -C "$project" config user.name "OpenCode Loop CI" - git -C "$project" add . - git -C "$project" commit --quiet -m "init" - plugin_url="$(node --input-type=module -e 'import { pathToFileURL } from "node:url"; console.log(pathToFileURL(process.argv[1]).href)' "$GITHUB_WORKSPACE/src/source/opencode2/experimental.js")" - echo "OPENCODE_LOOP_V2_PLUGIN_URL=$plugin_url" >> "$GITHUB_ENV" - echo "OPENCODE_LOOP_V2_MARKER=$project/v2-real-adapter-marker.json" >> "$GITHUB_ENV" - echo "OPENCODE_LOOP_V2_PROJECT=$project" >> "$GITHUB_ENV" - - name: Verify real command-field semantics - shell: bash - run: | - rm -f "$OPENCODE_LOOP_V2_MARKER" - opencode2 api get /api/command -H "x-opencode-directory: $OPENCODE_LOOP_V2_PROJECT" > "$RUNNER_TEMP/opencode2-command-field.json" 2>&1 || true - if [ ! -f "$OPENCODE_LOOP_V2_MARKER" ]; then - opencode2 api get /api/plugin -H "x-opencode-directory: $OPENCODE_LOOP_V2_PROJECT" > "$RUNNER_TEMP/opencode2-plugin-field.json" 2>&1 || true - fi - for attempt in $(seq 1 80); do - if [ -f "$OPENCODE_LOOP_V2_MARKER" ]; then - cat "$OPENCODE_LOOP_V2_MARKER" - node -e 'const fs=require("node:fs"); const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if(value.sessionCommand!==true || value.commandFieldProbe?.matched!==true) process.exit(1)' "$OPENCODE_LOOP_V2_MARKER" - exit 0 - fi - sleep 0.2 - done - cat "$RUNNER_TEMP/opencode2-command-field.json" >&2 || true - cat "$RUNNER_TEMP/opencode2-plugin-field.json" >&2 || true - exit 1