@bybrawe/opencode-loop 0.5.27 → 0.5.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +29 -7
  8. package/scripts/comprehensive-watchdog.mjs +32 -0
  9. package/scripts/executor-runtime-test.mjs +310 -0
  10. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  11. package/scripts/goal-command-handlers-test.mjs +236 -0
  12. package/scripts/goal-policy-test.mjs +143 -0
  13. package/scripts/goal-runtime-test.mjs +203 -0
  14. package/scripts/goal-steering-test.mjs +153 -0
  15. package/scripts/host-adapter-contract-test.mjs +147 -0
  16. package/scripts/host-goal-steering-canary.mjs +443 -0
  17. package/scripts/host-loop-canary.mjs +68 -12
  18. package/scripts/job-workspace-test.mjs +164 -0
  19. package/scripts/loop-command-handlers-test.mjs +279 -0
  20. package/scripts/loop-registration-test.mjs +220 -0
  21. package/scripts/opencode2-host-canary.mjs +194 -0
  22. package/scripts/opencode2-loop-canary.mjs +433 -0
  23. package/scripts/runtime-contract-test.mjs +59 -0
  24. package/scripts/runtime-event-test.mjs +78 -0
  25. package/scripts/runtime-scope-test.mjs +46 -0
  26. package/scripts/scheduler-runtime-test.mjs +147 -0
  27. package/scripts/session-activity-test.mjs +95 -0
  28. package/scripts/session-manager-test.mjs +48 -0
  29. package/scripts/session-status-test.mjs +220 -0
  30. package/scripts/v2-command-adapter-test.mjs +113 -0
  31. package/scripts/v2-command-runtime-test.mjs +98 -0
  32. package/scripts/v2-event-bridge-test.mjs +72 -0
  33. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  34. package/scripts/v2-host-contract-test.mjs +82 -0
  35. package/scripts/v2-plugin-sentinel-test.mjs +136 -0
  36. package/scripts/v2-prompt-interval-test.mjs +171 -0
  37. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  38. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  39. package/src/index.js +3737 -2220
  40. package/src/opencode/v1.js +1 -0
  41. package/src/source/core/args.js +281 -0
  42. package/src/source/core/jobs.js +80 -0
  43. package/src/source/core/process.js +51 -0
  44. package/src/source/core/state.js +211 -0
  45. package/src/source/legacy-v1.js +252 -0
  46. package/src/source/opencode/adapter.js +21 -0
  47. package/src/source/opencode/command-router.js +103 -0
  48. package/src/source/opencode/commands.js +83 -0
  49. package/src/source/opencode/goal-commands.js +107 -0
  50. package/src/source/opencode/host.js +134 -0
  51. package/src/source/opencode/loop-commands.js +195 -0
  52. package/src/source/opencode/loop-registration.js +100 -0
  53. package/src/source/opencode/messages.js +55 -0
  54. package/src/source/opencode/sdk.js +38 -0
  55. package/src/source/opencode/session-context.js +50 -0
  56. package/src/source/opencode2/capabilities.js +71 -0
  57. package/src/source/opencode2/commands.js +60 -0
  58. package/src/source/opencode2/event-bridge.js +174 -0
  59. package/src/source/opencode2/events.js +64 -0
  60. package/src/source/opencode2/experimental.js +47 -0
  61. package/src/source/opencode2/host-contract.js +94 -0
  62. package/src/source/opencode2/prompt-runtime.js +381 -0
  63. package/src/source/opencode2/runtime-adapter.js +56 -0
  64. package/src/source/opencode2/status.js +40 -0
  65. package/src/source/runtime/compaction.js +122 -0
  66. package/src/source/runtime/events.js +113 -0
  67. package/src/source/runtime/executor.js +558 -0
  68. package/src/source/runtime/goal-policy.js +77 -0
  69. package/src/source/runtime/goal-runtime.js +231 -0
  70. package/src/source/runtime/goal-steering.js +198 -0
  71. package/src/source/runtime/job-workspace.js +148 -0
  72. package/src/source/runtime/observer.js +23 -0
  73. package/src/source/runtime/scheduler.js +212 -0
  74. package/src/source/runtime/scope.js +56 -0
  75. package/src/source/runtime/session-activity.js +112 -0
  76. package/src/source/runtime/session-manager.js +100 -0
  77. package/src/source/runtime/session-registry.js +58 -0
  78. package/src/source/runtime/session-status.js +197 -0
  79. package/src/source/runtime/timers.js +45 -0
  80. package/src/source/v1.js +69 -0
@@ -0,0 +1,194 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import process from "node:process"
6
+ import { fileURLToPath, pathToFileURL } from "node:url"
7
+
8
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
9
+ const pluginID = "bybrawe.opencode-loop.v2.experimental"
10
+ const sentinelID = "bybrawe.opencode-loop.v2-canary-sentinel"
11
+
12
+ function run(command, args, { cwd, env, allowFailure = false, timeout = 60_000 } = {}) {
13
+ const result = spawnSync(command, args, {
14
+ cwd,
15
+ env,
16
+ encoding: "utf8",
17
+ maxBuffer: 8 * 1024 * 1024,
18
+ timeout,
19
+ windowsHide: true,
20
+ })
21
+ if (result.error) throw result.error
22
+ if (!allowFailure && result.status !== 0) {
23
+ throw new Error([
24
+ `command failed (${result.status}): ${command} ${args.join(" ")}`,
25
+ String(result.stdout ?? ""),
26
+ String(result.stderr ?? ""),
27
+ ].filter(Boolean).join("\n"))
28
+ }
29
+ return result
30
+ }
31
+
32
+ function output(result) {
33
+ return `${String(result.stdout ?? "")}\n${String(result.stderr ?? "")}`.trim()
34
+ }
35
+
36
+ function parseJSONOutput(result, label) {
37
+ const text = String(result.stdout ?? "").trim()
38
+ try {
39
+ return JSON.parse(text)
40
+ } catch {
41
+ throw new Error(`${label} did not return JSON on stdout.\nstdout:\n${text}\nstderr:\n${String(result.stderr ?? "")}`)
42
+ }
43
+ }
44
+
45
+ function collectPluginIDs(value) {
46
+ if (Array.isArray(value)) return value.flatMap(collectPluginIDs)
47
+ if (typeof value === "string") return [value]
48
+ if (!value || typeof value !== "object") return []
49
+ const direct = [value.id, value.pluginID, value.name].filter((item) => typeof item === "string")
50
+ const nested = [value.data, value.plugin, value.plugins, value.items].flatMap((item) => collectPluginIDs(item))
51
+ return [...direct, ...nested]
52
+ }
53
+
54
+ function uniquePluginIDs(value) {
55
+ return [...new Set(collectPluginIDs(value))]
56
+ }
57
+
58
+ async function failureLog(env) {
59
+ const candidates = [
60
+ path.join(env.XDG_DATA_HOME, "opencode", "log", "opencode.log"),
61
+ path.join(env.XDG_STATE_HOME, "opencode", "log", "opencode.log"),
62
+ ]
63
+ for (const file of candidates) {
64
+ try {
65
+ const raw = await readFile(file, "utf8")
66
+ return raw.slice(-30_000)
67
+ } catch {}
68
+ }
69
+ return ""
70
+ }
71
+
72
+ async function main() {
73
+ const temp = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-host-"))
74
+ const project = path.join(temp, "project")
75
+ const home = path.join(temp, "home")
76
+ const config = path.join(home, ".config")
77
+ const data = path.join(home, ".local", "share")
78
+ const state = path.join(home, ".local", "state")
79
+ const pluginDirectory = path.join(project, ".opencode", "plugins")
80
+ const pluginFile = path.join(root, "src", "source", "opencode2", "experimental.js")
81
+ const adapterBridge = path.join(pluginDirectory, "opencode-loop-v2-canary.js")
82
+ const sentinelFile = path.join(pluginDirectory, "opencode-loop-v2-sentinel.js")
83
+ const sentinelURL = pathToFileURL(sentinelFile).href
84
+ const adapterURL = pathToFileURL(adapterBridge).href
85
+
86
+ await Promise.all([
87
+ mkdir(pluginDirectory, { recursive: true }),
88
+ mkdir(config, { recursive: true }),
89
+ mkdir(data, { recursive: true }),
90
+ mkdir(state, { recursive: true }),
91
+ ])
92
+
93
+ await writeFile(sentinelFile, `export default { id: ${JSON.stringify(sentinelID)}, server: async () => ({ id: ${JSON.stringify(sentinelID)} }) }\n`)
94
+ await writeFile(adapterBridge, `export { default } from ${JSON.stringify(pathToFileURL(pluginFile).href)}\n`)
95
+ await writeFile(path.join(project, "README.md"), "# OpenCode Loop V2 host canary\n")
96
+ await writeFile(path.join(project, "opencode.json"), `${JSON.stringify({
97
+ $schema: "https://opencode.ai/config.json",
98
+ plugin: [
99
+ "./.opencode/plugins/opencode-loop-v2-sentinel.js",
100
+ "./.opencode/plugins/opencode-loop-v2-canary.js",
101
+ ],
102
+ }, null, 2)}\n`)
103
+
104
+ const env = {
105
+ ...process.env,
106
+ HOME: home,
107
+ USERPROFILE: home,
108
+ XDG_CONFIG_HOME: config,
109
+ XDG_DATA_HOME: data,
110
+ XDG_STATE_HOME: state,
111
+ OPENCODE_DB: path.join(data, "opencode", "opencode-loop-v2-canary.db"),
112
+ OPENCODE_LOG_LEVEL: "DEBUG",
113
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: [sentinelURL, adapterURL] }),
114
+ CI: "true",
115
+ }
116
+
117
+ run("git", ["init", "-q"], { cwd: project, env })
118
+ run("git", ["config", "user.name", "OpenCode Loop Canary"], { cwd: project, env })
119
+ run("git", ["config", "user.email", "opencode-loop-canary@example.invalid"], { cwd: project, env })
120
+ run("git", ["add", "."], { cwd: project, env })
121
+ run("git", ["commit", "-q", "-m", "initialize canary workspace"], { cwd: project, env })
122
+
123
+ try {
124
+ run("opencode2", ["service", "stop"], { cwd: project, env, allowFailure: true, timeout: 15_000 })
125
+
126
+ const version = output(run("opencode2", ["--version"], { cwd: project, env, timeout: 30_000 }))
127
+ if (!version) throw new Error("opencode2 --version returned no output")
128
+
129
+ const health = output(run("opencode2", ["api", "get", "/api/health"], { cwd: project, env }))
130
+ if (!health) throw new Error("OpenCode 2 health API returned no output")
131
+
132
+ const plainPluginResult = run("opencode2", ["api", "get", "/api/plugin"], { cwd: project, env })
133
+ const plainResponse = parseJSONOutput(plainPluginResult, "GET /api/plugin from project cwd")
134
+ const plainIDs = uniquePluginIDs(plainResponse)
135
+
136
+ const pluginPath = `/api/plugin?location%5Bdirectory%5D=${encodeURIComponent(project)}`
137
+ const scopedPluginResult = run("opencode2", ["api", "get", pluginPath], { cwd: project, env })
138
+ const scopedResponse = parseJSONOutput(scopedPluginResult, "GET /api/plugin at project Location")
139
+
140
+ if (scopedResponse?._tag) {
141
+ throw new Error(`project-scoped /api/plugin rejected the Location: ${JSON.stringify(scopedResponse)}`)
142
+ }
143
+ if (scopedResponse?.location?.directory !== project) {
144
+ throw new Error(`OpenCode 2 resolved the wrong Location: expected ${project}, got ${String(scopedResponse?.location?.directory)}`)
145
+ }
146
+
147
+ const scopedIDs = uniquePluginIDs(scopedResponse)
148
+ const ids = [...new Set([...plainIDs, ...scopedIDs])]
149
+ if (!ids.includes(sentinelID)) {
150
+ throw new Error([
151
+ `OpenCode 2 did not activate the current-loader sentinel. Active IDs: ${JSON.stringify(ids)}`,
152
+ `Plain /api/plugin IDs: ${JSON.stringify(plainIDs)}`,
153
+ `Scoped /api/plugin IDs: ${JSON.stringify(scopedIDs)}`,
154
+ `Plain response: ${String(plainPluginResult.stdout ?? "")}`,
155
+ `Scoped response: ${String(scopedPluginResult.stdout ?? "")}`,
156
+ ].join("\n"))
157
+ }
158
+ if (!ids.includes(pluginID)) {
159
+ throw new Error([
160
+ `OpenCode 2 activated the current-loader sentinel but not ${pluginID}. Active IDs: ${JSON.stringify(ids)}`,
161
+ `Plain /api/plugin IDs: ${JSON.stringify(plainIDs)}`,
162
+ `Scoped /api/plugin IDs: ${JSON.stringify(scopedIDs)}`,
163
+ `Plain response: ${String(plainPluginResult.stdout ?? "")}`,
164
+ `Scoped response: ${String(scopedPluginResult.stdout ?? "")}`,
165
+ ].join("\n"))
166
+ }
167
+
168
+ console.log(JSON.stringify({
169
+ ok: true,
170
+ platform: process.platform,
171
+ node: process.version,
172
+ opencode2Version: version,
173
+ health,
174
+ projectDirectory: scopedResponse.location.directory,
175
+ sentinelID,
176
+ pluginID,
177
+ plainPluginIDs: plainIDs,
178
+ scopedPluginIDs: scopedIDs,
179
+ activePluginIDs: ids,
180
+ }, null, 2))
181
+ } catch (error) {
182
+ const logs = await failureLog(env)
183
+ if (logs) console.error(`OpenCode 2 server log tail:\n${logs}`)
184
+ throw error
185
+ } finally {
186
+ run("opencode2", ["service", "stop"], { cwd: project, env, allowFailure: true, timeout: 15_000 })
187
+ await rm(temp, { recursive: true, force: true })
188
+ }
189
+ }
190
+
191
+ main().catch((error) => {
192
+ console.error(error?.stack || error)
193
+ process.exitCode = 1
194
+ })
@@ -0,0 +1,433 @@
1
+ import assert from "node:assert/strict"
2
+ import { createServer } from "node:http"
3
+ import net from "node:net"
4
+ import { execFileSync, spawn } from "node:child_process"
5
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
6
+ import os from "node:os"
7
+ import path from "node:path"
8
+ import process from "node:process"
9
+ import { fileURLToPath, pathToFileURL } from "node:url"
10
+
11
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
12
+ const LOOP_OBJECTIVE = "real OpenCode 2 loop canary"
13
+ const EXPECTED_TURNS = 2
14
+ const EXPECTED_COMMANDS = ["loop", "loop-pause", "loop-resume", "loop-stop", "loop-remove", "loop-clear"]
15
+ const SERVER_USERNAME = "opencode"
16
+ const SERVER_PASSWORD = "opencode-loop-v2-canary"
17
+
18
+ function appendLog(current, chunk, limit = 100_000) {
19
+ return (current + String(chunk)).slice(-limit)
20
+ }
21
+
22
+ async function reservePort() {
23
+ return await new Promise((resolve, reject) => {
24
+ const server = net.createServer()
25
+ server.once("error", reject)
26
+ server.listen(0, "127.0.0.1", () => {
27
+ const address = server.address()
28
+ if (!address || typeof address === "string") return reject(new Error("failed to reserve TCP port"))
29
+ server.close((error) => error ? reject(error) : resolve(address.port))
30
+ })
31
+ })
32
+ }
33
+
34
+ async function waitForTcp(port, child, logs, timeoutMs = 30_000) {
35
+ const deadline = Date.now() + timeoutMs
36
+ while (Date.now() < deadline) {
37
+ if (child.exitCode !== null) throw new Error(`OpenCode 2 server exited before ready.\n${logs()}`)
38
+ const connected = await new Promise((resolve) => {
39
+ const socket = net.createConnection({ host: "127.0.0.1", port })
40
+ socket.once("connect", () => { socket.destroy(); resolve(true) })
41
+ socket.once("error", () => resolve(false))
42
+ socket.setTimeout(500, () => { socket.destroy(); resolve(false) })
43
+ })
44
+ if (connected) return
45
+ await new Promise((resolve) => setTimeout(resolve, 100))
46
+ }
47
+ throw new Error(`timed out waiting for OpenCode 2 server on ${port}\n${logs()}`)
48
+ }
49
+
50
+ async function stopProcess(child, timeoutMs = 5_000) {
51
+ if (!child || child.exitCode !== null) return
52
+ child.kill("SIGTERM")
53
+ await new Promise((resolve) => {
54
+ if (child.exitCode !== null) return resolve()
55
+ const timer = setTimeout(resolve, timeoutMs)
56
+ child.once("close", () => { clearTimeout(timer); resolve() })
57
+ })
58
+ }
59
+
60
+ async function waitFor(predicate, description, diagnostics, timeoutMs = 60_000) {
61
+ const deadline = Date.now() + timeoutMs
62
+ while (Date.now() < deadline) {
63
+ if (await predicate()) return
64
+ await new Promise((resolve) => setTimeout(resolve, 100))
65
+ }
66
+ throw new Error(`timed out waiting for ${description}\n${await diagnostics()}`)
67
+ }
68
+
69
+ function contentText(content) {
70
+ if (typeof content === "string") return content
71
+ if (!Array.isArray(content)) return ""
72
+ return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n")
73
+ }
74
+
75
+ function lastUserText(body) {
76
+ const messages = Array.isArray(body.messages) ? body.messages : []
77
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
78
+ if (messages[index]?.role === "user") return contentText(messages[index]?.content)
79
+ }
80
+ return ""
81
+ }
82
+
83
+ function streamHeaders(res) {
84
+ res.writeHead(200, {
85
+ "content-type": "text/event-stream; charset=utf-8",
86
+ "cache-control": "no-cache",
87
+ connection: "keep-alive",
88
+ })
89
+ }
90
+
91
+ function writeSse(res, value) {
92
+ res.write(`data: ${JSON.stringify(value)}\n\n`)
93
+ }
94
+
95
+ function streamText(res, content, sequence) {
96
+ const id = `chatcmpl-v2-loop-${sequence}`
97
+ const created = Math.floor(Date.now() / 1000)
98
+ streamHeaders(res)
99
+ writeSse(res, {
100
+ id,
101
+ object: "chat.completion.chunk",
102
+ created,
103
+ model: "canary",
104
+ choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }],
105
+ })
106
+ writeSse(res, {
107
+ id,
108
+ object: "chat.completion.chunk",
109
+ created,
110
+ model: "canary",
111
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
112
+ usage: { prompt_tokens: 30, completion_tokens: 4, total_tokens: 34 },
113
+ })
114
+ res.end("data: [DONE]\n\n")
115
+ }
116
+
117
+ function startProvider() {
118
+ const stats = { chatRequests: 0, loopRequests: 0, paths: [] }
119
+ const server = createServer(async (req, res) => {
120
+ const url = new URL(req.url ?? "/", "http://127.0.0.1")
121
+ stats.paths.push(`${req.method} ${url.pathname}`)
122
+ if (req.method === "GET" && url.pathname.endsWith("/models")) {
123
+ res.writeHead(200, { "content-type": "application/json" })
124
+ res.end(JSON.stringify({ object: "list", data: [{ id: "canary", object: "model", owned_by: "canary" }] }))
125
+ return
126
+ }
127
+ if (req.method !== "POST" || !url.pathname.endsWith("/chat/completions")) {
128
+ res.writeHead(404, { "content-type": "application/json" })
129
+ res.end(JSON.stringify({ error: { message: `unexpected endpoint: ${req.method} ${url.pathname}` } }))
130
+ return
131
+ }
132
+
133
+ let raw = ""
134
+ for await (const chunk of req) raw += String(chunk)
135
+ const body = raw ? JSON.parse(raw) : {}
136
+ stats.chatRequests += 1
137
+ const text = lastUserText(body)
138
+ if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION")) {
139
+ stats.loopRequests += 1
140
+ streamText(res, `V2_LOOP_TURN_${stats.loopRequests}`, stats.chatRequests)
141
+ return
142
+ }
143
+ streamText(res, "OK", stats.chatRequests)
144
+ })
145
+
146
+ return {
147
+ stats,
148
+ async listen() {
149
+ await new Promise((resolve, reject) => {
150
+ server.once("error", reject)
151
+ server.listen(0, "127.0.0.1", resolve)
152
+ })
153
+ const address = server.address()
154
+ if (!address || typeof address === "string") throw new Error("failed to start deterministic V2 provider")
155
+ return address.port
156
+ },
157
+ async close() {
158
+ await new Promise((resolve) => server.close(() => resolve()))
159
+ },
160
+ }
161
+ }
162
+
163
+ function bridgePluginSource() {
164
+ return `import { writeFile } from "node:fs/promises"
165
+
166
+ export default {
167
+ id: "bybrawe.opencode-loop.v2.loop-canary-bridge",
168
+ async setup(ctx) {
169
+ const module = await import(process.env.OPENCODE_LOOP_V2_PLUGIN_URL)
170
+ const cleanup = await module.default.setup(ctx)
171
+ await writeFile(process.env.OPENCODE_LOOP_V2_MARKER, JSON.stringify({ activated: true }, null, 2), "utf8")
172
+ return async () => {
173
+ if (typeof cleanup === "function") await cleanup()
174
+ }
175
+ },
176
+ }
177
+ `
178
+ }
179
+
180
+ function commandNames(payload) {
181
+ const data = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload) ? payload : []
182
+ return new Set(data.map((item) => item?.name).filter((name) => typeof name === "string"))
183
+ }
184
+
185
+ async function main() {
186
+ assert.equal(process.platform, "linux", "the real OpenCode 2 Loop canary is intentionally Ubuntu-only")
187
+
188
+ const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-e2e-"))
189
+ const home = path.join(workspace, ".home")
190
+ const pluginDir = path.join(workspace, ".opencode", "plugins")
191
+ const marker = path.join(workspace, "v2-real-adapter-marker.json")
192
+ const provider = startProvider()
193
+ const providerPort = await provider.listen()
194
+ let server
195
+ let serverLog = ""
196
+ let apiPrefix = null
197
+ let latestCommands = new Set()
198
+ let sessionID = ""
199
+ let commandError = null
200
+
201
+ await Promise.all([
202
+ mkdir(pluginDir, { recursive: true }),
203
+ mkdir(path.join(home, ".config"), { recursive: true }),
204
+ mkdir(path.join(home, ".local", "share"), { recursive: true }),
205
+ mkdir(path.join(home, ".local", "state"), { recursive: true }),
206
+ ])
207
+
208
+ await writeFile(path.join(pluginDir, "opencode-loop-v2-real.js"), bridgePluginSource(), "utf8")
209
+ await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({
210
+ $schema: "https://opencode.ai/config.json",
211
+ model: "canary/canary",
212
+ providers: {
213
+ canary: {
214
+ name: "Deterministic OpenCode 2 Loop Canary",
215
+ package: "@opencode-ai/ai/providers/openai-compatible",
216
+ settings: { baseURL: `http://127.0.0.1:${providerPort}/v1` },
217
+ models: {
218
+ canary: {
219
+ name: "Deterministic OpenCode 2 Loop Canary",
220
+ capabilities: { tools: true, input: ["text"], output: ["text"] },
221
+ limit: { context: 100000, output: 4096 },
222
+ },
223
+ },
224
+ },
225
+ },
226
+ }, null, 2)}\n`)
227
+
228
+ execFileSync("git", ["init", "--quiet", workspace], { stdio: "ignore" })
229
+ execFileSync("git", ["-C", workspace, "config", "user.email", "opencode-loop-ci@example.invalid"], { stdio: "ignore" })
230
+ execFileSync("git", ["-C", workspace, "config", "user.name", "OpenCode Loop CI"], { stdio: "ignore" })
231
+ execFileSync("git", ["-C", workspace, "add", "."], { stdio: "ignore" })
232
+ execFileSync("git", ["-C", workspace, "commit", "--quiet", "-m", "init"], { stdio: "ignore" })
233
+
234
+ const env = {
235
+ ...process.env,
236
+ HOME: home,
237
+ USERPROFILE: home,
238
+ XDG_CONFIG_HOME: path.join(home, ".config"),
239
+ XDG_DATA_HOME: path.join(home, ".local", "share"),
240
+ XDG_STATE_HOME: path.join(home, ".local", "state"),
241
+ XDG_CACHE_HOME: path.join(home, ".cache"),
242
+ OPENCODE_LOOP_V2_PLUGIN_URL: pathToFileURL(path.join(repoRoot, "src", "source", "opencode2", "experimental.js")).href,
243
+ OPENCODE_LOOP_V2_MARKER: marker,
244
+ OPENCODE_SERVER_USERNAME: SERVER_USERNAME,
245
+ OPENCODE_SERVER_PASSWORD: SERVER_PASSWORD,
246
+ OPENCODE_DISABLE_AUTOUPDATE: "true",
247
+ OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
248
+ CI: "true",
249
+ }
250
+
251
+ const diagnostics = async () => {
252
+ let state = "missing"
253
+ if (sessionID) {
254
+ try { state = await readFile(path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`), "utf8") } catch {}
255
+ }
256
+ return [
257
+ `apiPrefix=${String(apiPrefix)}`,
258
+ `commands=${JSON.stringify([...latestCommands])}`,
259
+ `provider=${JSON.stringify(provider.stats)}`,
260
+ `sessionID=${sessionID || "none"}`,
261
+ `commandError=${String(commandError ?? "none")}`,
262
+ `state=${state}`,
263
+ `serverExit=${server?.exitCode}`,
264
+ `serverLog=${serverLog}`,
265
+ ].join("\n")
266
+ }
267
+
268
+ try {
269
+ const port = await reservePort()
270
+ server = spawn("opencode2", ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
271
+ cwd: workspace,
272
+ env,
273
+ windowsHide: true,
274
+ })
275
+ server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
276
+ server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
277
+ await waitForTcp(port, server, () => serverLog)
278
+
279
+ const baseURL = `http://127.0.0.1:${port}`
280
+ const authorization = `Basic ${Buffer.from(`${SERVER_USERNAME}:${SERVER_PASSWORD}`).toString("base64")}`
281
+ const request = async (pathname, init = {}, timeoutMs = 30_000) => {
282
+ const response = await fetch(`${baseURL}${pathname}`, {
283
+ ...init,
284
+ headers: {
285
+ "content-type": "application/json",
286
+ "x-opencode-directory": workspace,
287
+ authorization,
288
+ ...(init.headers ?? {}),
289
+ },
290
+ signal: init.signal ?? AbortSignal.timeout(timeoutMs),
291
+ })
292
+ const text = await response.text()
293
+ let body = null
294
+ if (text) {
295
+ try { body = JSON.parse(text) } catch { body = text }
296
+ }
297
+ return { ok: response.ok, status: response.status, body, text }
298
+ }
299
+
300
+ for (const prefix of ["/api", ""]) {
301
+ const deadline = Date.now() + 30_000
302
+ while (Date.now() < deadline) {
303
+ let response
304
+ try { response = await request(`${prefix}/command`, { method: "GET" }, 5_000) } catch {}
305
+ if (response?.ok) {
306
+ apiPrefix = prefix
307
+ break
308
+ }
309
+ if (response && ![404, 503].includes(response.status)) {
310
+ throw new Error(`command registry probe failed with HTTP ${response.status}: ${response.text}\n${await diagnostics()}`)
311
+ }
312
+ if (response?.status === 404) break
313
+ await new Promise((resolve) => setTimeout(resolve, 250))
314
+ }
315
+ if (apiPrefix !== null) break
316
+ }
317
+ assert.notEqual(apiPrefix, null, `OpenCode 2 command API never became ready\n${await diagnostics()}`)
318
+
319
+ await waitFor(async () => {
320
+ try { return Boolean(JSON.parse(await readFile(marker, "utf8"))?.activated) } catch { return false }
321
+ }, "experimental V2 plugin activation", diagnostics, 30_000)
322
+
323
+ await waitFor(async () => {
324
+ const response = await request(`${apiPrefix}/command`, { method: "GET" }, 5_000)
325
+ if (!response.ok) return false
326
+ latestCommands = commandNames(response.body)
327
+ return EXPECTED_COMMANDS.every((name) => latestCommands.has(name))
328
+ }, "plugin-registered Loop commands to enter the real V2 registry", diagnostics, 30_000)
329
+
330
+ const createdResponse = await request(`${apiPrefix}/session`, {
331
+ method: "POST",
332
+ body: JSON.stringify({ title: "OpenCode 2 Loop canary" }),
333
+ })
334
+ if (!createdResponse.ok) throw new Error(`session create failed: HTTP ${createdResponse.status} ${createdResponse.text}\n${await diagnostics()}`)
335
+ const session = createdResponse.body?.data ?? createdResponse.body
336
+ sessionID = String(session?.id ?? "")
337
+ assert.ok(sessionID, `OpenCode 2 did not create a session: ${createdResponse.text}\n${await diagnostics()}`)
338
+
339
+ const commandPromise = request(`${apiPrefix}/session/${encodeURIComponent(sessionID)}/command`, {
340
+ method: "POST",
341
+ body: JSON.stringify({ command: "loop", arguments: `0s --max-runs ${EXPECTED_TURNS} ${LOOP_OBJECTIVE}` }),
342
+ }, 120_000).catch((error) => {
343
+ commandError = error
344
+ return null
345
+ })
346
+
347
+ await waitFor(() => provider.stats.loopRequests >= EXPECTED_TURNS, `${EXPECTED_TURNS} autonomous OpenCode 2 Loop turns`, diagnostics, 90_000)
348
+ await new Promise((resolve) => setTimeout(resolve, 1_000))
349
+ assert.equal(provider.stats.loopRequests, EXPECTED_TURNS, `V2 Loop exceeded --max-runs ${EXPECTED_TURNS}\n${await diagnostics()}`)
350
+
351
+ const stateFile = path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`)
352
+ const persisted = JSON.parse(await readFile(stateFile, "utf8"))
353
+ const loop = persisted.jobs?.find((job) => job.name === "default")
354
+ assert.ok(loop, `persisted V2 Loop job was missing\n${await diagnostics()}`)
355
+ assert.equal(loop.runCount, EXPECTED_TURNS)
356
+ assert.equal(loop.enabled, false)
357
+
358
+ const commandResponse = await commandPromise
359
+ if (commandError) throw commandError
360
+ assert.ok(commandResponse?.ok, `Loop command request failed: ${commandResponse?.status} ${commandResponse?.text}\n${await diagnostics()}`)
361
+ assert.equal(server.exitCode, null, `OpenCode 2 server exited during canary\n${await diagnostics()}`)
362
+
363
+ const readPersisted = async () => {
364
+ try { return JSON.parse(await readFile(stateFile, "utf8")) }
365
+ catch (error) {
366
+ if (error?.code === "ENOENT") return { jobs: [] }
367
+ throw error
368
+ }
369
+ }
370
+ const jobNamed = async (name) => (await readPersisted()).jobs?.find((job) => String(job?.name || "default") === name)
371
+ const sendControl = async (command, argumentsText = "") => {
372
+ const response = await request(`${apiPrefix}/session/${encodeURIComponent(sessionID)}/command`, {
373
+ method: "POST",
374
+ body: JSON.stringify({ command, arguments: argumentsText }),
375
+ }, 120_000)
376
+ if (!response.ok) throw new Error(`${command} failed: HTTP ${response.status} ${response.text}\n${await diagnostics()}`)
377
+ return response
378
+ }
379
+
380
+ await sendControl("loop-pause", "default")
381
+ await waitFor(async () => (await jobNamed("default"))?.paused === true, "real V2 loop-pause state mutation", diagnostics, 30_000)
382
+
383
+ await sendControl("loop-resume", "default")
384
+ await waitFor(async () => {
385
+ const job = await jobNamed("default")
386
+ return job?.paused === false && job?.lastRunAt === 0
387
+ }, "real V2 loop-resume state mutation", diagnostics, 30_000)
388
+
389
+ await sendControl("loop-stop", "default")
390
+ await waitFor(async () => !(await jobNamed("default")), "real V2 loop-stop removal", diagnostics, 30_000)
391
+
392
+ const beforeRemoveTurn = provider.stats.loopRequests
393
+ await sendControl("loop", "0s --max-runs 1 --name removable removable control lifecycle task")
394
+ await waitFor(() => provider.stats.loopRequests === beforeRemoveTurn + 1, "real V2 removable Loop turn", diagnostics, 60_000)
395
+ await waitFor(async () => {
396
+ const job = await jobNamed("removable")
397
+ return job?.runCount === 1 && job?.enabled === false
398
+ }, "real V2 removable Loop persisted completion", diagnostics, 30_000)
399
+ await sendControl("loop-remove", "removable")
400
+ await waitFor(async () => !(await jobNamed("removable")), "real V2 loop-remove removal", diagnostics, 30_000)
401
+
402
+ const beforeClearTurn = provider.stats.loopRequests
403
+ await sendControl("loop", "0s --max-runs 1 --name clearable clearable control lifecycle task")
404
+ await waitFor(() => provider.stats.loopRequests === beforeClearTurn + 1, "real V2 clearable Loop turn", diagnostics, 60_000)
405
+ await waitFor(async () => Boolean(await jobNamed("clearable")), "real V2 clearable Loop persistence", diagnostics, 30_000)
406
+ await sendControl("loop-clear")
407
+ await waitFor(async () => (await readPersisted()).jobs?.length === 0, "real V2 loop-clear state removal", diagnostics, 30_000)
408
+
409
+ assert.equal(provider.stats.loopRequests, EXPECTED_TURNS + 2, `control lifecycle should add exactly two autonomous Loop turns\n${await diagnostics()}`)
410
+ assert.equal(server.exitCode, null, `OpenCode 2 server exited during control lifecycle canary\n${await diagnostics()}`)
411
+
412
+ console.log(JSON.stringify({
413
+ ok: true,
414
+ apiPrefix,
415
+ sessionID,
416
+ registeredCommands: [...latestCommands],
417
+ loopRequests: provider.stats.loopRequests,
418
+ chatRequests: provider.stats.chatRequests,
419
+ initialRunCount: loop.runCount,
420
+ initialEnabled: loop.enabled,
421
+ controlsVerified: ["loop-pause", "loop-resume", "loop-stop", "loop-remove", "loop-clear"],
422
+ }, null, 2))
423
+ } finally {
424
+ await stopProcess(server)
425
+ await provider.close().catch(() => undefined)
426
+ await rm(workspace, { recursive: true, force: true }).catch(() => undefined)
427
+ }
428
+ }
429
+
430
+ main().catch((error) => {
431
+ console.error(error?.stack || error)
432
+ process.exitCode = 1
433
+ })
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict"
2
+ import { createSessionRuntimeManager } from "../src/source/runtime/session-manager.js"
3
+ import { createSessionRegistry } from "../src/source/runtime/session-registry.js"
4
+
5
+ let clock = 1000
6
+ const registry = createSessionRegistry({ now: () => clock, staleAfterMs: 100 })
7
+ const first = {}
8
+ const second = {}
9
+
10
+ registry.observeExternal("ses_a", first)
11
+ clock = 1050
12
+ assert.equal(registry.peek("ses_a").seenAt, 1000)
13
+ assert.equal(registry.entries()[0].seenAt, 1000)
14
+ assert.deepEqual(registry.pruneStale(), [])
15
+
16
+ registry.observeExternal("ses_a", first)
17
+ assert.equal(registry.peek("ses_a").seenAt, 1050)
18
+ clock = 1060
19
+ registry.observeExternal("ses_a", second)
20
+ assert.equal(registry.remove("ses_a", first), false)
21
+ assert.equal(registry.peek("ses_a").runtime, second)
22
+
23
+ clock = 1159
24
+ assert.deepEqual(registry.pruneStale(), [])
25
+ clock = 1160
26
+ assert.deepEqual(registry.pruneStale(), ["ses_a"])
27
+ assert.equal(registry.peek("ses_a"), undefined)
28
+
29
+ {
30
+ const factoryCalls = []
31
+ const timerAPI = {}
32
+ const manager = createSessionRuntimeManager({
33
+ timerAPI,
34
+ runtimeFactory({ sessionID, timerAPI: injectedTimerAPI }) {
35
+ let active = true
36
+ const runtime = {
37
+ sessionID,
38
+ scope: { isActive: () => active },
39
+ dispose() { active = false; return true },
40
+ }
41
+ factoryCalls.push({ injectedTimerAPI, runtime })
42
+ return runtime
43
+ },
44
+ })
45
+
46
+ assert.throws(() => manager.observeExternal(" "), /session ID/)
47
+ assert.equal(factoryCalls.length, 0)
48
+ const runtime = manager.observeExternal(" ses_custom ")
49
+ assert.equal(runtime.sessionID, "ses_custom")
50
+ assert.equal(factoryCalls[0].injectedTimerAPI, timerAPI)
51
+ assert.equal(manager.observeExternal("ses_custom"), runtime)
52
+ assert.equal(factoryCalls.length, 1)
53
+ runtime.dispose()
54
+ assert.notEqual(manager.observeExternal("ses_custom"), runtime)
55
+ assert.equal(factoryCalls.length, 2)
56
+ assert.equal(manager.dispose(), true)
57
+ }
58
+
59
+ console.log("OpenCode runtime contract test passed")