From 43e282e49959fe9753ae3dde69e4bc274441516f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:15:04 +0300 Subject: [PATCH 1/8] fix: match OpenCode V2 prompt contract --- src/source/opencode2/runtime-adapter.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/source/opencode2/runtime-adapter.js b/src/source/opencode2/runtime-adapter.js index c22c5d5..7d31a76 100644 --- a/src/source/opencode2/runtime-adapter.js +++ b/src/source/opencode2/runtime-adapter.js @@ -4,7 +4,9 @@ import { createOpenCode2HostContract } from "./host-contract.js" function promptRequest(request) { return { sessionID: request.sessionID, - text: request.text, + prompt: { text: request.text }, + delivery: request.delivery || "queue", + resume: request.resume !== false, } } From 8cf86451ce143d95690209e926cc173a02d7cbff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:15:22 +0300 Subject: [PATCH 2/8] fix: preserve OpenCode V2 prompt delivery options --- src/source/opencode2/host-contract.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/source/opencode2/host-contract.js b/src/source/opencode2/host-contract.js index 3173e3f..11d7e49 100644 --- a/src/source/opencode2/host-contract.js +++ b/src/source/opencode2/host-contract.js @@ -7,9 +7,12 @@ function normalizePrompt(input) { const text = String(input?.text || "") if (!sessionID) throw new TypeError("prompt requires a session ID") if (!text.trim()) throw new TypeError("prompt requires text") + const delivery = input?.delivery === "steer" ? "steer" : "queue" return Object.freeze({ sessionID, text, + delivery, + resume: input?.resume !== false, agent: input?.agent, model: input?.model, noReply: input?.noReply === true, From ad138a34f5d73f719587219d91321db9a32b2af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:15:47 +0300 Subject: [PATCH 3/8] feat: normalize OpenCode V2 session boundaries --- src/source/runtime/events.js | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/source/runtime/events.js b/src/source/runtime/events.js index d177aca..a719ecf 100644 --- a/src/source/runtime/events.js +++ b/src/source/runtime/events.js @@ -50,6 +50,58 @@ export function normalizeOpenCodeEvent(input) { }) } + if ( + event.type === "session.next.step.started" || + event.type === "session.next.step.ended" || + event.type === "session.next.step.failed" + ) { + const sessionID = text(properties.sessionID) + const messageID = text(properties.assistantMessageID) + if (!sessionID || !messageID) return undefined + if (event.type === "session.next.step.started") { + return freezeEvent({ + kind: "session", + action: "step-started", + sessionID, + directory, + messageID, + agent: text(properties.agent), + model: record(properties.model), + }) + } + if (event.type === "session.next.step.ended") { + return freezeEvent({ + kind: "session", + action: "step-ended", + sessionID, + directory, + messageID, + finish: text(properties.finish), + }) + } + return freezeEvent({ + kind: "session", + action: "step-failed", + sessionID, + directory, + messageID, + error: record(properties.error) || properties.error, + }) + } + + if (event.type === "session.next.compaction.ended") { + const sessionID = text(properties.sessionID) + if (!sessionID) return undefined + return freezeEvent({ + kind: "session", + action: "compacted", + sessionID, + directory, + messageID: text(properties.messageID), + reason: text(properties.reason), + }) + } + if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { const info = record(properties.info) const sessionID = text(info?.id) From 8e381b84edd9d991c541ac61d49f0e092736cd93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:16:15 +0300 Subject: [PATCH 4/8] test: cover OpenCode V2 prompt payload --- scripts/v2-runtime-adapter-test.mjs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/v2-runtime-adapter-test.mjs b/scripts/v2-runtime-adapter-test.mjs index 4fb5e2f..780ba42 100644 --- a/scripts/v2-runtime-adapter-test.mjs +++ b/scripts/v2-runtime-adapter-test.mjs @@ -71,7 +71,20 @@ async function settle() { const result = await adapter.prompt({ sessionID: "ses_v2", text: "continue" }) assert.deepEqual(result, { accepted: true }) - assert.deepEqual(prompts, [{ sessionID: "ses_v2", text: "continue" }]) + assert.deepEqual(prompts, [{ + sessionID: "ses_v2", + prompt: { text: "continue" }, + delivery: "queue", + resume: true, + }]) + + await adapter.prompt({ sessionID: "ses_v2", text: "steer now", delivery: "steer", resume: false }) + assert.deepEqual(prompts.at(-1), { + sessionID: "ses_v2", + prompt: { text: "steer now" }, + delivery: "steer", + resume: false, + }) assert.equal(await adapter.dispose("test-complete"), true) assert.equal(await adapter.dispose("test-complete"), false) From ba40fc496a20a51f086716a155db6cd965ade4a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:16:35 +0300 Subject: [PATCH 5/8] test: cover OpenCode V2 session events --- scripts/runtime-event-test.mjs | 79 ++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/scripts/runtime-event-test.mjs b/scripts/runtime-event-test.mjs index c9c054c..342ff0e 100644 --- a/scripts/runtime-event-test.mjs +++ b/scripts/runtime-event-test.mjs @@ -14,6 +14,85 @@ assert.deepEqual( { kind: "session", action: "status", sessionID: "ses_busy", directory: "/workspace", status: "busy" }, ) +assert.deepEqual( + normalizeOpenCodeEvent({ + directory: "/workspace", + payload: { + type: "session.next.step.started", + properties: { + sessionID: "ses_v2", + assistantMessageID: "msg_v2", + agent: "build", + model: { providerID: "canary", modelID: "canary" }, + }, + }, + }), + { + kind: "session", + action: "step-started", + sessionID: "ses_v2", + directory: "/workspace", + messageID: "msg_v2", + agent: "build", + model: { providerID: "canary", modelID: "canary" }, + }, +) + +assert.deepEqual( + normalizeOpenCodeEvent({ + directory: "/workspace", + payload: { + type: "session.next.step.ended", + properties: { sessionID: "ses_v2", assistantMessageID: "msg_v2", finish: "stop" }, + }, + }), + { + kind: "session", + action: "step-ended", + sessionID: "ses_v2", + directory: "/workspace", + messageID: "msg_v2", + finish: "stop", + }, +) + +const v2Failure = { name: "UnknownError", data: { message: "provider failed" } } +assert.deepEqual( + normalizeOpenCodeEvent({ + directory: "/workspace", + payload: { + type: "session.next.step.failed", + properties: { sessionID: "ses_v2", assistantMessageID: "msg_v2", error: v2Failure }, + }, + }), + { + kind: "session", + action: "step-failed", + sessionID: "ses_v2", + directory: "/workspace", + messageID: "msg_v2", + error: v2Failure, + }, +) + +assert.deepEqual( + normalizeOpenCodeEvent({ + directory: "/workspace", + payload: { + type: "session.next.compaction.ended", + properties: { sessionID: "ses_v2", messageID: "msg_compact", reason: "manual" }, + }, + }), + { + kind: "session", + action: "compacted", + sessionID: "ses_v2", + directory: "/workspace", + messageID: "msg_compact", + reason: "manual", + }, +) + assert.deepEqual( normalizeOpenCodeEvent({ type: "session.created", From f671cf84c4ff9c1abf3608ceda3baf881dec00db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:20:39 +0300 Subject: [PATCH 6/8] test: add real V2 session probe --- .../fixtures/opencode2-real-session-probe.js | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 scripts/fixtures/opencode2-real-session-probe.js diff --git a/scripts/fixtures/opencode2-real-session-probe.js b/scripts/fixtures/opencode2-real-session-probe.js new file mode 100644 index 0000000..6ca2f3c --- /dev/null +++ b/scripts/fixtures/opencode2-real-session-probe.js @@ -0,0 +1,96 @@ +import { writeFile } from "node:fs/promises" + +function eventStream(registration) { + const stream = registration?.stream ?? registration + if (!stream || typeof stream[Symbol.asyncIterator] !== "function") { + throw new TypeError("V2 event subscription did not return an async stream") + } + return stream +} + +function rawType(value) { + return value?.payload?.type || value?.event?.type || value?.type +} + +function rawSessionID(value) { + const event = value?.payload || value?.event || value + return event?.properties?.sessionID || event?.properties?.info?.id +} + +export default { + id: "bybrawe.opencode-loop.v2.real-session-probe", + async setup(ctx) { + const runtimeURL = process.env.OPENCODE_LOOP_V2_RUNTIME_URL + const marker = process.env.OPENCODE_LOOP_V2_SESSION_MARKER + if (!runtimeURL) throw new Error("OPENCODE_LOOP_V2_RUNTIME_URL is required") + if (!marker) throw new Error("OPENCODE_LOOP_V2_SESSION_MARKER is required") + + const { createOpenCode2RuntimeAdapter } = await import(runtimeURL) + const state = { + ready: false, + promptStarted: false, + promptCompleted: false, + rawTypes: [], + normalized: [], + } + let markerWrite = Promise.resolve() + const persist = () => { + markerWrite = markerWrite.then(() => writeFile(marker, JSON.stringify(state, null, 2), "utf8")) + return markerWrite + } + + const adapter = createOpenCode2RuntimeAdapter(ctx, { + onEvent: async (event) => { + state.normalized.push(`${event.kind}:${event.action}`) + if (state.normalized.length > 80) state.normalized.shift() + await persist() + }, + onError: (error) => { + state.error = String(error?.stack || error) + void persist() + }, + }) + await adapter.start() + + const registration = await ctx.event.subscribe() + const iterator = eventStream(registration)[Symbol.asyncIterator]() + let stopped = false + const pump = (async () => { + while (!stopped) { + const next = await iterator.next() + if (next?.done) break + const type = rawType(next.value) + if (type) { + state.rawTypes.push(type) + if (state.rawTypes.length > 120) state.rawTypes.shift() + } + const sessionID = rawSessionID(next.value) + if (!state.promptStarted && type === "session.created" && sessionID) { + state.promptStarted = true + await persist() + try { + await adapter.prompt({ sessionID, text: "V2_REAL_SESSION_CANARY", delivery: "queue", resume: true }) + state.promptCompleted = true + } catch (error) { + state.promptError = String(error?.stack || error) + } + } + await persist() + } + })().catch((error) => { + state.streamError = String(error?.stack || error) + void persist() + }) + + state.ready = true + await persist() + + return async () => { + stopped = true + await iterator.return?.().catch?.(() => undefined) + await adapter.dispose("real-session-probe-cleanup") + await pump.catch(() => undefined) + await markerWrite.catch(() => undefined) + } + }, +} From 3b5695b0a4001eeffa1f8e234b34be77147e42fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:21:51 +0300 Subject: [PATCH 7/8] test: add real OpenCode V2 session canary --- scripts/opencode2-real-session-canary.mjs | 273 ++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 scripts/opencode2-real-session-canary.mjs diff --git a/scripts/opencode2-real-session-canary.mjs b/scripts/opencode2-real-session-canary.mjs new file mode 100644 index 0000000..7a682ea --- /dev/null +++ b/scripts/opencode2-real-session-canary.mjs @@ -0,0 +1,273 @@ +import assert from "node:assert/strict" +import { createServer } from "node:http" +import net from "node:net" +import { spawn } from "node:child_process" +import { mkdir, mkdtemp, readFile, rm, writeFile, copyFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const opencode2Bin = process.env.OPENCODE2_BIN || "opencode2" + +function appendLog(current, chunk, limit = 120_000) { + return (current + String(chunk)).slice(-limit) +} + +async function reservePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer() + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (!address || typeof address === "string") return reject(new Error("failed to reserve port")) + server.close((error) => error ? reject(error) : resolve(address.port)) + }) + }) +} + +async function waitForTcp(port, child, logs, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`OpenCode 2 exited before ready\n${logs()}`) + const ready = await new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }) + socket.once("connect", () => { socket.destroy(); resolve(true) }) + socket.once("error", () => resolve(false)) + socket.setTimeout(500, () => { socket.destroy(); resolve(false) }) + }) + if (ready) return + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`timed out waiting for OpenCode 2 server\n${logs()}`) +} + +async function stopProcess(child, timeoutMs = 2_000) { + if (!child || child.exitCode !== null) return + child.kill() + await new Promise((resolve) => { + if (child.exitCode !== null) return resolve() + const timer = setTimeout(resolve, timeoutMs) + child.once("close", () => { clearTimeout(timer); resolve() }) + }) +} + +function contentText(content) { + if (typeof content === "string") return content + if (!Array.isArray(content)) return "" + return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n") +} + +function messageText(body) { + return (body.messages || []).map((message) => contentText(message?.content)).join("\n") +} + +function streamHeaders(res) { + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }) +} + +function writeSse(res, value) { + res.write(`data: ${JSON.stringify(value)}\n\n`) +} + +function startProvider() { + const stats = { requests: 0, canaryPrompts: 0, paths: [] } + const server = createServer(async (req, res) => { + const url = new URL(req.url || "/", "http://127.0.0.1") + stats.paths.push(`${req.method} ${url.pathname}`) + if (req.method === "GET" && url.pathname.endsWith("/models")) { + res.writeHead(200, { "content-type": "application/json" }) + res.end(JSON.stringify({ object: "list", data: [{ id: "canary", object: "model", owned_by: "canary" }] })) + return + } + if (req.method !== "POST" || !url.pathname.endsWith("/chat/completions")) { + res.writeHead(404, { "content-type": "application/json" }) + res.end(JSON.stringify({ error: { message: `unexpected endpoint: ${req.method} ${url.pathname}` } })) + return + } + + let raw = "" + for await (const chunk of req) raw += String(chunk) + const body = raw ? JSON.parse(raw) : {} + stats.requests += 1 + if (messageText(body).includes("V2_REAL_SESSION_CANARY")) stats.canaryPrompts += 1 + const content = "V2_CANARY_OK" + const id = `chatcmpl-v2-${stats.requests}` + const created = Math.floor(Date.now() / 1000) + + if (body.stream === false) { + res.writeHead(200, { "content-type": "application/json" }) + res.end(JSON.stringify({ + id, + object: "chat.completion", + created, + model: "canary", + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage: { prompt_tokens: 20, completion_tokens: 4, total_tokens: 24 }, + })) + return + } + + streamHeaders(res) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }], + }) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 20, completion_tokens: 4, total_tokens: 24 }, + }) + res.end("data: [DONE]\n\n") + }) + + return { + stats, + async listen() { + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", resolve) + }) + const address = server.address() + if (!address || typeof address === "string") throw new Error("failed to start provider") + return address.port + }, + async close() { + await new Promise((resolve) => server.close(() => resolve())) + }, + } +} + +async function waitFor(predicate, description, diagnostics, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await predicate()) return + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`timed out waiting for ${description}\n${await diagnostics()}`) +} + +async function main() { + const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-session-")) + const home = path.join(workspace, ".home") + const pluginDir = path.join(workspace, ".opencode", "plugins") + const marker = path.join(workspace, "v2-session-marker.json") + const provider = startProvider() + const providerPort = await provider.listen() + const port = await reservePort() + let server + let serverLog = "" + + await mkdir(pluginDir, { recursive: true }) + await mkdir(home, { recursive: true }) + await copyFile( + path.join(repoRoot, "scripts", "fixtures", "opencode2-real-session-probe.js"), + path.join(pluginDir, "opencode-loop-v2-session-probe.js"), + ) + await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "canary/canary", + small_model: "canary/canary", + provider: { + canary: { + npm: "@ai-sdk/openai-compatible", + name: "Deterministic V2 Canary", + options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" }, + models: { canary: { name: "Deterministic V2 Canary", limit: { context: 100000, output: 4096 } } }, + }, + }, + }, null, 2)}\n`) + + const env = { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: path.join(home, ".config"), + XDG_DATA_HOME: path.join(home, ".local", "share"), + XDG_STATE_HOME: path.join(home, ".local", "state"), + XDG_CACHE_HOME: path.join(home, ".cache"), + OPENCODE_SERVER_PASSWORD: "probe", + OPENCODE_LOOP_V2_RUNTIME_URL: pathToFileURL(path.join(repoRoot, "src", "source", "opencode2", "runtime-adapter.js")).href, + OPENCODE_LOOP_V2_SESSION_MARKER: marker, + CI: "true", + } + + const diagnostics = async () => { + let markerText = "missing" + try { markerText = await readFile(marker, "utf8") } catch {} + return `provider=${JSON.stringify(provider.stats)}\nmarker=${markerText}\nserver log:\n${serverLog}` + } + + try { + server = spawn(opencode2Bin, ["serve", "--hostname", "127.0.0.1", "--port", String(port)], { + cwd: workspace, + env, + windowsHide: true, + }) + server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) }) + server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) }) + await waitForTcp(port, server, () => serverLog) + + const baseURL = `http://127.0.0.1:${port}` + const auth = `Basic ${Buffer.from("opencode:probe").toString("base64")}` + const api = async (pathname, init = {}) => { + const response = await fetch(`${baseURL}${pathname}`, { + ...init, + headers: { + authorization: auth, + "content-type": "application/json", + "x-opencode-directory": workspace, + ...(init.headers || {}), + }, + signal: init.signal || AbortSignal.timeout(30_000), + }) + const text = await response.text() + if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`) + return text ? JSON.parse(text) : null + } + + await api("/api/command") + await waitFor(async () => { + try { return JSON.parse(await readFile(marker, "utf8")).ready === true } catch { return false } + }, "V2 probe plugin readiness", diagnostics) + + const created = await api("/api/session", { + method: "POST", + body: JSON.stringify({ location: { directory: workspace } }), + }) + const session = created?.data ?? created + assert.ok(session?.id, `V2 session creation failed: ${JSON.stringify(created)}`) + + await waitFor(() => provider.stats.canaryPrompts >= 1, "runtime-adapter session.prompt dispatch", diagnostics) + await waitFor(async () => { + try { + const value = JSON.parse(await readFile(marker, "utf8")) + return value.rawTypes.includes("session.next.step.ended") && value.normalized.includes("session:step-ended") + } catch { return false } + }, "V2 step-ended event normalization", diagnostics) + + const finalMarker = JSON.parse(await readFile(marker, "utf8")) + assert.equal(finalMarker.promptStarted, true) + assert.equal(finalMarker.promptCompleted, true) + assert.ok(finalMarker.rawTypes.includes("session.created")) + assert.ok(finalMarker.rawTypes.includes("session.next.step.ended")) + assert.ok(finalMarker.normalized.includes("session:step-ended")) + console.log("OpenCode 2 real session runtime canary passed") + } finally { + await stopProcess(server) + await provider.close() + await rm(workspace, { recursive: true, force: true }) + } +} + +await main() From 7ca1ee4111e2acba3735884be4a622564215a331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:22:32 +0300 Subject: [PATCH 8/8] test: run real OpenCode V2 session canary --- .github/workflows/opencode2-real-adapter.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/opencode2-real-adapter.yml b/.github/workflows/opencode2-real-adapter.yml index e581ca3..d1ad444 100644 --- a/.github/workflows/opencode2-real-adapter.yml +++ b/.github/workflows/opencode2-real-adapter.yml @@ -4,13 +4,19 @@ on: pull_request: paths: - "src/source/opencode2/**" + - "src/source/runtime/events.js" - "scripts/fixtures/opencode2-real-adapter-probe.js" + - "scripts/fixtures/opencode2-real-session-probe.js" + - "scripts/opencode2-real-session-canary.mjs" - ".github/workflows/opencode2-real-adapter.yml" push: branches: [main] paths: - "src/source/opencode2/**" + - "src/source/runtime/events.js" - "scripts/fixtures/opencode2-real-adapter-probe.js" + - "scripts/fixtures/opencode2-real-session-probe.js" + - "scripts/opencode2-real-session-canary.mjs" - ".github/workflows/opencode2-real-adapter.yml" permissions: @@ -19,7 +25,7 @@ permissions: jobs: adapter: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v6 with: @@ -66,10 +72,13 @@ jobs: if [ -f "$OPENCODE_LOOP_V2_MARKER" ]; then cat "$OPENCODE_LOOP_V2_MARKER" 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 || value.commandTransform!==true || value.eventSubscribe!==true || value.sessionPrompt!==true) process.exit(1)' "$OPENCODE_LOOP_V2_MARKER" - exit 0 + break fi sleep 0.2 done - cat "$RUNNER_TEMP/opencode2-real-command.json" >&2 || true - cat "$RUNNER_TEMP/opencode2-real-plugin.json" >&2 || true - exit 1 + test -f "$OPENCODE_LOOP_V2_MARKER" + - name: Verify real V2 session prompt and event stream + run: | + node --check scripts/fixtures/opencode2-real-session-probe.js + node --check scripts/opencode2-real-session-canary.mjs + node scripts/opencode2-real-session-canary.mjs