Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/v2-lifecycle-contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"

Expand All @@ -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
55 changes: 55 additions & 0 deletions scripts/v2-host-contract-test.mjs
Original file line number Diff line number Diff line change
@@ -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")
68 changes: 68 additions & 0 deletions src/source/opencode2/host-contract.js
Original file line number Diff line number Diff line change
@@ -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,
})
}