From bfff123e42c3e681b6f02a437931cc31b0d0e3ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:01:58 +0300 Subject: [PATCH 1/8] feat: add V2 loop status handler --- src/source/opencode2/status.js | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/source/opencode2/status.js diff --git a/src/source/opencode2/status.js b/src/source/opencode2/status.js new file mode 100644 index 0000000..ed30eec --- /dev/null +++ b/src/source/opencode2/status.js @@ -0,0 +1,41 @@ +import { durationToText, now } from "../core/args.js" +import { goalStatusText, isGoalJob, jobLabel } from "../core/jobs.js" +import { readState } from "../core/state.js" + +export function formatOpenCode2LoopStatus(state) { + const jobs = Array.isArray(state?.jobs) ? state.jobs : [] + const lines = jobs.length + ? jobs.map((job, index) => { + const dueIn = Math.max(0, Number(job.intervalMs || 0) - (now() - Number(job.lastRunAt || 0))) + const flags = [ + isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, + job.paused ? "paused" : "active", + job.safe ? "safe" : undefined, + job.askNever ? "ask-never" : undefined, + job.noOverlap ? "no-overlap" : undefined, + job.checkpointOnly ? "checkpoint-only" : undefined, + job.gitCheckpoint ? "git-checkpoint" : undefined, + ].filter(Boolean).join(",") + return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}` + }) + : ["No active loop jobs."] + + return Object.freeze({ + jobs, + text: `OpenCode loop status:\n${lines.join("\n")}`, + }) +} + +export async function handleOpenCode2LoopStatus({ directory, sessionID, prompt }) { + if (!directory) throw new TypeError("V2 loop status requires a project directory") + if (!sessionID) throw new TypeError("V2 loop status requires a session ID") + if (typeof prompt !== "function") throw new TypeError("V2 loop status requires a prompt function") + + const status = formatOpenCode2LoopStatus(await readState(directory, sessionID)) + await prompt({ + sessionID, + text: status.text, + noReply: true, + }) + return status +} From ada3287044fcd898b3d2d0835976aac134fb0332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:02:18 +0300 Subject: [PATCH 2/8] feat: handle V2 loop status lifecycle --- src/source/opencode2/experimental.js | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/source/opencode2/experimental.js b/src/source/opencode2/experimental.js index a559d31..5d20296 100644 --- a/src/source/opencode2/experimental.js +++ b/src/source/opencode2/experimental.js @@ -1,5 +1,6 @@ import { inspectOpenCode2Context } from "./capabilities.js" import { createOpenCode2HostContract } from "./host-contract.js" +import { handleOpenCode2LoopStatus } from "./status.js" export const OPENCODE_LOOP_V2_PLUGIN_ID = "bybrawe.opencode-loop.v2.experimental" @@ -15,11 +16,28 @@ export const OpenCodeLoopV2ExperimentalPlugin = { if (!capabilities.eventSubscribe || !capabilities.sessionPrompt) return undefined const subscribe = () => ctx.event.subscribe() - const sendPrompt = (request) => ctx.session.prompt(request) - const options = {} - options.subscribe = subscribe - options.sendPrompt = sendPrompt - const host = createOpenCode2HostContract(options) + const sendPrompt = (request) => ctx.session.prompt({ + sessionID: request.sessionID, + noReply: request.noReply, + ...(request.agent === undefined ? {} : { agent: request.agent }), + ...(request.model === undefined ? {} : { model: request.model }), + parts: [{ type: "text", text: request.text }], + }) + + let host + const options = { + subscribe, + sendPrompt, + onEvent: async (event) => { + if (event.kind !== "command" || event.action !== "executed" || event.name !== "loop-status") return + await handleOpenCode2LoopStatus({ + directory: event.directory, + sessionID: event.sessionID, + prompt: host.prompt, + }) + }, + } + host = createOpenCode2HostContract(options) const startHost = host.start.bind(host) const disposeHost = host.dispose.bind(host) await startHost() From 5a52076f93b02119f61c81895b133f2cd19c0e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:02:45 +0300 Subject: [PATCH 3/8] test: cover V2 loop status lifecycle --- scripts/v2-loop-status-test.mjs | 137 ++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 scripts/v2-loop-status-test.mjs diff --git a/scripts/v2-loop-status-test.mjs b/scripts/v2-loop-status-test.mjs new file mode 100644 index 0000000..4e14cc1 --- /dev/null +++ b/scripts/v2-loop-status-test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +import plugin from "../src/source/opencode2/experimental.js" +import { writeState } from "../src/source/core/state.js" + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +function createEventStream() { + const queued = [] + const waiters = [] + let closed = false + + const iterator = { + next() { + if (queued.length) return Promise.resolve({ value: queued.shift(), done: false }) + if (closed) return Promise.resolve({ done: true }) + return new Promise((resolve) => waiters.push(resolve)) + }, + async return() { + closed = true + while (waiters.length) waiters.shift()({ done: true }) + return { done: true } + }, + } + + return { + stream: { [Symbol.asyncIterator]: () => iterator }, + push(value) { + const resolve = waiters.shift() + if (resolve) resolve({ value, done: false }) + else queued.push(value) + }, + } +} + +async function waitFor(predicate, timeoutMs = 1_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return true + await delay(10) + } + return predicate() +} + +const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-status-")) +const sessionID = "ses_v2_status" +const events = createEventStream() +let transforms = 0 +const prompts = [] + +const ctx = { + command: { + transform: async () => { + transforms += 1 + return { dispose: async () => {} } + }, + }, + event: { + subscribe: async () => ({ stream: events.stream }), + }, + session: { + prompt: async (request) => { + prompts.push(structuredClone(request)) + return { accepted: true } + }, + }, +} + +let dispose +try { + dispose = await plugin.setup(ctx) + assert.equal(transforms, 1) + assert.equal(typeof dispose, "function") + + events.push({ + directory, + payload: { + type: "command.executed", + properties: { name: "loop-status", sessionID, arguments: "", messageID: "msg_status_empty" }, + }, + }) + + assert.equal(await waitFor(() => prompts.length === 1), true) + assert.equal(prompts[0].sessionID, sessionID) + assert.equal(prompts[0].noReply, true) + assert.deepEqual(prompts[0].parts, [{ type: "text", text: "OpenCode loop status:\nNo active loop jobs." }]) + + await writeState(directory, sessionID, { + version: 4, + jobs: [{ + id: "job_status", + name: "status-test", + action: "continue safely", + kind: "prompt", + intervalMs: 60_000, + lastRunAt: Date.now(), + runCount: 2, + failureCount: 1, + noOverlap: true, + }], + }) + + events.push({ + directory, + payload: { + type: "command.executed", + properties: { name: "loop-status", sessionID, arguments: "", messageID: "msg_status_job" }, + }, + }) + + assert.equal(await waitFor(() => prompts.length === 2), true) + const statusText = prompts[1].parts[0].text + assert.match(statusText, /^OpenCode loop status:/) + assert.match(statusText, /job_status \(status-test\)/) + assert.match(statusText, /continue safely/) + assert.match(statusText, /runs=2/) + assert.match(statusText, /failures=1/) + assert.match(statusText, /active,no-overlap/) + + events.push({ + directory, + payload: { + type: "command.executed", + properties: { name: "loop-help", sessionID, arguments: "", messageID: "msg_help" }, + }, + }) + await delay(25) + assert.equal(prompts.length, 2, "the V2 status slice must ignore commands it does not own yet") + + console.log("OpenCode 2 loop status contract passed") +} finally { + await dispose?.() + await rm(directory, { recursive: true, force: true }) +} From 28faffd93966cce44f527983326cb3527fa26e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:03:11 +0300 Subject: [PATCH 4/8] ci: gate V2 loop status lifecycle --- .github/workflows/v2-lifecycle-contract.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/v2-lifecycle-contract.yml b/.github/workflows/v2-lifecycle-contract.yml index 448111f..3c61b82 100644 --- a/.github/workflows/v2-lifecycle-contract.yml +++ b/.github/workflows/v2-lifecycle-contract.yml @@ -9,6 +9,7 @@ on: - "scripts/v2-event-stream-contract-test.mjs" - "scripts/v2-host-contract-test.mjs" - "scripts/v2-plugin-sentinel-test.mjs" + - "scripts/v2-loop-status-test.mjs" - ".github/workflows/v2-lifecycle-contract.yml" push: branches: [main] @@ -19,6 +20,7 @@ on: - "scripts/v2-event-stream-contract-test.mjs" - "scripts/v2-host-contract-test.mjs" - "scripts/v2-plugin-sentinel-test.mjs" + - "scripts/v2-loop-status-test.mjs" - ".github/workflows/v2-lifecycle-contract.yml" permissions: @@ -43,11 +45,14 @@ jobs: - 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/status.js - run: node --check src/source/opencode2/experimental.js - run: node --check scripts/v2-event-bridge-test.mjs - run: node --check scripts/v2-event-stream-contract-test.mjs - run: node --check scripts/v2-host-contract-test.mjs + - run: node --check scripts/v2-loop-status-test.mjs - run: node scripts/v2-event-bridge-test.mjs - run: node scripts/v2-event-stream-contract-test.mjs - run: node scripts/v2-host-contract-test.mjs - run: node scripts/v2-plugin-sentinel-test.mjs + - run: node scripts/v2-loop-status-test.mjs From a4830eb36df489884b09cc636c576d4dcfd0b061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:09:59 +0300 Subject: [PATCH 5/8] test: add real V2 loop status canary --- scripts/opencode2-loop-status-canary.mjs | 270 +++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 scripts/opencode2-loop-status-canary.mjs diff --git a/scripts/opencode2-loop-status-canary.mjs b/scripts/opencode2-loop-status-canary.mjs new file mode 100644 index 0000000..859ccf2 --- /dev/null +++ b/scripts/opencode2-loop-status-canary.mjs @@ -0,0 +1,270 @@ +import assert from "node:assert/strict" +import { createServer } from "node:http" +import { spawn } from "node:child_process" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import process from "node:process" +import { fileURLToPath, pathToFileURL } from "node:url" + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const opencode2 = process.platform === "win32" ? "opencode2.cmd" : "opencode2" + +function append(current, chunk, limit = 80_000) { + return (current + String(chunk)).slice(-limit) +} + +async function run(command, args, { cwd, env, timeoutMs = 90_000, allowFailure = false } = {}) { + return await new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, env, windowsHide: true }) + let stdout = "" + let stderr = "" + let settled = false + const finish = (fn, value) => { + if (settled) return + settled = true + clearTimeout(timer) + fn(value) + } + child.stdout?.on("data", (chunk) => { stdout = append(stdout, chunk) }) + child.stderr?.on("data", (chunk) => { stderr = append(stderr, chunk) }) + const timer = setTimeout(() => { + try { child.kill() } catch {} + finish(reject, new Error(`command timed out: ${command} ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`)) + }, timeoutMs) + child.once("error", (error) => finish(reject, error)) + child.once("close", (code) => { + const result = { code: code ?? -1, stdout, stderr } + if (!allowFailure && code !== 0) { + return finish(reject, new Error(`command exited ${code}: ${command} ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`)) + } + finish(resolve, result) + }) + }) +} + +function json(result, label) { + const text = String(result.stdout ?? "").trim() + try { + return JSON.parse(text) + } catch { + throw new Error(`${label} did not return JSON.\nstdout:\n${text}\nstderr:\n${result.stderr ?? ""}`) + } +} + +function contentText(value) { + if (typeof value === "string") return value + if (!Array.isArray(value)) return "" + return value.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n") +} + +function messageText(message) { + const parts = Array.isArray(message?.parts) ? message.parts : [] + const direct = parts.map((part) => typeof part?.text === "string" ? part.text : "").join("\n") + if (direct) return direct + return contentText(message?.content) +} + +function writeSse(res, value) { + res.write(`data: ${JSON.stringify(value)}\n\n`) +} + +function streamText(res, text, sequence) { + const id = `chatcmpl-v2-status-${sequence}` + const created = Math.floor(Date.now() / 1000) + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ index: 0, delta: { role: "assistant", content: text }, 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: 2, total_tokens: 22 }, + }) + res.end("data: [DONE]\n\n") +} + +function startProvider() { + const stats = { chatRequests: 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) + if (raw) JSON.parse(raw) + stats.chatRequests += 1 + streamText(res, "OK", stats.chatRequests) + }) + + 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 bind deterministic provider") + return address.port + }, + async close() { + await new Promise((resolve) => server.close(() => resolve())) + }, + } +} + +async function waitFor(read, predicate, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs + let value + while (Date.now() < deadline) { + value = await read() + if (predicate(value)) return value + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return value +} + +async function main() { + const temp = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-status-host-")) + const project = path.join(temp, "project") + const home = path.join(temp, "home") + const pluginDir = path.join(project, ".opencode", "plugins") + const commandDir = path.join(project, ".opencode", "commands") + const provider = startProvider() + const providerPort = await provider.listen() + + await mkdir(pluginDir, { recursive: true }) + await mkdir(commandDir, { recursive: true }) + await mkdir(home, { recursive: true }) + + const pluginURL = pathToFileURL(path.join(root, "src", "source", "opencode2", "experimental.js")).href + await writeFile(path.join(pluginDir, "opencode-loop-v2.js"), `export { default } from ${JSON.stringify(pluginURL)}\n`) + await writeFile(path.join(commandDir, "loop-status.md"), `---\ndescription: Show OpenCode Loop status\n---\n\nOpenCode Loop status command handled locally. Reply exactly: OK.\n`) + await writeFile(path.join(project, "README.md"), "# OpenCode Loop V2 status canary\n") + await writeFile(path.join(project, "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 Status Canary", + options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" }, + models: { + canary: { + name: "Deterministic V2 Status 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_DB: path.join(home, ".local", "share", "opencode", "status-canary.db"), + OPENCODE_LOG_LEVEL: "DEBUG", + CI: "true", + } + + const api = async (method, pathname, data) => { + const args = ["api", method.toLowerCase(), pathname, "-H", `x-opencode-directory: ${project}`] + if (data !== undefined) args.push("-d", JSON.stringify(data)) + return json(await run(opencode2, args, { cwd: project, env }), `${method} ${pathname}`) + } + + try { + await run("git", ["init", "-q"], { cwd: project, env }) + await run("git", ["config", "user.name", "OpenCode Loop Canary"], { cwd: project, env }) + await run("git", ["config", "user.email", "opencode-loop-canary@example.invalid"], { cwd: project, env }) + await run("git", ["add", "."], { cwd: project, env }) + await run("git", ["commit", "-q", "-m", "initialize V2 status canary"], { cwd: project, env }) + + await run(opencode2, ["service", "stop"], { cwd: project, env, allowFailure: true, timeoutMs: 15_000 }) + const version = (await run(opencode2, ["--version"], { cwd: project, env, timeoutMs: 30_000 })).stdout.trim() + assert.ok(version) + + const commandCatalog = await api("GET", "/api/command") + const commands = commandCatalog?.data ?? commandCatalog + assert.ok(Array.isArray(commands), `command catalog was not an array: ${JSON.stringify(commandCatalog)}`) + assert.ok(commands.some((item) => item?.name === "loop-status" || item?.id === "loop-status"), `loop-status was not discovered: ${JSON.stringify(commands)}`) + + const createdPayload = await api("POST", "/session", { title: "OpenCode Loop V2 status canary" }) + const session = createdPayload?.data ?? createdPayload + const sessionID = String(session?.id ?? "") + assert.ok(sessionID, `session creation failed: ${JSON.stringify(createdPayload)}`) + + await api("POST", `/session/${encodeURIComponent(sessionID)}/command`, { + agent: "build", + model: "canary/canary", + command: "loop-status", + arguments: "", + }) + + const readMessages = async () => { + const payload = await api("GET", `/session/${encodeURIComponent(sessionID)}/message`) + return payload?.data ?? payload + } + const messages = await waitFor( + readMessages, + (items) => Array.isArray(items) && items.some((message) => messageText(message).includes("OpenCode loop status:\nNo active loop jobs.")), + ) + + assert.ok(Array.isArray(messages), `session messages were not an array: ${JSON.stringify(messages)}`) + const texts = messages.map(messageText) + assert.ok(texts.some((text) => text.includes("OpenCode loop status:\nNo active loop jobs.")), `V2 status response was not persisted. Messages: ${JSON.stringify(texts)}`) + assert.ok(provider.stats.chatRequests >= 1, `command did not reach deterministic provider: ${JSON.stringify(provider.stats)}`) + + console.log(JSON.stringify({ + ok: true, + opencode2Version: version, + sessionID, + provider: provider.stats, + messageTexts: texts, + }, null, 2)) + } catch (error) { + let logTail = "" + try { + const logFile = path.join(env.XDG_DATA_HOME, "opencode", "log", "opencode.log") + logTail = (await readFile(logFile, "utf8")).slice(-30_000) + } catch {} + if (logTail) console.error(`OpenCode 2 log tail:\n${logTail}`) + throw error + } finally { + await run(opencode2, ["service", "stop"], { cwd: project, env, allowFailure: true, timeoutMs: 15_000 }).catch(() => {}) + await provider.close() + await rm(temp, { recursive: true, force: true }) + } +} + +main().catch((error) => { + console.error(error?.stack || error) + process.exitCode = 1 +}) From 5a1d43dc8bad1164fc4361fa89f75c57b463e100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:10:18 +0300 Subject: [PATCH 6/8] ci: run real V2 loop status canary --- .github/workflows/opencode2-real-adapter.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/opencode2-real-adapter.yml b/.github/workflows/opencode2-real-adapter.yml index e581ca3..d922443 100644 --- a/.github/workflows/opencode2-real-adapter.yml +++ b/.github/workflows/opencode2-real-adapter.yml @@ -5,12 +5,14 @@ on: paths: - "src/source/opencode2/**" - "scripts/fixtures/opencode2-real-adapter-probe.js" + - "scripts/opencode2-loop-status-canary.mjs" - ".github/workflows/opencode2-real-adapter.yml" push: branches: [main] paths: - "src/source/opencode2/**" - "scripts/fixtures/opencode2-real-adapter-probe.js" + - "scripts/opencode2-loop-status-canary.mjs" - ".github/workflows/opencode2-real-adapter.yml" permissions: @@ -19,7 +21,7 @@ permissions: jobs: adapter: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v6 with: @@ -66,10 +68,10 @@ 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: Run real V2 loop status canary + run: node scripts/opencode2-loop-status-canary.mjs From 774e03ecc23995679dd6c968a5b3788db441aa75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:19:48 +0300 Subject: [PATCH 7/8] test: run V2 status canary against explicit server --- scripts/opencode2-loop-status-canary.mjs | 148 +++++++++++++++++------ 1 file changed, 112 insertions(+), 36 deletions(-) diff --git a/scripts/opencode2-loop-status-canary.mjs b/scripts/opencode2-loop-status-canary.mjs index 859ccf2..b7b0c3e 100644 --- a/scripts/opencode2-loop-status-canary.mjs +++ b/scripts/opencode2-loop-status-canary.mjs @@ -1,5 +1,6 @@ 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 } from "node:fs/promises" import os from "node:os" @@ -10,10 +11,20 @@ import { fileURLToPath, pathToFileURL } from "node:url" const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") const opencode2 = process.platform === "win32" ? "opencode2.cmd" : "opencode2" -function append(current, chunk, limit = 80_000) { +function append(current, chunk, limit = 120_000) { return (current + String(chunk)).slice(-limit) } +async function stopProcess(child, timeoutMs = 3_000) { + if (!child || child.exitCode !== null) return + try { child.kill() } catch {} + await new Promise((resolve) => { + if (child.exitCode !== null) return resolve() + const timer = setTimeout(resolve, timeoutMs) + child.once("close", () => { clearTimeout(timer); resolve() }) + }) +} + async function run(command, args, { cwd, env, timeoutMs = 90_000, allowFailure = false } = {}) { return await new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env, windowsHide: true }) @@ -29,7 +40,7 @@ async function run(command, args, { cwd, env, timeoutMs = 90_000, allowFailure = child.stdout?.on("data", (chunk) => { stdout = append(stdout, chunk) }) child.stderr?.on("data", (chunk) => { stderr = append(stderr, chunk) }) const timer = setTimeout(() => { - try { child.kill() } catch {} + void stopProcess(child) finish(reject, new Error(`command timed out: ${command} ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`)) }, timeoutMs) child.once("error", (error) => finish(reject, error)) @@ -43,13 +54,32 @@ async function run(command, args, { cwd, env, timeoutMs = 90_000, allowFailure = }) } -function json(result, label) { - const text = String(result.stdout ?? "").trim() - try { - return JSON.parse(text) - } catch { - throw new Error(`${label} did not return JSON.\nstdout:\n${text}\nstderr:\n${result.stderr ?? ""}`) +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 server 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 server exited before ready.\n${logs()}`) + const connected = 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 (connected) return + await new Promise((resolve) => setTimeout(resolve, 100)) } + throw new Error(`timed out waiting for OpenCode 2 server on ${port}\n${logs()}`) } function contentText(value) { @@ -134,7 +164,7 @@ function startProvider() { } } -async function waitFor(read, predicate, timeoutMs = 15_000) { +async function waitFor(read, predicate, timeoutMs = 20_000) { const deadline = Date.now() + timeoutMs let value while (Date.now() < deadline) { @@ -153,10 +183,21 @@ async function main() { const commandDir = path.join(project, ".opencode", "commands") const provider = startProvider() const providerPort = await provider.listen() + let server + let serverLog = "" - await mkdir(pluginDir, { recursive: true }) - await mkdir(commandDir, { recursive: true }) - await mkdir(home, { recursive: true }) + const xdgConfig = path.join(home, ".config") + const xdgData = path.join(home, ".local", "share") + const xdgState = path.join(home, ".local", "state") + const xdgCache = path.join(home, ".cache") + await Promise.all([ + mkdir(pluginDir, { recursive: true }), + mkdir(commandDir, { recursive: true }), + mkdir(xdgConfig, { recursive: true }), + mkdir(xdgData, { recursive: true }), + mkdir(xdgState, { recursive: true }), + mkdir(xdgCache, { recursive: true }), + ]) const pluginURL = pathToFileURL(path.join(root, "src", "source", "opencode2", "experimental.js")).href await writeFile(path.join(pluginDir, "opencode-loop-v2.js"), `export { default } from ${JSON.stringify(pluginURL)}\n`) @@ -171,12 +212,7 @@ async function main() { npm: "@ai-sdk/openai-compatible", name: "Deterministic V2 Status Canary", options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" }, - models: { - canary: { - name: "Deterministic V2 Status Canary", - limit: { context: 100000, output: 4096 }, - }, - }, + models: { canary: { name: "Deterministic V2 Status Canary", limit: { context: 100000, output: 4096 } } }, }, }, }, null, 2)}\n`) @@ -185,21 +221,17 @@ async function main() { ...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_DB: path.join(home, ".local", "share", "opencode", "status-canary.db"), + XDG_CONFIG_HOME: xdgConfig, + XDG_DATA_HOME: xdgData, + XDG_STATE_HOME: xdgState, + XDG_CACHE_HOME: xdgCache, + OPENCODE_DB: ":memory:", OPENCODE_LOG_LEVEL: "DEBUG", + OPENCODE_DISABLE_AUTOUPDATE: "true", + OPENCODE_DISABLE_LSP_DOWNLOAD: "true", CI: "true", } - const api = async (method, pathname, data) => { - const args = ["api", method.toLowerCase(), pathname, "-H", `x-opencode-directory: ${project}`] - if (data !== undefined) args.push("-d", JSON.stringify(data)) - return json(await run(opencode2, args, { cwd: project, env }), `${method} ${pathname}`) - } - try { await run("git", ["init", "-q"], { cwd: project, env }) await run("git", ["config", "user.name", "OpenCode Loop Canary"], { cwd: project, env }) @@ -207,21 +239,60 @@ async function main() { await run("git", ["add", "."], { cwd: project, env }) await run("git", ["commit", "-q", "-m", "initialize V2 status canary"], { cwd: project, env }) - await run(opencode2, ["service", "stop"], { cwd: project, env, allowFailure: true, timeoutMs: 15_000 }) const version = (await run(opencode2, ["--version"], { cwd: project, env, timeoutMs: 30_000 })).stdout.trim() assert.ok(version) - const commandCatalog = await api("GET", "/api/command") + const port = await reservePort() + server = spawn(opencode2, ["serve", "--hostname", "127.0.0.1", "--port", String(port)], { + cwd: project, + env, + windowsHide: true, + }) + server.stdout?.on("data", (chunk) => { serverLog = append(serverLog, chunk) }) + server.stderr?.on("data", (chunk) => { serverLog = append(serverLog, chunk) }) + await waitForTcp(port, server, () => serverLog) + + const baseURL = `http://127.0.0.1:${port}` + const request = async (method, paths, data) => { + const candidates = Array.isArray(paths) ? paths : [paths] + let last + for (let index = 0; index < candidates.length; index++) { + const pathname = candidates[index] + const separator = pathname.includes("?") ? "&" : "?" + const url = `${baseURL}${pathname}${separator}directory=${encodeURIComponent(project)}` + const response = await fetch(url, { + method, + headers: { + "content-type": "application/json", + "x-opencode-directory": project, + }, + body: data === undefined ? undefined : JSON.stringify(data), + signal: AbortSignal.timeout(30_000), + }) + const text = await response.text() + last = { response, text, pathname } + if (response.status === 404 && index < candidates.length - 1) continue + if (!response.ok) throw new Error(`${method} ${pathname} returned HTTP ${response.status}: ${text}\nserver log:\n${serverLog}`) + if (!text) return null + try { return JSON.parse(text) } catch { return text } + } + throw new Error(`${method} ${candidates.join(" or ")} failed: ${last?.text ?? "no response"}`) + } + + const commandCatalog = await request("GET", ["/api/command", "/command"]) const commands = commandCatalog?.data ?? commandCatalog assert.ok(Array.isArray(commands), `command catalog was not an array: ${JSON.stringify(commandCatalog)}`) assert.ok(commands.some((item) => item?.name === "loop-status" || item?.id === "loop-status"), `loop-status was not discovered: ${JSON.stringify(commands)}`) - const createdPayload = await api("POST", "/session", { title: "OpenCode Loop V2 status canary" }) + const createdPayload = await request("POST", ["/api/session", "/session"], { title: "OpenCode Loop V2 status canary" }) const session = createdPayload?.data ?? createdPayload const sessionID = String(session?.id ?? "") assert.ok(sessionID, `session creation failed: ${JSON.stringify(createdPayload)}`) - await api("POST", `/session/${encodeURIComponent(sessionID)}/command`, { + await request("POST", [ + `/api/session/${encodeURIComponent(sessionID)}/command`, + `/session/${encodeURIComponent(sessionID)}/command`, + ], { agent: "build", model: "canary/canary", command: "loop-status", @@ -229,7 +300,10 @@ async function main() { }) const readMessages = async () => { - const payload = await api("GET", `/session/${encodeURIComponent(sessionID)}/message`) + const payload = await request("GET", [ + `/api/session/${encodeURIComponent(sessionID)}/message`, + `/session/${encodeURIComponent(sessionID)}/message`, + ]) return payload?.data ?? payload } const messages = await waitFor( @@ -239,7 +313,7 @@ async function main() { assert.ok(Array.isArray(messages), `session messages were not an array: ${JSON.stringify(messages)}`) const texts = messages.map(messageText) - assert.ok(texts.some((text) => text.includes("OpenCode loop status:\nNo active loop jobs.")), `V2 status response was not persisted. Messages: ${JSON.stringify(texts)}`) + assert.ok(texts.some((text) => text.includes("OpenCode loop status:\nNo active loop jobs.")), `V2 status response was not persisted. Messages: ${JSON.stringify(texts)}\nserver log:\n${serverLog}`) assert.ok(provider.stats.chatRequests >= 1, `command did not reach deterministic provider: ${JSON.stringify(provider.stats)}`) console.log(JSON.stringify({ @@ -248,6 +322,7 @@ async function main() { sessionID, provider: provider.stats, messageTexts: texts, + serverLog, }, null, 2)) } catch (error) { let logTail = "" @@ -256,9 +331,10 @@ async function main() { logTail = (await readFile(logFile, "utf8")).slice(-30_000) } catch {} if (logTail) console.error(`OpenCode 2 log tail:\n${logTail}`) + if (serverLog) console.error(`OpenCode 2 server output:\n${serverLog}`) throw error } finally { - await run(opencode2, ["service", "stop"], { cwd: project, env, allowFailure: true, timeoutMs: 15_000 }).catch(() => {}) + await stopProcess(server) await provider.close() await rm(temp, { recursive: true, force: true }) } From 514d97ed7bdc616d1f270d13ca43d96c8d972b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Fri, 14 Aug 2026 04:21:22 +0300 Subject: [PATCH 8/8] test: authenticate explicit V2 server canary --- scripts/opencode2-loop-status-canary.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/opencode2-loop-status-canary.mjs b/scripts/opencode2-loop-status-canary.mjs index b7b0c3e..269315b 100644 --- a/scripts/opencode2-loop-status-canary.mjs +++ b/scripts/opencode2-loop-status-canary.mjs @@ -10,6 +10,9 @@ import { fileURLToPath, pathToFileURL } from "node:url" const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") const opencode2 = process.platform === "win32" ? "opencode2.cmd" : "opencode2" +const serverUsername = "opencode" +const serverPassword = "opencode-loop-v2-canary" +const serverAuthorization = `Basic ${Buffer.from(`${serverUsername}:${serverPassword}`).toString("base64")}` function append(current, chunk, limit = 120_000) { return (current + String(chunk)).slice(-limit) @@ -229,6 +232,8 @@ async function main() { OPENCODE_LOG_LEVEL: "DEBUG", OPENCODE_DISABLE_AUTOUPDATE: "true", OPENCODE_DISABLE_LSP_DOWNLOAD: "true", + OPENCODE_SERVER_USERNAME: serverUsername, + OPENCODE_SERVER_PASSWORD: serverPassword, CI: "true", } @@ -263,6 +268,7 @@ async function main() { const response = await fetch(url, { method, headers: { + authorization: serverAuthorization, "content-type": "application/json", "x-opencode-directory": project, },