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
16 changes: 11 additions & 5 deletions .github/workflows/opencode2-real-adapter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ on:
paths:
- "src/source/opencode2/**"
- "scripts/fixtures/opencode2-real-adapter-probe.js"
- "scripts/opencode2-loop-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-canary.mjs"
- ".github/workflows/opencode2-real-adapter.yml"

permissions:
Expand All @@ -19,7 +21,7 @@ permissions:
jobs:
adapter:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
Expand Down Expand Up @@ -66,10 +68,14 @@ 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
if [ ! -f "$OPENCODE_LOOP_V2_MARKER" ]; then
cat "$RUNNER_TEMP/opencode2-real-command.json" >&2 || true
cat "$RUNNER_TEMP/opencode2-real-plugin.json" >&2 || true
exit 1
fi
- name: Run real two-turn V2 Loop canary
run: node scripts/opencode2-loop-canary.mjs
308 changes: 308 additions & 0 deletions scripts/opencode2-loop-canary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
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 repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const LOOP_OBJECTIVE = "real OpenCode 2 loop canary"
const EXPECTED_TURNS = 2

function appendLog(current, chunk, limit = 100_000) {
return (current + String(chunk)).slice(-limit)
}

async function runOpenCode2(args, { cwd, env, timeoutMs = 60_000, allowFailure = false } = {}) {
return await new Promise((resolve, reject) => {
const child = spawn("opencode2", args, { cwd, env, windowsHide: true })
let stdout = ""
let stderr = ""
let settled = false

child.stdout?.on("data", (chunk) => { stdout = appendLog(stdout, chunk) })
child.stderr?.on("data", (chunk) => { stderr = appendLog(stderr, chunk) })

const finish = (fn, value) => {
if (settled) return
settled = true
clearTimeout(timer)
fn(value)
}

const timer = setTimeout(() => {
try { child.kill("SIGTERM") } catch {}
finish(reject, new Error(`opencode2 timed out: ${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) {
finish(reject, new Error(`opencode2 exited ${code}: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
return
}
finish(resolve, result)
})
})
}

function parseJSONResult(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${String(result?.stderr ?? "")}`)
}
}

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 allMessageText(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 streamText(res, content, sequence) {
const id = `chatcmpl-v2-loop-${sequence}`
const created = Math.floor(Date.now() / 1000)
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: 30, completion_tokens: 4, total_tokens: 34 },
})
res.end("data: [DONE]\n\n")
}

function startProvider() {
const stats = { chatRequests: 0, loopRequests: 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.chatRequests += 1
const text = allMessageText(body)
if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION") && text.includes(LOOP_OBJECTIVE)) {
stats.loopRequests += 1
streamText(res, `V2_LOOP_TURN_${stats.loopRequests}`, stats.chatRequests)
return
}
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 start deterministic V2 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, 50))
}
throw new Error(`timed out waiting for ${description}\n${await diagnostics()}`)
}

async function readOpenCodeLogTail(env) {
const candidates = [
path.join(env.XDG_DATA_HOME, "opencode", "log", "opencode.log"),
path.join(env.XDG_STATE_HOME, "opencode", "log", "opencode.log"),
]
for (const file of candidates) {
try { return (await readFile(file, "utf8")).slice(-30_000) } catch {}
}
return ""
}

async function main() {
assert.equal(process.platform, "linux", "the first real OpenCode 2 loop canary is intentionally Ubuntu-only")

const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-e2e-"))
const home = path.join(workspace, ".home")
const pluginDir = path.join(workspace, ".opencode", "plugins")
const commandDir = path.join(workspace, ".opencode", "commands")
const agentDir = path.join(workspace, ".opencode", "agents")
const marker = path.join(workspace, "v2-real-adapter-marker.json")
const provider = startProvider()
const providerPort = await provider.listen()
let commandResult = null

await Promise.all([
mkdir(pluginDir, { recursive: true }),
mkdir(commandDir, { recursive: true }),
mkdir(agentDir, { recursive: true }),
mkdir(path.join(home, ".config"), { recursive: true }),
mkdir(path.join(home, ".local", "share"), { recursive: true }),
mkdir(path.join(home, ".local", "state"), { recursive: true }),
])

await writeFile(path.join(pluginDir, "opencode-loop-v2-real.js"), await readFile(path.join(repoRoot, "scripts", "fixtures", "opencode2-real-adapter-probe.js"), "utf8"))
await writeFile(path.join(commandDir, "loop.md"), await readFile(path.join(repoRoot, "commands", "loop.md"), "utf8"))
await writeFile(path.join(agentDir, "opencode-loop-local.md"), await readFile(path.join(repoRoot, "agents", "opencode-loop-local.md"), "utf8"))
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 OpenCode 2 Loop Canary",
options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" },
models: { canary: { name: "Deterministic OpenCode 2 Loop 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_LOOP_V2_PLUGIN_URL: pathToFileURL(path.join(repoRoot, "src", "source", "opencode2", "experimental.js")).href,
OPENCODE_LOOP_V2_MARKER: marker,
OPENCODE_DISABLE_AUTOUPDATE: "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
CI: "true",
}

try {
const createBody = {
title: "OpenCode 2 Loop canary",
model: { providerID: "canary", id: "canary" },
location: { directory: workspace },
}
const createdResult = await runOpenCode2([
"api",
"--standalone",
"v2.session.create",
"-d",
JSON.stringify(createBody),
], { cwd: workspace, env, timeoutMs: 60_000 })
const created = parseJSONResult(createdResult, "v2.session.create")
const session = created?.data ?? created
const sessionID = String(session?.id ?? "")
assert.ok(sessionID, `OpenCode 2 did not create a session: ${JSON.stringify(created)}`)

const commandBody = {
command: "loop",
arguments: `0s --max-runs ${EXPECTED_TURNS} ${LOOP_OBJECTIVE}`,
model: { providerID: "canary", id: "canary" },
}
const commandPromise = runOpenCode2([
"api",
"--standalone",
"v2.session.command",
"--param",
`sessionID=${sessionID}`,
"-d",
JSON.stringify(commandBody),
], { cwd: workspace, env, timeoutMs: 120_000 })
.then((result) => { commandResult = result; return result })
.catch((error) => { commandResult = { error }; return commandResult })

const stateFile = path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`)
const diagnostics = async () => {
let state = "missing"
try { state = await readFile(stateFile, "utf8") } catch {}
const log = await readOpenCodeLogTail(env)
return [
`provider=${JSON.stringify(provider.stats)}`,
`command=${commandResult?.error ? String(commandResult.error) : JSON.stringify(commandResult)}`,
`state=${state}`,
`log=${log}`,
].join("\n")
}

await waitFor(async () => {
try { return Boolean(JSON.parse(await readFile(marker, "utf8"))?.activated) } catch { return false }
}, "real V2 adapter activation during command execution", diagnostics, 30_000)

await waitFor(() => provider.stats.loopRequests >= EXPECTED_TURNS, `${EXPECTED_TURNS} autonomous OpenCode 2 Loop turns`, diagnostics, 90_000)
await new Promise((resolve) => setTimeout(resolve, 1_000))
assert.equal(provider.stats.loopRequests, EXPECTED_TURNS, `V2 Loop must stop at --max-runs ${EXPECTED_TURNS}\n${await diagnostics()}`)

const persisted = JSON.parse(await readFile(stateFile, "utf8"))
const loop = persisted.jobs?.find((job) => job.name === "default")
assert.ok(loop, `persisted V2 Loop job was missing\n${await diagnostics()}`)
assert.equal(loop.runCount, EXPECTED_TURNS)
assert.equal(loop.enabled, false)

const finalCommand = await commandPromise
if (finalCommand?.error) throw finalCommand.error

console.log(JSON.stringify({
ok: true,
opencode2: true,
sessionID,
loopRequests: provider.stats.loopRequests,
chatRequests: provider.stats.chatRequests,
runCount: loop.runCount,
enabled: loop.enabled,
}, null, 2))
} finally {
await provider.close().catch(() => undefined)
await rm(workspace, { recursive: true, force: true }).catch(() => undefined)
}
}

main().catch((error) => {
console.error(error?.stack || error)
process.exitCode = 1
})
Loading