diff --git a/.github/workflows/opencode2-activation.yml b/.github/workflows/opencode2-activation.yml index f0866eb..be51531 100644 --- a/.github/workflows/opencode2-activation.yml +++ b/.github/workflows/opencode2-activation.yml @@ -4,12 +4,16 @@ on: pull_request: paths: - "src/source/opencode2/**" + - "src/source/runtime/**" + - "commands/**" - ".github/workflows/opencode2-activation.yml" push: branches: - main paths: - "src/source/opencode2/**" + - "src/source/runtime/**" + - "commands/**" - ".github/workflows/opencode2-activation.yml" workflow_dispatch: @@ -99,8 +103,6 @@ jobs: EOF git -C "$project" add .opencode/plugins/opencode-loop-v2-probe.js git -C "$project" commit --quiet -m "init" - git -C "$project" rev-parse --show-toplevel - cat "$plugin_dir/opencode-loop-v2-probe.js" - name: Verify dependency-free setup runs in real OpenCode 2 host shell: bash @@ -108,12 +110,10 @@ jobs: project="$RUNNER_TEMP/opencode-loop-v2-activation" marker="$project/v2-activation-marker.json" rm -f "$marker" - opencode2 api get /api/command -H "x-opencode-directory: $project" > "$RUNNER_TEMP/opencode2-command.json" 2>&1 || true if [ ! -f "$marker" ]; then opencode2 api get /api/plugin -H "x-opencode-directory: $project" > "$RUNNER_TEMP/opencode2-plugin.json" 2>&1 || true fi - for attempt in $(seq 1 50); do if [ -f "$marker" ]; then cat "$marker" @@ -122,14 +122,66 @@ jobs: fi sleep 0.2 done - echo "OpenCode 2 did not execute the local plugin setup hook." >&2 - echo "--- /api/command ---" >&2 cat "$RUNNER_TEMP/opencode2-command.json" >&2 || true - echo "--- /api/plugin ---" >&2 cat "$RUNNER_TEMP/opencode2-plugin.json" >&2 || true exit 1 + - name: Prepare real experimental Loop V2 adapter + shell: bash + run: | + project="$RUNNER_TEMP/opencode-loop-v2-real-adapter" + plugin_dir="$project/.opencode/plugins" + command_dir="$project/.opencode/commands" + mkdir -p "$plugin_dir" "$command_dir" + cp "$GITHUB_WORKSPACE"/commands/*.md "$command_dir"/ + git init --quiet "$project" + git -C "$project" config user.email "opencode-loop-ci@example.invalid" + git -C "$project" config user.name "OpenCode Loop CI" + export LOOP_V2_PLUGIN_FILE="$plugin_dir/opencode-loop-v2-real.js" + node --input-type=module <<'EOF' + import { writeFile } from "node:fs/promises" + import { pathToFileURL } from "node:url" + import path from "node:path" + const source = pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, "src/source/opencode2/experimental.js")).href + const content = [ + `import plugin from ${JSON.stringify(source)}`, + `import { writeFile } from "node:fs/promises"`, + `const marker = new URL("../../v2-real-adapter-marker.json", import.meta.url)`, + `export default {`, + ` id: plugin.id,`, + ` async setup(ctx) {`, + ` await plugin.setup(ctx)`, + ` await writeFile(marker, JSON.stringify({ id: plugin.id, activated: true }, null, 2), "utf8")`, + ` },`, + `}`, + ].join("\n") + "\n" + await writeFile(process.env.LOOP_V2_PLUGIN_FILE, content, "utf8") + EOF + git -C "$project" add .opencode + git -C "$project" commit --quiet -m "init" + + - name: Verify real Loop V2 adapter setup and command discovery + shell: bash + run: | + project="$RUNNER_TEMP/opencode-loop-v2-real-adapter" + marker="$project/v2-real-adapter-marker.json" + command_json="$RUNNER_TEMP/opencode2-real-command.json" + plugin_json="$RUNNER_TEMP/opencode2-real-plugin.json" + rm -f "$marker" + opencode2 api get /api/command -H "x-opencode-directory: $project" > "$command_json" 2>&1 || true + opencode2 api get /api/plugin -H "x-opencode-directory: $project" > "$plugin_json" 2>&1 || true + for attempt in $(seq 1 50); do + if [ -f "$marker" ]; then break; fi + sleep 0.2 + done + test -f "$marker" + cat "$marker" + cat "$command_json" + cat "$plugin_json" + 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) process.exit(1)' "$marker" + node -e 'const fs=require("node:fs"); const text=fs.readFileSync(process.argv[1],"utf8"); if(!/\"loop\"/.test(text)) process.exit(1)' "$command_json" + - name: Dump OpenCode 2 logs on failure if: failure() shell: bash diff --git a/scripts/v2-plugin-sentinel-test.mjs b/scripts/v2-plugin-sentinel-test.mjs index ce52019..bc0d73c 100644 --- a/scripts/v2-plugin-sentinel-test.mjs +++ b/scripts/v2-plugin-sentinel-test.mjs @@ -20,6 +20,9 @@ assert.deepEqual(OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS, ["event.subscribe", "ses let transforms = 0 let registered +let subscriptions = 0 +async function* emptyEvents() {} + const currentContext = { command: { transform: async (callback) => { @@ -27,10 +30,21 @@ const currentContext = { registered = callback }, }, + event: { + subscribe: async () => { + subscriptions += 1 + return { stream: emptyEvents() } + }, + }, + session: { + prompt: async () => ({}), + }, } + const setupResult = await plugin.setup(currentContext) assert.equal(setupResult, undefined) assert.equal(transforms, 1) +assert.equal(subscriptions, 1) assert.equal(typeof registered, "function") const currentDraft = { @@ -43,9 +57,9 @@ await registered(currentDraft) assert.deepEqual(inspectOpenCode2Context(currentContext), { commandTransform: true, - eventSubscribe: false, + eventSubscribe: true, sessionHook: false, - sessionPrompt: false, + sessionPrompt: true, toolTransform: false, toolHook: false, }) @@ -57,31 +71,27 @@ assert.deepEqual(inspectOpenCode2CommandDraft(currentDraft), { }) const currentStatus = openCode2LoopRuntimeStatus(currentContext, currentDraft) -assert.deepEqual(currentStatus.hostBlockers, ["event.subscribe", "session.prompt"]) -assert.deepEqual(currentStatus.blockers, ["event.subscribe", "session.prompt", "runtime.adapter"]) -assert.equal(currentStatus.hostReady, false) +assert.deepEqual(currentStatus.hostBlockers, []) +assert.deepEqual(currentStatus.blockers, ["runtime.adapter"]) +assert.equal(currentStatus.hostReady, true) assert.equal(currentStatus.implementationReady, false) assert.equal(currentStatus.ready, false) assert.equal(currentStatus.commandSource, "file-definitions") -const futureContext = { - command: { transform: async () => {} }, - event: { subscribe: async () => {} }, - session: { prompt: async () => ({}) }, +let missingEventFailed = false +try { + await plugin.setup({ session: { prompt: async () => ({}) } }) +} catch (error) { + missingEventFailed = /event subscription is unavailable/.test(String(error)) } -const futureStatus = openCode2LoopRuntimeStatus(futureContext, currentDraft) -assert.deepEqual(futureStatus.hostBlockers, []) -assert.deepEqual(futureStatus.blockers, ["runtime.adapter"]) -assert.equal(futureStatus.hostReady, true) -assert.equal(futureStatus.implementationReady, false) -assert.equal(futureStatus.ready, false) +assert.equal(missingEventFailed, true) -let missingCapabilityFailed = false +let missingPromptFailed = false try { - await plugin.setup({}) + await plugin.setup({ event: { subscribe: async () => ({ stream: emptyEvents() }) } }) } catch (error) { - missingCapabilityFailed = /command\.transform capability is unavailable/.test(String(error)) + missingPromptFailed = /session prompt is unavailable/.test(String(error)) } -assert.equal(missingCapabilityFailed, true) +assert.equal(missingPromptFailed, true) console.log("OpenCode 2 plugin sentinel contract passed") diff --git a/src/source/opencode2/experimental.js b/src/source/opencode2/experimental.js index cf9c31d..2082981 100644 --- a/src/source/opencode2/experimental.js +++ b/src/source/opencode2/experimental.js @@ -1,17 +1,22 @@ -import { define } from "@opencode-ai/plugin/v2/promise" import { inspectOpenCode2Context } from "./capabilities.js" +import { createOpenCode2EventBridge } from "./event-bridge.js" export const OPENCODE_LOOP_V2_PLUGIN_ID = "bybrawe.opencode-loop.v2.experimental" -export const OpenCodeLoopV2ExperimentalPlugin = define({ +export async function setupOpenCodeLoopV2(ctx) { + const capabilities = inspectOpenCode2Context(ctx) + if (!capabilities.eventSubscribe) throw new Error("OpenCode 2 event subscription is unavailable") + if (!capabilities.sessionPrompt) throw new Error("OpenCode 2 session prompt is unavailable") + + if (capabilities.commandTransform) await ctx.command.transform(() => {}) + + const bridge = createOpenCode2EventBridge({ directory: ctx?.options?.directory }) + await bridge.attach(ctx.event.subscribe.bind(ctx.event)) +} + +export const OpenCodeLoopV2ExperimentalPlugin = Object.freeze({ id: OPENCODE_LOOP_V2_PLUGIN_ID, - async setup(ctx) { - const capabilities = inspectOpenCode2Context(ctx) - if (!capabilities.commandTransform) { - throw new Error("OpenCode 2 command.transform capability is unavailable") - } - await ctx.command.transform(() => {}) - }, + setup: setupOpenCodeLoopV2, }) export default OpenCodeLoopV2ExperimentalPlugin