@bybrawe/opencode-loop 0.5.28 → 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 +4 -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 +11 -3
- 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-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +54 -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-loop-canary.mjs +433 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -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-host-contract-test.mjs +27 -0
- package/scripts/v2-plugin-sentinel-test.mjs +57 -8
- 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 +2751 -1594
- package/src/source/legacy-v1.js +168 -1860
- 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/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/opencode2/capabilities.js +3 -1
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +19 -1
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +32 -1
- package/src/source/opencode2/host-contract.js +26 -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/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/scheduler.js +212 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-status.js +197 -0
- package/scripts/host-bridge-parity-test.mjs +0 -22
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
import OpenCodeLoopV2ExperimentalPlugin, { OPENCODE_LOOP_V2_COMMANDS } from "../src/source/opencode2/experimental.js"
|
|
7
|
+
import { createOpenCode2RuntimeAdapter } from "../src/source/opencode2/runtime-adapter.js"
|
|
8
|
+
|
|
9
|
+
function controllableStream() {
|
|
10
|
+
const queued = []
|
|
11
|
+
const waiting = []
|
|
12
|
+
let closed = false
|
|
13
|
+
let returns = 0
|
|
14
|
+
|
|
15
|
+
const iterator = {
|
|
16
|
+
next() {
|
|
17
|
+
if (queued.length) return Promise.resolve({ done: false, value: queued.shift() })
|
|
18
|
+
if (closed) return Promise.resolve({ done: true, value: undefined })
|
|
19
|
+
return new Promise((resolve) => waiting.push(resolve))
|
|
20
|
+
},
|
|
21
|
+
async return() {
|
|
22
|
+
returns += 1
|
|
23
|
+
closed = true
|
|
24
|
+
while (waiting.length) waiting.shift()({ done: true, value: undefined })
|
|
25
|
+
return { done: true, value: undefined }
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
stream: { [Symbol.asyncIterator]: () => iterator },
|
|
31
|
+
push(value) {
|
|
32
|
+
if (closed) throw new Error("stream is closed")
|
|
33
|
+
if (waiting.length) waiting.shift()({ done: false, value })
|
|
34
|
+
else queued.push(value)
|
|
35
|
+
},
|
|
36
|
+
returns: () => returns,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function settle() {
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function waitFor(predicate, description, timeoutMs = 2_000) {
|
|
45
|
+
const deadline = Date.now() + timeoutMs
|
|
46
|
+
while (Date.now() < deadline) {
|
|
47
|
+
if (await predicate()) return
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`timed out waiting for ${description}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function runtimeContext(events, prompts) {
|
|
54
|
+
return {
|
|
55
|
+
command: { transform: async () => ({ dispose: async () => {} }) },
|
|
56
|
+
event: { subscribe: () => events.stream },
|
|
57
|
+
session: {
|
|
58
|
+
prompt: async (input) => {
|
|
59
|
+
prompts.push(structuredClone(input))
|
|
60
|
+
return { accepted: true }
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function verifyTwoTurnLoop({ directory, sessionID, events, prompts, native }) {
|
|
67
|
+
const stateFile = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
|
|
68
|
+
const readState = async () => JSON.parse(await readFile(stateFile, "utf8"))
|
|
69
|
+
|
|
70
|
+
if (native) {
|
|
71
|
+
events.push({
|
|
72
|
+
id: "evt_inbox",
|
|
73
|
+
type: "session.inbox.enqueued",
|
|
74
|
+
location: { directory },
|
|
75
|
+
data: {
|
|
76
|
+
sessionID,
|
|
77
|
+
item: {
|
|
78
|
+
type: "user",
|
|
79
|
+
payload: {
|
|
80
|
+
text: `${OPENCODE_LOOP_V2_COMMANDS.loop.template}\n\n0s --max-runs 2 continue the native wiring test`,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
} else {
|
|
86
|
+
events.push({
|
|
87
|
+
directory,
|
|
88
|
+
payload: {
|
|
89
|
+
type: "command.executed",
|
|
90
|
+
properties: { sessionID, name: "loop", arguments: "0s --max-runs 2 continue the legacy wiring test" },
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await waitFor(async () => {
|
|
96
|
+
try { return (await readState()).jobs?.length === 1 } catch { return false }
|
|
97
|
+
}, `${native ? "native" : "legacy"} V2 loop state creation`)
|
|
98
|
+
|
|
99
|
+
const idle = () => native
|
|
100
|
+
? events.push({ id: `evt_idle_${prompts.length}`, type: "session.execution.succeeded", data: { sessionID } })
|
|
101
|
+
: events.push({ directory, payload: { type: "session.idle", properties: { sessionID } } })
|
|
102
|
+
|
|
103
|
+
idle()
|
|
104
|
+
await waitFor(() => prompts.length === 1, `first ${native ? "native" : "legacy"} V2 prompt dispatch`)
|
|
105
|
+
assert.equal(prompts[0].sessionID, sessionID)
|
|
106
|
+
assert.ok(prompts[0].text.includes("AUTONOMOUS OPENCODE LOOP ITERATION"))
|
|
107
|
+
assert.ok(prompts[0].text.includes(native ? "continue the native wiring test" : "continue the legacy wiring test"))
|
|
108
|
+
|
|
109
|
+
idle()
|
|
110
|
+
await waitFor(() => prompts.length === 2, `second ${native ? "native" : "legacy"} V2 prompt dispatch`)
|
|
111
|
+
const state = await readState()
|
|
112
|
+
assert.equal(state.jobs[0].runCount, 2)
|
|
113
|
+
assert.equal(state.jobs[0].enabled, false)
|
|
114
|
+
|
|
115
|
+
idle()
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
117
|
+
assert.equal(prompts.length, 2, "max-runs must stop a third V2 prompt")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
const events = controllableStream()
|
|
122
|
+
const prompts = []
|
|
123
|
+
let subscriptions = 0
|
|
124
|
+
const ctx = {
|
|
125
|
+
event: {
|
|
126
|
+
subscribe() {
|
|
127
|
+
subscriptions += 1
|
|
128
|
+
return events.stream
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
session: {
|
|
132
|
+
prompt: async (input) => {
|
|
133
|
+
prompts.push(input)
|
|
134
|
+
return { accepted: true }
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const adapter = createOpenCode2RuntimeAdapter(ctx)
|
|
140
|
+
assert.equal(await adapter.start(), true)
|
|
141
|
+
assert.equal(await adapter.start(), false)
|
|
142
|
+
assert.equal(subscriptions, 1)
|
|
143
|
+
|
|
144
|
+
events.push({
|
|
145
|
+
directory: "/project",
|
|
146
|
+
payload: { type: "session.created", properties: { info: { id: "ses_v2", directory: "/project" } } },
|
|
147
|
+
})
|
|
148
|
+
await settle()
|
|
149
|
+
assert.ok(adapter.runtimeManager.peek("ses_v2"), "V2 event stream must create a session runtime")
|
|
150
|
+
|
|
151
|
+
const result = await adapter.prompt({ sessionID: "ses_v2", text: "continue" })
|
|
152
|
+
assert.deepEqual(result, { accepted: true })
|
|
153
|
+
assert.deepEqual(prompts, [{ sessionID: "ses_v2", text: "continue" }])
|
|
154
|
+
|
|
155
|
+
const statusResult = await adapter.prompt({
|
|
156
|
+
sessionID: "ses_v2",
|
|
157
|
+
text: "OpenCode loop status:\nNo active loop jobs.",
|
|
158
|
+
noReply: true,
|
|
159
|
+
agent: "build",
|
|
160
|
+
model: { providerID: "canary", modelID: "canary" },
|
|
161
|
+
})
|
|
162
|
+
assert.deepEqual(statusResult, { accepted: true })
|
|
163
|
+
assert.deepEqual(prompts[1], {
|
|
164
|
+
sessionID: "ses_v2",
|
|
165
|
+
noReply: true,
|
|
166
|
+
parts: [{ type: "text", text: "OpenCode loop status:\nNo active loop jobs." }],
|
|
167
|
+
agent: "build",
|
|
168
|
+
model: { providerID: "canary", modelID: "canary" },
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
assert.equal(await adapter.dispose("test-complete"), true)
|
|
172
|
+
assert.equal(await adapter.dispose("test-complete"), false)
|
|
173
|
+
assert.equal(events.returns(), 1, "disposing the V2 adapter must close the event iterator")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
let callback
|
|
178
|
+
let subscriptions = 0
|
|
179
|
+
let disposals = 0
|
|
180
|
+
const ctx = {
|
|
181
|
+
event: {
|
|
182
|
+
subscribe(handler) {
|
|
183
|
+
subscriptions += 1
|
|
184
|
+
callback = handler
|
|
185
|
+
return { dispose: async () => { disposals += 1 } }
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
session: { prompt: async () => ({ accepted: true }) },
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const adapter = createOpenCode2RuntimeAdapter(ctx)
|
|
192
|
+
assert.equal(await adapter.start(), true)
|
|
193
|
+
assert.equal(subscriptions, 1)
|
|
194
|
+
assert.equal(typeof callback, "function", "callback-style V2 subscribe must receive the bridge callback")
|
|
195
|
+
|
|
196
|
+
await callback({
|
|
197
|
+
directory: "/callback-project",
|
|
198
|
+
payload: { type: "session.created", properties: { info: { id: "ses_v2_callback", directory: "/callback-project" } } },
|
|
199
|
+
})
|
|
200
|
+
assert.ok(adapter.runtimeManager.peek("ses_v2_callback"), "callback-style V2 event subscription must reach the runtime manager")
|
|
201
|
+
|
|
202
|
+
assert.equal(await adapter.dispose("callback-test-complete"), true)
|
|
203
|
+
assert.equal(disposals, 1, "callback-style V2 subscription must dispose its registration")
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
{
|
|
207
|
+
const events = controllableStream()
|
|
208
|
+
let commandTransforms = 0
|
|
209
|
+
let commandDisposals = 0
|
|
210
|
+
let eventSubscriptions = 0
|
|
211
|
+
const ctx = {
|
|
212
|
+
command: {
|
|
213
|
+
transform: async () => {
|
|
214
|
+
commandTransforms += 1
|
|
215
|
+
return { dispose: async () => { commandDisposals += 1 } }
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
event: {
|
|
219
|
+
subscribe() {
|
|
220
|
+
eventSubscriptions += 1
|
|
221
|
+
return events.stream
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
session: { prompt: async () => ({ accepted: true }) },
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const cleanup = await OpenCodeLoopV2ExperimentalPlugin.setup(ctx)
|
|
228
|
+
assert.equal(typeof cleanup, "function")
|
|
229
|
+
assert.equal(commandTransforms, 1)
|
|
230
|
+
assert.equal(eventSubscriptions, 1)
|
|
231
|
+
await cleanup()
|
|
232
|
+
assert.equal(commandDisposals, 1, "plugin cleanup must dispose command.transform registration")
|
|
233
|
+
assert.equal(events.returns(), 1, "plugin cleanup must dispose the V2 event subscription")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
{
|
|
237
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-wiring-legacy-"))
|
|
238
|
+
const sessionID = "ses_v2_wiring_legacy"
|
|
239
|
+
const events = controllableStream()
|
|
240
|
+
const prompts = []
|
|
241
|
+
const cleanup = await OpenCodeLoopV2ExperimentalPlugin.setup(runtimeContext(events, prompts))
|
|
242
|
+
try {
|
|
243
|
+
await verifyTwoTurnLoop({ directory, sessionID, events, prompts, native: false })
|
|
244
|
+
} finally {
|
|
245
|
+
await cleanup?.()
|
|
246
|
+
await rm(directory, { recursive: true, force: true })
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
{
|
|
251
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-wiring-native-"))
|
|
252
|
+
const sessionID = "ses_v2_wiring_native"
|
|
253
|
+
const events = controllableStream()
|
|
254
|
+
const prompts = []
|
|
255
|
+
const cleanup = await OpenCodeLoopV2ExperimentalPlugin.setup(runtimeContext(events, prompts))
|
|
256
|
+
try {
|
|
257
|
+
await verifyTwoTurnLoop({ directory, sessionID, events, prompts, native: true })
|
|
258
|
+
events.push({
|
|
259
|
+
id: "evt_status",
|
|
260
|
+
type: "session.inbox.enqueued",
|
|
261
|
+
location: { directory },
|
|
262
|
+
data: {
|
|
263
|
+
sessionID,
|
|
264
|
+
item: {
|
|
265
|
+
type: "user",
|
|
266
|
+
payload: { text: OPENCODE_LOOP_V2_COMMANDS["loop-status"].template },
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
await waitFor(() => prompts.length === 3, "native V2 loop-status response")
|
|
271
|
+
assert.equal(prompts[2].sessionID, sessionID)
|
|
272
|
+
assert.equal(prompts[2].noReply, true)
|
|
273
|
+
assert.equal(prompts[2].parts?.[0]?.type, "text")
|
|
274
|
+
assert.match(prompts[2].parts?.[0]?.text || "", /^OpenCode loop status:/)
|
|
275
|
+
assert.match(prompts[2].parts?.[0]?.text || "", /runs=2/)
|
|
276
|
+
assert.match(prompts[2].parts?.[0]?.text || "", /continue the native wiring test/)
|
|
277
|
+
} finally {
|
|
278
|
+
await cleanup?.()
|
|
279
|
+
await rm(directory, { recursive: true, force: true })
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
{
|
|
284
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-inbox-ignore-"))
|
|
285
|
+
const sessionID = "ses_v2_inbox_ignore"
|
|
286
|
+
const events = controllableStream()
|
|
287
|
+
const prompts = []
|
|
288
|
+
const cleanup = await OpenCodeLoopV2ExperimentalPlugin.setup(runtimeContext(events, prompts))
|
|
289
|
+
try {
|
|
290
|
+
events.push({
|
|
291
|
+
type: "session.inbox.enqueued",
|
|
292
|
+
location: { directory },
|
|
293
|
+
data: { sessionID, item: { type: "user", payload: { text: "ordinary user message" } } },
|
|
294
|
+
})
|
|
295
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
296
|
+
await assert.rejects(readFile(path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`), "utf8"), /ENOENT/)
|
|
297
|
+
assert.equal(prompts.length, 0, "ordinary V2 inbox messages must not be interpreted as Loop commands")
|
|
298
|
+
} finally {
|
|
299
|
+
await cleanup?.()
|
|
300
|
+
await rm(directory, { recursive: true, force: true })
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
{
|
|
305
|
+
let commandDisposals = 0
|
|
306
|
+
const result = await OpenCodeLoopV2ExperimentalPlugin.setup({
|
|
307
|
+
command: {
|
|
308
|
+
transform: async () => ({ dispose: async () => { commandDisposals += 1 } }),
|
|
309
|
+
},
|
|
310
|
+
event: {},
|
|
311
|
+
session: {},
|
|
312
|
+
})
|
|
313
|
+
assert.equal(result, undefined)
|
|
314
|
+
assert.equal(commandDisposals, 1, "partial V2 hosts must not leak command registrations")
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
assert.throws(
|
|
318
|
+
() => createOpenCode2RuntimeAdapter({ event: {}, session: { prompt: async () => {} } }),
|
|
319
|
+
/event\.subscribe capability is unavailable/,
|
|
320
|
+
)
|
|
321
|
+
assert.throws(
|
|
322
|
+
() => createOpenCode2RuntimeAdapter({ event: { subscribe() {} }, session: {} }),
|
|
323
|
+
/session\.prompt capability is unavailable/,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
console.log("OpenCode 2 runtime adapter contract passed")
|