diff --git a/scripts/v2-runtime-adapter-test.mjs b/scripts/v2-runtime-adapter-test.mjs index 4fb5e2f..633ba3f 100644 --- a/scripts/v2-runtime-adapter-test.mjs +++ b/scripts/v2-runtime-adapter-test.mjs @@ -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 = [] @@ -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 = [] @@ -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 diff --git a/src/source/opencode2/runtime-adapter.js b/src/source/opencode2/runtime-adapter.js index c22c5d5..1e1b804 100644 --- a/src/source/opencode2/runtime-adapter.js +++ b/src/source/opencode2/runtime-adapter.js @@ -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 { @@ -13,11 +14,20 @@ 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, }) @@ -25,6 +35,7 @@ export function createOpenCode2RuntimeAdapter(ctx, options = {}) { 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,