diff --git a/.github/workflows/v2-lifecycle-contract.yml b/.github/workflows/v2-lifecycle-contract.yml index bc13910..132ecef 100644 --- a/.github/workflows/v2-lifecycle-contract.yml +++ b/.github/workflows/v2-lifecycle-contract.yml @@ -6,6 +6,7 @@ on: - "src/source/opencode2/**" - "src/source/runtime/**" - "scripts/v2-event-bridge-test.mjs" + - "scripts/v2-host-contract-test.mjs" - "scripts/v2-plugin-sentinel-test.mjs" - ".github/workflows/v2-lifecycle-contract.yml" push: @@ -14,6 +15,7 @@ on: - "src/source/opencode2/**" - "src/source/runtime/**" - "scripts/v2-event-bridge-test.mjs" + - "scripts/v2-host-contract-test.mjs" - "scripts/v2-plugin-sentinel-test.mjs" - ".github/workflows/v2-lifecycle-contract.yml" @@ -38,7 +40,10 @@ jobs: cache: npm - run: npm ci - run: node --check src/source/opencode2/event-bridge.js + - run: node --check src/source/opencode2/host-contract.js - run: node --check src/source/opencode2/experimental.js - run: node --check scripts/v2-event-bridge-test.mjs + - run: node --check scripts/v2-host-contract-test.mjs - run: node scripts/v2-event-bridge-test.mjs + - run: node scripts/v2-host-contract-test.mjs - run: node scripts/v2-plugin-sentinel-test.mjs diff --git a/scripts/v2-host-contract-test.mjs b/scripts/v2-host-contract-test.mjs new file mode 100644 index 0000000..f89fe0b --- /dev/null +++ b/scripts/v2-host-contract-test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict" +import { createOpenCode2HostContract } from "../src/source/opencode2/host-contract.js" + +let listener +let unsubscribeCalls = 0 +const prompts = [] +const events = [] +const host = createOpenCode2HostContract({ + directory: "/project", + subscribe: async (callback) => { + listener = callback + return { unsubscribe: async () => { unsubscribeCalls += 1 } } + }, + sendPrompt: async (request) => { + prompts.push(request) + return { accepted: true } + }, + onEvent: async (event, runtime) => events.push({ event, runtime }), +}) + +await assert.rejects(host.prompt({ sessionID: "ses_a", text: "early" }), /not started/) +assert.equal(await host.start(), true) +assert.equal(await host.start(), false) +assert.equal(typeof listener, "function") + +await listener({ + directory: "/project", + payload: { type: "session.created", properties: { info: { id: "ses_a", directory: "/project" } } }, +}) +const runtime = host.runtimeManager.peek("ses_a") +assert.ok(runtime) + +const result = await host.prompt({ sessionID: "ses_a", text: "continue" }) +assert.deepEqual(result, { accepted: true }) +assert.equal(prompts.length, 1) +assert.equal(prompts[0].sessionID, "ses_a") +assert.equal(prompts[0].text, "continue") +assert.equal(prompts[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 listener({ + directory: "/project", + payload: { type: "server.instance.disposed", properties: { directory: "/project" } }, +}) +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/) + +assert.equal(await host.dispose(), true) +assert.equal(await host.dispose(), false) +assert.equal(unsubscribeCalls, 1) +console.log("OpenCode 2 host contract passed") diff --git a/src/source/opencode2/host-contract.js b/src/source/opencode2/host-contract.js new file mode 100644 index 0000000..3173e3f --- /dev/null +++ b/src/source/opencode2/host-contract.js @@ -0,0 +1,68 @@ +import { createOpenCode2EventBridge } from "./event-bridge.js" + +export const OPENCODE_LOOP_V2_HOST_CONTRACT = "experimental" + +function normalizePrompt(input) { + const sessionID = String(input?.sessionID || "").trim() + const text = String(input?.text || "") + if (!sessionID) throw new TypeError("prompt requires a session ID") + if (!text.trim()) throw new TypeError("prompt requires text") + return Object.freeze({ + sessionID, + text, + agent: input?.agent, + model: input?.model, + noReply: input?.noReply === true, + }) +} + +export function createOpenCode2HostContract(options = {}) { + const onEvent = typeof options.onEvent === "function" ? options.onEvent : async () => {} + let started = false + let disposed = false + let hostDisposed = false + + const bridge = createOpenCode2EventBridge({ + directory: options.directory, + onError: options.onError, + onEvent: async (event, runtime) => { + if (event.kind === "server" && event.action === "disposed") hostDisposed = true + await onEvent(event, runtime) + }, + }) + + async function start() { + if (disposed) throw new Error("OpenCode 2 host contract is disposed") + if (started) return false + if (typeof options.subscribe !== "function") throw new TypeError("subscribe must be a function") + if (typeof options.sendPrompt !== "function") throw new TypeError("sendPrompt must be a function") + await bridge.attach(options.subscribe) + started = true + return true + } + + async function prompt(input) { + if (disposed || hostDisposed) throw new Error("OpenCode 2 host contract is unavailable") + if (!started) throw new Error("OpenCode 2 host contract is not started") + const request = normalizePrompt(input) + const runtime = bridge.runtimeManager.observeExternal(request.sessionID) + return await options.sendPrompt(Object.freeze({ ...request, runtime })) + } + + async function dispose(reason = "host-contract-disposed") { + if (disposed) return false + disposed = true + await bridge.dispose(reason) + return true + } + + return Object.freeze({ + start, + prompt, + dispose, + runtimeManager: bridge.runtimeManager, + isStarted: () => started, + isDisposed: () => disposed, + isHostDisposed: () => hostDisposed, + }) +}