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
84 changes: 84 additions & 0 deletions scripts/v2-runtime-adapter-test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import assert from "node:assert/strict"
import { promises as fs } from "node:fs"
import os from "node:os"
import path from "node:path"

import OpenCodeLoopV2ExperimentalPlugin from "../src/source/opencode2/experimental.js"
import { createOpenCode2RuntimeAdapter } from "../src/source/opencode2/runtime-adapter.js"
import { readState } from "../src/source/core/state.js"

function controllableStream() {
const queued = []
Expand Down Expand Up @@ -38,6 +42,19 @@ async function settle() {
await new Promise((resolve) => setTimeout(resolve, 0))
}

async function waitFor(read, accept, timeoutMs = 1_500) {
const deadline = Date.now() + timeoutMs
let value
while (Date.now() < deadline) {
value = await read()
if (accept(value)) return value
await new Promise((resolve) => setTimeout(resolve, 10))
}
value = await read()
assert.ok(accept(value), "timed out waiting for V2 runtime state")
return value
}

{
const events = controllableStream()
const prompts = []
Expand Down Expand Up @@ -78,6 +95,73 @@ async function settle() {
assert.equal(events.returns(), 1, "disposing the V2 adapter must close the event iterator")
}

{
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-wire-"))
const sessionID = "ses_v2_wire"
const events = controllableStream()
const prompts = []
const ctx = {
event: { subscribe: () => events.stream },
session: {
prompt: async (input) => {
prompts.push(input)
return { accepted: true }
},
},
}
const adapter = createOpenCode2RuntimeAdapter(ctx)
try {
await adapter.start()
events.push({
directory,
payload: {
type: "command.executed",
properties: {
sessionID,
name: "loop",
arguments: "0s --no-now --max-runs 1 continue the wired V2 task",
messageID: "msg_loop_wire",
},
},
})

const created = await waitFor(
() => readState(directory, sessionID),
(state) => state.jobs?.length === 1,
)
assert.equal(created.jobs[0].action, "continue the wired V2 task")
assert.equal(created.jobs[0].runCount, 0)

events.push({
directory,
payload: { type: "session.idle", properties: { sessionID } },
})
await waitFor(
async () => prompts.length,
(count) => count === 1,
)
assert.equal(prompts[0].sessionID, sessionID)
assert.match(prompts[0].text, /AUTONOMOUS OPENCODE LOOP ITERATION/)
assert.match(prompts[0].text, /continue the wired V2 task/)

const completed = await waitFor(
() => readState(directory, sessionID),
(state) => state.jobs?.[0]?.runCount === 1,
)
assert.equal(completed.jobs[0].enabled, false, "max-runs=1 must stop the V2 prompt loop after one dispatch")

events.push({
directory,
payload: { type: "session.idle", properties: { sessionID } },
})
await new Promise((resolve) => setTimeout(resolve, 40))
assert.equal(prompts.length, 1, "a completed V2 prompt loop must not dispatch again")
} finally {
await adapter.dispose("wired-test-complete")
await fs.rm(directory, { recursive: true, force: true })
}
}

{
const events = controllableStream()
let commandTransforms = 0
Expand Down
15 changes: 13 additions & 2 deletions src/source/opencode2/runtime-adapter.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { inspectOpenCode2Context } from "./capabilities.js"
import { createOpenCode2HostContract } from "./host-contract.js"
import { createOpenCode2PromptRuntime } from "./prompt-runtime.js"

function promptRequest(request) {
return {
Expand All @@ -13,18 +14,28 @@ export function createOpenCode2RuntimeAdapter(ctx, options = {}) {
if (!capabilities.eventSubscribe) throw new Error("OpenCode 2 event.subscribe capability is unavailable")
if (!capabilities.sessionPrompt) throw new Error("OpenCode 2 session.prompt capability is unavailable")

const host = createOpenCode2HostContract({
let host
const promptRuntime = createOpenCode2PromptRuntime({
prompt: (request) => host.prompt(request),
})
const externalOnEvent = typeof options.onEvent === "function" ? options.onEvent : undefined

host = createOpenCode2HostContract({
directory: options.directory,
subscribe: () => ctx.event.subscribe(),
sendPrompt: (request) => ctx.session.prompt(promptRequest(request)),
onEvent: options.onEvent,
onEvent: async (event, runtime) => {
await promptRuntime.onEvent(event)
if (externalOnEvent) await externalOnEvent(event, runtime)
},
onError: options.onError,
})

return Object.freeze({
start: () => host.start(),
prompt: (request) => host.prompt(request),
dispose: (reason = "runtime-adapter-disposed") => host.dispose(reason),
promptRuntime,
runtimeManager: host.runtimeManager,
isStarted: host.isStarted,
isDisposed: host.isDisposed,
Expand Down