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 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") 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 }) 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", 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) 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, 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) 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