@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.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/package.json +7 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +29 -7
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +68 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-host-canary.mjs +194 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-manager-test.mjs +48 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +82 -0
- package/scripts/v2-plugin-sentinel-test.mjs +136 -0
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +3737 -2220
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +252 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +71 -0
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +174 -0
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +47 -0
- package/src/source/opencode2/host-contract.js +94 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/session-status.js +197 -0
- package/src/source/runtime/timers.js +45 -0
- 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")
|