@bybrawe/opencode-loop 0.5.15 → 0.5.17
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 +17 -0
- package/README.md +34 -13
- package/agents/opencode-loop-local.md +10 -0
- package/commands/loop-ask.md +1 -0
- package/commands/loop-clear.md +1 -0
- package/commands/loop-cmd.md +1 -0
- package/commands/loop-command.md +1 -0
- package/commands/loop-compact.md +1 -0
- package/commands/loop-dev.md +1 -0
- package/commands/loop-doctor.md +1 -0
- package/commands/loop-export.md +1 -0
- package/commands/loop-goal-blocked.md +1 -0
- package/commands/loop-goal-clear.md +1 -0
- package/commands/loop-goal-done.md +1 -0
- package/commands/loop-goal-pause.md +1 -0
- package/commands/loop-goal-resume.md +1 -0
- package/commands/loop-goal-status.md +1 -0
- package/commands/loop-goal.md +1 -0
- package/commands/loop-help.md +1 -0
- package/commands/loop-init.md +1 -0
- package/commands/loop-logs.md +1 -0
- package/commands/loop-now.md +1 -0
- package/commands/loop-pause.md +1 -0
- package/commands/loop-progress.md +1 -0
- package/commands/loop-prompt.md +1 -0
- package/commands/loop-remove.md +1 -0
- package/commands/loop-resume.md +1 -0
- package/commands/loop-safe-dev.md +1 -0
- package/commands/loop-shell.md +1 -0
- package/commands/loop-status.md +1 -0
- package/commands/loop-stop.md +1 -0
- package/commands/loop-testfix.md +1 -0
- package/commands/loop.md +1 -0
- package/package.json +6 -5
- package/scripts/comprehensive-test.mjs +455 -0
- package/scripts/install-node.mjs +100 -4
- package/scripts/install-test.mjs +86 -0
- package/scripts/install.ps1 +6 -32
- package/scripts/install.sh +6 -34
- package/scripts/smoke-test.mjs +45 -2
- package/src/index.js +297 -41
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { promises as fs } from "node:fs"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
import OpenCodeLoopPlugin from "../src/index.js"
|
|
7
|
+
|
|
8
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
9
|
+
let harnessNumber = 0
|
|
10
|
+
|
|
11
|
+
async function createHarness(options = {}) {
|
|
12
|
+
harnessNumber++
|
|
13
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-loop-comprehensive-${harnessNumber}-`))
|
|
14
|
+
const sessionID = `ses_comprehensive_${harnessNumber}`
|
|
15
|
+
const records = {
|
|
16
|
+
aborts: [],
|
|
17
|
+
commands: [],
|
|
18
|
+
logs: [],
|
|
19
|
+
prompts: [],
|
|
20
|
+
shells: [],
|
|
21
|
+
toasts: [],
|
|
22
|
+
tuiCommands: [],
|
|
23
|
+
}
|
|
24
|
+
const statuses = new Map([[sessionID, "idle"]])
|
|
25
|
+
|
|
26
|
+
const client = {
|
|
27
|
+
app: {
|
|
28
|
+
log: async (args) => {
|
|
29
|
+
assert.ok(args?.body, "app.log must use the SDK body shape")
|
|
30
|
+
records.logs.push(args.body)
|
|
31
|
+
return { data: true }
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
tui: {
|
|
35
|
+
executeCommand: async (args) => {
|
|
36
|
+
assert.ok(args?.body?.command, "tui.executeCommand must use the SDK body shape")
|
|
37
|
+
records.tuiCommands.push(args.body.command)
|
|
38
|
+
if (options.failCompact) throw new Error("simulated TUI compact failure")
|
|
39
|
+
return { data: true }
|
|
40
|
+
},
|
|
41
|
+
showToast: async (args) => {
|
|
42
|
+
assert.ok(args?.body?.message, "tui.showToast must use the SDK body shape")
|
|
43
|
+
records.toasts.push(args.body)
|
|
44
|
+
return { data: true }
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
session: {
|
|
48
|
+
list: async (args) => {
|
|
49
|
+
assert.equal(args?.query?.directory, directory)
|
|
50
|
+
return {
|
|
51
|
+
data: [...statuses.keys()].map((id) => ({
|
|
52
|
+
id,
|
|
53
|
+
directory,
|
|
54
|
+
projectID: "comprehensive",
|
|
55
|
+
title: id,
|
|
56
|
+
version: "test",
|
|
57
|
+
time: { created: Date.now(), updated: Date.now() },
|
|
58
|
+
})),
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
get: async (args) => {
|
|
62
|
+
assert.equal(args?.path?.id, sessionID)
|
|
63
|
+
return { data: { id: sessionID, agent: "build", model: { id: "test-model", providerID: "test-provider" } } }
|
|
64
|
+
},
|
|
65
|
+
abort: async (args) => {
|
|
66
|
+
records.aborts.push(args?.path?.id)
|
|
67
|
+
return { data: true }
|
|
68
|
+
},
|
|
69
|
+
command: async (args) => {
|
|
70
|
+
assert.equal(args?.path?.id, sessionID)
|
|
71
|
+
records.commands.push(args.body)
|
|
72
|
+
return { data: true }
|
|
73
|
+
},
|
|
74
|
+
prompt: async (args) => {
|
|
75
|
+
assert.equal(args?.path?.id, sessionID)
|
|
76
|
+
const text = args.body.parts.map((part) => part.text || "").join("\n")
|
|
77
|
+
records.prompts.push({ text, noReply: args.body.noReply === true, agent: args.body.agent, model: args.body.model })
|
|
78
|
+
return { data: true }
|
|
79
|
+
},
|
|
80
|
+
shell: async (args) => {
|
|
81
|
+
assert.equal(args?.path?.id, sessionID)
|
|
82
|
+
records.shells.push(args.body)
|
|
83
|
+
return { data: true }
|
|
84
|
+
},
|
|
85
|
+
status: async (args) => {
|
|
86
|
+
assert.equal(args?.query?.directory, directory)
|
|
87
|
+
// Current OpenCode omits idle sessions from this response.
|
|
88
|
+
return {
|
|
89
|
+
data: Object.fromEntries(
|
|
90
|
+
[...statuses].filter(([, type]) => type !== "idle").map(([id, type]) => [id, { type }]),
|
|
91
|
+
),
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
summarize: async () => {
|
|
95
|
+
if (options.failCompact) throw new Error("simulated summarize failure")
|
|
96
|
+
return { data: true }
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const hooks = await OpenCodeLoopPlugin({ client, directory })
|
|
102
|
+
const stateFile = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
|
|
103
|
+
return {
|
|
104
|
+
client,
|
|
105
|
+
directory,
|
|
106
|
+
hooks,
|
|
107
|
+
records,
|
|
108
|
+
sessionID,
|
|
109
|
+
statuses,
|
|
110
|
+
async command(command, argumentsText = "", output = { parts: [] }) {
|
|
111
|
+
await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)
|
|
112
|
+
return output
|
|
113
|
+
},
|
|
114
|
+
async commandEvent(command, argumentsText = "", messageID = `msg_${Date.now()}_${Math.random()}`) {
|
|
115
|
+
await hooks.event({ event: { type: "command.executed", properties: { name: command, sessionID, arguments: argumentsText, messageID } } })
|
|
116
|
+
},
|
|
117
|
+
async idle() {
|
|
118
|
+
statuses.set(sessionID, "idle")
|
|
119
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
|
|
120
|
+
},
|
|
121
|
+
async readState() {
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(await fs.readFile(stateFile, "utf8"))
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error?.code === "ENOENT") return { version: 4, jobs: [] }
|
|
126
|
+
throw error
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
reportTexts() {
|
|
130
|
+
return records.prompts.filter((item) => item.noReply).map((item) => item.text)
|
|
131
|
+
},
|
|
132
|
+
actionTexts() {
|
|
133
|
+
return records.prompts.filter((item) => !item.noReply).map((item) => item.text)
|
|
134
|
+
},
|
|
135
|
+
async cleanup() {
|
|
136
|
+
await hooks.dispose?.()
|
|
137
|
+
await fs.rm(directory, { recursive: true, force: true })
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function jsonReport(harness) {
|
|
143
|
+
const report = [...harness.reportTexts()].reverse().find((text) => text.includes("```json"))
|
|
144
|
+
assert.ok(report, "expected a JSON report")
|
|
145
|
+
const match = report.match(/```json\s*([\s\S]*?)\s*```/)
|
|
146
|
+
assert.ok(match, "expected a fenced JSON object")
|
|
147
|
+
return JSON.parse(match[1])
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function testParserAndPresets() {
|
|
151
|
+
const h = await createHarness()
|
|
152
|
+
try {
|
|
153
|
+
await h.command("loop", "3m --name matrix --no-now --allow-overlap --safe --quiet --ask-never --git-checkpoint --checkpoint-only --pause-on-verify-fail --multi --prompt --max-runs 4 --max-no-progress 2 --timeout 7s --max-runtime 2h --max-failures 3 --until \"2030-01-01T00:00:00Z\" --stop-file stop.txt --progress-file progress.md --prompt-file prompt.md --goal-file goal.md --evidence-file evidence.md --test \"npm test\" --verify \"npm run verify\" --preflight \"npm run preflight\" --postrun \"npm run postrun\" --notify \"node notify.js\" --branch feature/test --batch 2 --compact-every 5 --watch one.txt --watch two.txt --include-file context.md --acceptance \"criterion A\" --success \"criterion B\" --check \"npm test\" --dry-run perform matrix work")
|
|
154
|
+
const job = jsonReport(h)
|
|
155
|
+
assert.equal(job.intervalMs, 180_000)
|
|
156
|
+
assert.equal(job.name, "matrix")
|
|
157
|
+
assert.equal(job.action, "perform matrix work")
|
|
158
|
+
assert.equal(job.kind, "prompt")
|
|
159
|
+
assert.equal(job.immediate, false)
|
|
160
|
+
assert.equal(job.noOverlap, false)
|
|
161
|
+
assert.equal(job.safe, true)
|
|
162
|
+
assert.equal(job.quiet, true)
|
|
163
|
+
assert.equal(job.askNever, true)
|
|
164
|
+
assert.equal(job.gitCheckpoint, true)
|
|
165
|
+
assert.equal(job.checkpointOnly, true)
|
|
166
|
+
assert.equal(job.pauseOnVerifyFail, true)
|
|
167
|
+
assert.equal(job.multi, true)
|
|
168
|
+
assert.equal(job.maxRuns, 4)
|
|
169
|
+
assert.equal(job.maxNoProgress, 2)
|
|
170
|
+
assert.equal(job.timeoutMs, 7_000)
|
|
171
|
+
assert.equal(job.maxRuntimeMs, 7_200_000)
|
|
172
|
+
assert.equal(job.maxFailures, 3)
|
|
173
|
+
assert.equal(job.stopFile, "stop.txt")
|
|
174
|
+
assert.equal(job.testCommand, "npm test")
|
|
175
|
+
assert.equal(job.verifyCommand, "npm run verify")
|
|
176
|
+
assert.equal(job.preflightCommand, "npm run preflight")
|
|
177
|
+
assert.equal(job.postrunCommand, "npm run postrun")
|
|
178
|
+
assert.equal(job.notifyCommand, "node notify.js")
|
|
179
|
+
assert.equal(job.branch, "feature/test")
|
|
180
|
+
assert.equal(job.batch, 2)
|
|
181
|
+
assert.equal(job.compactEveryRuns, 5)
|
|
182
|
+
assert.deepEqual(job.watchPaths, ["one.txt", "two.txt"])
|
|
183
|
+
assert.deepEqual(job.includeFiles, ["context.md"])
|
|
184
|
+
assert.deepEqual(job.goalAcceptance, ["criterion A", "criterion B"])
|
|
185
|
+
assert.deepEqual(job.goalChecks, ["npm test"])
|
|
186
|
+
|
|
187
|
+
await h.command("loop-compact", "0s --dry-run")
|
|
188
|
+
let preset = jsonReport(h)
|
|
189
|
+
assert.equal(preset.intervalMs, 0, "explicit 0s must not become the 200m default")
|
|
190
|
+
assert.equal(preset.action, "/compact")
|
|
191
|
+
assert.equal(preset.kind, "compact")
|
|
192
|
+
|
|
193
|
+
await h.command("loop-compact", "--dry-run")
|
|
194
|
+
preset = jsonReport(h)
|
|
195
|
+
assert.equal(preset.intervalMs, 12_000_000)
|
|
196
|
+
assert.equal(preset.action, "/compact", "a flag-only preset must keep its real default action")
|
|
197
|
+
|
|
198
|
+
await h.command("loop-safe-dev", "--dry-run --safe")
|
|
199
|
+
preset = jsonReport(h)
|
|
200
|
+
assert.match(preset.action, /Develop the project/)
|
|
201
|
+
assert.notEqual(preset.action, "--safe")
|
|
202
|
+
|
|
203
|
+
await h.command("loop-testfix", "5m pnpm test --dry-run")
|
|
204
|
+
preset = jsonReport(h)
|
|
205
|
+
assert.equal(preset.intervalMs, 300_000)
|
|
206
|
+
assert.equal(preset.verifyCommand, "pnpm test")
|
|
207
|
+
assert.match(preset.action, /Test command hint: pnpm test/)
|
|
208
|
+
|
|
209
|
+
await h.command("loop-testfix", "--verify \"npm run ci\" --dry-run")
|
|
210
|
+
preset = jsonReport(h)
|
|
211
|
+
assert.equal(preset.verifyCommand, "npm run ci")
|
|
212
|
+
assert.match(preset.action, /Test command hint: npm run ci/)
|
|
213
|
+
|
|
214
|
+
const toastCount = h.records.toasts.length
|
|
215
|
+
await h.command("loop", "nonsense")
|
|
216
|
+
await h.command("loop", "5m")
|
|
217
|
+
assert.equal(h.records.toasts.length, toastCount + 2)
|
|
218
|
+
assert.ok(h.records.toasts.slice(-2).every((item) => item.variant === "warning"))
|
|
219
|
+
} finally {
|
|
220
|
+
await h.cleanup()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function testLifecycleAndCommandDedupe() {
|
|
225
|
+
const h = await createHarness()
|
|
226
|
+
try {
|
|
227
|
+
await h.command("loop", "10m --no-now --name same first action")
|
|
228
|
+
await h.command("loop", "10m --no-now --name same replacement action")
|
|
229
|
+
let state = await h.readState()
|
|
230
|
+
assert.equal(state.jobs.length, 1)
|
|
231
|
+
assert.equal(state.jobs[0].action, "replacement action")
|
|
232
|
+
|
|
233
|
+
await h.command("loop", "10m --no-now --multi --name extra additional action")
|
|
234
|
+
state = await h.readState()
|
|
235
|
+
assert.equal(state.jobs.length, 2)
|
|
236
|
+
|
|
237
|
+
await h.command("loop-pause", "same")
|
|
238
|
+
state = await h.readState()
|
|
239
|
+
assert.equal(state.jobs.find((item) => item.name === "same").paused, true)
|
|
240
|
+
await h.command("loop-resume", "same")
|
|
241
|
+
state = await h.readState()
|
|
242
|
+
assert.equal(state.jobs.find((item) => item.name === "same").paused, false)
|
|
243
|
+
|
|
244
|
+
const beforeReports = h.reportTexts().length
|
|
245
|
+
await h.command("loop-status")
|
|
246
|
+
await h.commandEvent("loop-status", "", "msg_status_1")
|
|
247
|
+
assert.equal(h.reportTexts().length, beforeReports + 1, "command.executed must not duplicate the before hook")
|
|
248
|
+
await h.command("loop-status")
|
|
249
|
+
await h.commandEvent("loop-status", "", "msg_status_2")
|
|
250
|
+
assert.equal(h.reportTexts().length, beforeReports + 2, "an intentional repeated command must not be swallowed")
|
|
251
|
+
await h.commandEvent("loop-status", "", "msg_event_only")
|
|
252
|
+
await h.commandEvent("loop-status", "", "msg_event_only")
|
|
253
|
+
assert.equal(h.reportTexts().length, beforeReports + 3, "the same event message must only be handled once")
|
|
254
|
+
|
|
255
|
+
await h.command("loop-stop", "same")
|
|
256
|
+
state = await h.readState()
|
|
257
|
+
assert.deepEqual(state.jobs.map((item) => item.name), ["extra"])
|
|
258
|
+
await h.command("loop-clear")
|
|
259
|
+
state = await h.readState()
|
|
260
|
+
assert.equal(state.jobs.length, 0)
|
|
261
|
+
|
|
262
|
+
await h.command("loop-init")
|
|
263
|
+
const progress = path.join(h.directory, "progress.md")
|
|
264
|
+
assert.match(await fs.readFile(progress, "utf8"), /Active TODO/)
|
|
265
|
+
await fs.writeFile(progress, "custom progress", "utf8")
|
|
266
|
+
await h.command("loop-init")
|
|
267
|
+
assert.equal(await fs.readFile(progress, "utf8"), "custom progress", "loop-init must not overwrite user progress")
|
|
268
|
+
|
|
269
|
+
for (const command of ["loop-help", "loop-doctor", "loop-export", "loop-logs"]) await h.command(command)
|
|
270
|
+
assert.ok(h.reportTexts().some((text) => text.includes("OpenCode Loop help")))
|
|
271
|
+
assert.ok(h.reportTexts().some((text) => text.includes("OpenCode Loop doctor")))
|
|
272
|
+
assert.ok(h.reportTexts().some((text) => text.includes("state export")))
|
|
273
|
+
} finally {
|
|
274
|
+
await h.cleanup()
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function testWatchScheduling() {
|
|
279
|
+
const h = await createHarness()
|
|
280
|
+
try {
|
|
281
|
+
const watched = path.join(h.directory, "watched.txt")
|
|
282
|
+
await fs.writeFile(watched, "one", "utf8")
|
|
283
|
+
await h.command("loop", "0s --watch watched.txt --max-runs 1 react to watched file")
|
|
284
|
+
await h.idle()
|
|
285
|
+
await delay(1_400)
|
|
286
|
+
assert.equal(h.actionTexts().length, 0, "watch loop must not run until a watched file changes")
|
|
287
|
+
|
|
288
|
+
await fs.writeFile(watched, "two and changed size", "utf8")
|
|
289
|
+
await h.idle()
|
|
290
|
+
await delay(1_500)
|
|
291
|
+
assert.equal(h.actionTexts().length, 1, "watch loop must run after a watched file changes")
|
|
292
|
+
assert.match(h.actionTexts()[0], /react to watched file/)
|
|
293
|
+
const actionPrompt = h.records.prompts.find((item) => !item.noReply)
|
|
294
|
+
assert.equal(actionPrompt.agent, "build", "scheduled work must restore the normal coding agent")
|
|
295
|
+
assert.deepEqual(actionPrompt.model, { providerID: "test-provider", modelID: "test-model" })
|
|
296
|
+
} finally {
|
|
297
|
+
await h.cleanup()
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function testActionRoutingAndSafety() {
|
|
302
|
+
let h = await createHarness()
|
|
303
|
+
try {
|
|
304
|
+
await h.command("loop-shell", "0s --safe npm run format")
|
|
305
|
+
await h.command("loop-now", "shell")
|
|
306
|
+
await delay(25)
|
|
307
|
+
assert.equal(h.records.shells.length, 1)
|
|
308
|
+
assert.equal(h.records.shells[0].command, "npm run format", "safe mode must allow a harmless format script")
|
|
309
|
+
} finally {
|
|
310
|
+
await h.cleanup()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
h = await createHarness()
|
|
314
|
+
try {
|
|
315
|
+
await h.command("loop-shell", "0s --safe Remove-Item -Recurse ./important")
|
|
316
|
+
await h.command("loop-now", "shell")
|
|
317
|
+
await delay(25)
|
|
318
|
+
assert.equal(h.records.shells.length, 0)
|
|
319
|
+
const state = await h.readState()
|
|
320
|
+
assert.equal(state.jobs[0].paused, true, "a blocked synchronous action must pause instead of retrying forever")
|
|
321
|
+
assert.equal(state.jobs[0].failureCount, 1)
|
|
322
|
+
assert.equal(state.jobs[0].lastFailureReason, "safe_shell_blocked")
|
|
323
|
+
|
|
324
|
+
await h.command("loop-clear")
|
|
325
|
+
await h.command("loop-shell", "0s --safe rm -r -f ./important")
|
|
326
|
+
await h.command("loop-now", "shell")
|
|
327
|
+
const separateFlagsState = await h.readState()
|
|
328
|
+
assert.equal(h.records.shells.length, 0)
|
|
329
|
+
assert.equal(separateFlagsState.jobs[0].lastFailureReason, "safe_shell_blocked", "separate rm -r -f flags must be blocked")
|
|
330
|
+
} finally {
|
|
331
|
+
await h.cleanup()
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
h = await createHarness()
|
|
335
|
+
try {
|
|
336
|
+
await fs.writeFile(path.join(h.directory, "empty.md"), "fallback context", "utf8")
|
|
337
|
+
await h.command("loop-command", "0s --prompt-file empty.md")
|
|
338
|
+
await h.command("loop-now", "command")
|
|
339
|
+
const state = await h.readState()
|
|
340
|
+
assert.equal(state.jobs[0].paused, true)
|
|
341
|
+
assert.equal(state.jobs[0].lastFailureReason, "empty_command")
|
|
342
|
+
} finally {
|
|
343
|
+
await h.cleanup()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
h = await createHarness({ failCompact: true })
|
|
347
|
+
try {
|
|
348
|
+
await h.command("loop-compact", "0s")
|
|
349
|
+
await h.command("loop-now", "compact")
|
|
350
|
+
const state = await h.readState()
|
|
351
|
+
assert.equal(state.jobs[0].paused, true)
|
|
352
|
+
assert.equal(state.jobs[0].lastFailureReason, "compact_failed")
|
|
353
|
+
} finally {
|
|
354
|
+
await h.cleanup()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
h = await createHarness()
|
|
358
|
+
try {
|
|
359
|
+
await h.command("loop-command", "0s /custom-command alpha beta")
|
|
360
|
+
await h.command("loop-now", "command")
|
|
361
|
+
await delay(25)
|
|
362
|
+
assert.deepEqual(h.records.commands[0], {
|
|
363
|
+
command: "custom-command",
|
|
364
|
+
arguments: "alpha beta",
|
|
365
|
+
agent: "build",
|
|
366
|
+
model: "test-provider/test-model",
|
|
367
|
+
})
|
|
368
|
+
} finally {
|
|
369
|
+
await h.cleanup()
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function testStopsPreflightAndGoalLifecycle() {
|
|
374
|
+
let h = await createHarness()
|
|
375
|
+
try {
|
|
376
|
+
await h.command("loop", "5m --no-now --stop-file STOP continue")
|
|
377
|
+
await fs.writeFile(path.join(h.directory, "STOP"), "stop", "utf8")
|
|
378
|
+
await h.command("loop-now")
|
|
379
|
+
assert.equal((await h.readState()).jobs.length, 0)
|
|
380
|
+
} finally {
|
|
381
|
+
await h.cleanup()
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
h = await createHarness()
|
|
385
|
+
try {
|
|
386
|
+
await h.command("loop", "5m --no-now --preflight \"node -e process.exit(7)\" continue")
|
|
387
|
+
await h.command("loop-now")
|
|
388
|
+
const state = await h.readState()
|
|
389
|
+
assert.equal(state.jobs[0].paused, true)
|
|
390
|
+
assert.equal(state.jobs[0].failureCount, 1)
|
|
391
|
+
assert.match(state.jobs[0].lastPreflightFailure, /exit=7/)
|
|
392
|
+
} finally {
|
|
393
|
+
await h.cleanup()
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
h = await createHarness()
|
|
397
|
+
try {
|
|
398
|
+
await h.command("loop-goal", "Ship the feature --acceptance \"tests pass\" --max-turns 3")
|
|
399
|
+
await h.command("loop-now", "goal")
|
|
400
|
+
await delay(25)
|
|
401
|
+
assert.match(h.actionTexts()[0], /Goal objective:\nShip the feature/)
|
|
402
|
+
const progress = await h.hooks.tool.opencode_loop_goal_progress.execute(
|
|
403
|
+
{ summary: "Implemented the first part", next: "Run verification" },
|
|
404
|
+
{ directory: h.directory, sessionID: h.sessionID },
|
|
405
|
+
)
|
|
406
|
+
assert.equal(progress.title, "Goal progress")
|
|
407
|
+
let state = await h.readState()
|
|
408
|
+
assert.equal(state.jobs[0].goalProgress.length, 1)
|
|
409
|
+
const blocked = await h.hooks.tool.opencode_loop_goal_blocked.execute(
|
|
410
|
+
{ reason: "Credential is missing", needed: "Provide a test credential" },
|
|
411
|
+
{ directory: h.directory, sessionID: h.sessionID },
|
|
412
|
+
)
|
|
413
|
+
assert.equal(blocked.title, "Goal blocked")
|
|
414
|
+
state = await h.readState()
|
|
415
|
+
assert.equal(state.jobs[0].goalStatus, "blocked")
|
|
416
|
+
assert.equal(state.jobs[0].paused, true)
|
|
417
|
+
await h.command("loop-goal-resume")
|
|
418
|
+
state = await h.readState()
|
|
419
|
+
assert.equal(state.jobs[0].goalStatus, "active")
|
|
420
|
+
assert.equal(state.jobs[0].paused, false)
|
|
421
|
+
await h.command("loop-goal-clear")
|
|
422
|
+
assert.equal((await h.readState()).jobs.length, 0)
|
|
423
|
+
} finally {
|
|
424
|
+
await h.cleanup()
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function testInitializationDoesNotWaitForLocalApi() {
|
|
429
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-init-deadlock-"))
|
|
430
|
+
const never = new Promise(() => {})
|
|
431
|
+
const client = {
|
|
432
|
+
app: { log: async () => never },
|
|
433
|
+
tui: { showToast: async () => ({ data: true }) },
|
|
434
|
+
session: { list: async () => never },
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
const hooks = await Promise.race([
|
|
438
|
+
OpenCodeLoopPlugin({ client, directory }),
|
|
439
|
+
delay(500).then(() => { throw new Error("plugin initialization waited for the local OpenCode API") }),
|
|
440
|
+
])
|
|
441
|
+
assert.equal(typeof hooks.event, "function")
|
|
442
|
+
await hooks.dispose?.()
|
|
443
|
+
} finally {
|
|
444
|
+
await fs.rm(directory, { recursive: true, force: true })
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
await testParserAndPresets()
|
|
449
|
+
await testLifecycleAndCommandDedupe()
|
|
450
|
+
await testWatchScheduling()
|
|
451
|
+
await testActionRoutingAndSafety()
|
|
452
|
+
await testStopsPreflightAndGoalLifecycle()
|
|
453
|
+
await testInitializationDoesNotWaitForLocalApi()
|
|
454
|
+
|
|
455
|
+
console.log("OpenCode Loop comprehensive test passed")
|
package/scripts/install-node.mjs
CHANGED
|
@@ -8,7 +8,87 @@ const root = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
|
8
8
|
const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode")
|
|
9
9
|
const pluginDir = join(config, "plugins")
|
|
10
10
|
const commandDir = join(config, "commands")
|
|
11
|
+
const agentDir = join(config, "agents")
|
|
11
12
|
const packagePath = join(config, "package.json")
|
|
13
|
+
const packageName = "@bybrawe/opencode-loop"
|
|
14
|
+
|
|
15
|
+
function stripJsonComments(input) {
|
|
16
|
+
let output = ""
|
|
17
|
+
let quote = ""
|
|
18
|
+
let escaped = false
|
|
19
|
+
let lineComment = false
|
|
20
|
+
let blockComment = false
|
|
21
|
+
for (let index = 0; index < input.length; index++) {
|
|
22
|
+
const char = input[index]
|
|
23
|
+
const next = input[index + 1]
|
|
24
|
+
if (lineComment) {
|
|
25
|
+
if (char === "\n" || char === "\r") { lineComment = false; output += char }
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
if (blockComment) {
|
|
29
|
+
if (char === "*" && next === "/") { blockComment = false; index++ }
|
|
30
|
+
else if (char === "\n" || char === "\r") output += char
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (quote) {
|
|
34
|
+
output += char
|
|
35
|
+
if (escaped) escaped = false
|
|
36
|
+
else if (char === "\\") escaped = true
|
|
37
|
+
else if (char === quote) quote = ""
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
if (char === '"') { quote = char; output += char; continue }
|
|
41
|
+
if (char === "/" && next === "/") { lineComment = true; index++; continue }
|
|
42
|
+
if (char === "/" && next === "*") { blockComment = true; index++; continue }
|
|
43
|
+
output += char
|
|
44
|
+
}
|
|
45
|
+
return output
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stripTrailingCommas(input) {
|
|
49
|
+
let output = ""
|
|
50
|
+
let quote = ""
|
|
51
|
+
let escaped = false
|
|
52
|
+
for (let index = 0; index < input.length; index++) {
|
|
53
|
+
const char = input[index]
|
|
54
|
+
if (quote) {
|
|
55
|
+
output += char
|
|
56
|
+
if (escaped) escaped = false
|
|
57
|
+
else if (char === "\\") escaped = true
|
|
58
|
+
else if (char === quote) quote = ""
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (char === '"') { quote = char; output += char; continue }
|
|
62
|
+
if (char === ",") {
|
|
63
|
+
let lookahead = index + 1
|
|
64
|
+
while (/\s/.test(input[lookahead] || "")) lookahead++
|
|
65
|
+
if (input[lookahead] === "]" || input[lookahead] === "}") continue
|
|
66
|
+
}
|
|
67
|
+
output += char
|
|
68
|
+
}
|
|
69
|
+
return output
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseJsonc(input) {
|
|
73
|
+
return JSON.parse(stripTrailingCommas(stripJsonComments(input)))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPackageSpec(value) {
|
|
77
|
+
const spec = String(value || "").trim()
|
|
78
|
+
return spec === packageName || spec.startsWith(`${packageName}@`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function packagePluginConfigured() {
|
|
82
|
+
for (const name of ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]) {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = parseJsonc(await readFile(join(config, name), "utf8"))
|
|
85
|
+
if (Array.isArray(parsed?.plugin) && parsed.plugin.some(isPackageSpec)) return true
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error?.code !== "ENOENT") console.warn(`Could not inspect ${join(config, name)} for duplicate plugin entries: ${error.message}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
12
92
|
|
|
13
93
|
async function ensureDependency() {
|
|
14
94
|
let pkg = {}
|
|
@@ -31,9 +111,16 @@ async function ensureDependency() {
|
|
|
31
111
|
|
|
32
112
|
await mkdir(pluginDir, { recursive: true })
|
|
33
113
|
await mkdir(commandDir, { recursive: true })
|
|
34
|
-
await
|
|
35
|
-
|
|
36
|
-
|
|
114
|
+
await mkdir(agentDir, { recursive: true })
|
|
115
|
+
const useConfiguredPackage = await packagePluginConfigured()
|
|
116
|
+
if (useConfiguredPackage) {
|
|
117
|
+
await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
|
|
118
|
+
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
119
|
+
} else {
|
|
120
|
+
await ensureDependency()
|
|
121
|
+
await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
|
|
122
|
+
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
123
|
+
}
|
|
37
124
|
|
|
38
125
|
for (const name of await readdir(join(root, "commands"))) {
|
|
39
126
|
if (name.endsWith(".md")) {
|
|
@@ -41,5 +128,14 @@ for (const name of await readdir(join(root, "commands"))) {
|
|
|
41
128
|
}
|
|
42
129
|
}
|
|
43
130
|
|
|
44
|
-
|
|
131
|
+
for (const name of await readdir(join(root, "agents"))) {
|
|
132
|
+
if (name.endsWith(".md")) {
|
|
133
|
+
await copyFile(join(root, "agents", name), join(agentDir, name))
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (useConfiguredPackage) console.log(`OpenCode Loop is already configured as a package in ${config}; removed the duplicate local plugin copy.`)
|
|
138
|
+
else console.log(`Installed OpenCode Loop plugin to ${config}`)
|
|
139
|
+
console.log(`Installed ${packageName} commands to ${commandDir}`)
|
|
140
|
+
console.log(`Installed ${packageName} local command agent to ${agentDir}`)
|
|
45
141
|
console.log("Restart OpenCode, then run: /loop-help")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { spawn } from "node:child_process"
|
|
3
|
+
import { promises as fs } from "node:fs"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|
9
|
+
const installer = path.join(root, "scripts", "install-node.mjs")
|
|
10
|
+
const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-installer-"))
|
|
11
|
+
|
|
12
|
+
async function runInstaller(config) {
|
|
13
|
+
return await new Promise((resolve, reject) => {
|
|
14
|
+
const child = spawn(process.execPath, [installer], {
|
|
15
|
+
cwd: root,
|
|
16
|
+
env: { ...process.env, OPENCODE_CONFIG_DIR: config },
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
})
|
|
19
|
+
const stdout = []
|
|
20
|
+
const stderr = []
|
|
21
|
+
child.stdout.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
22
|
+
child.stderr.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
23
|
+
child.on("error", reject)
|
|
24
|
+
child.on("close", (code) => resolve({
|
|
25
|
+
code,
|
|
26
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
27
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
28
|
+
}))
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function commandCount(config) {
|
|
33
|
+
return (await fs.readdir(path.join(config, "commands"))).filter((name) => name.endsWith(".md")).length
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function exists(target) {
|
|
37
|
+
try { await fs.access(target); return true } catch { return false }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const local = path.join(temporaryRoot, "local")
|
|
42
|
+
const localResult = await runInstaller(local)
|
|
43
|
+
assert.equal(localResult.code, 0, localResult.stderr)
|
|
44
|
+
assert.equal(await exists(path.join(local, "plugins", "opencode-loop.ts")), true)
|
|
45
|
+
assert.equal(await commandCount(local), 30)
|
|
46
|
+
assert.equal(await exists(path.join(local, "agents", "opencode-loop-local.md")), true)
|
|
47
|
+
const localPackage = JSON.parse(await fs.readFile(path.join(local, "package.json"), "utf8"))
|
|
48
|
+
assert.equal(localPackage.dependencies["@opencode-ai/plugin"], ">=1.4.0")
|
|
49
|
+
|
|
50
|
+
const configured = path.join(temporaryRoot, "configured")
|
|
51
|
+
await fs.mkdir(path.join(configured, "plugins"), { recursive: true })
|
|
52
|
+
await fs.writeFile(path.join(configured, "opencode.json"), JSON.stringify({ plugin: ["@bybrawe/opencode-loop@latest"] }), "utf8")
|
|
53
|
+
await fs.writeFile(path.join(configured, "plugins", "opencode-loop.ts"), "duplicate", "utf8")
|
|
54
|
+
await fs.writeFile(path.join(configured, "plugins", "opencode-loop.js"), "legacy duplicate", "utf8")
|
|
55
|
+
const configuredResult = await runInstaller(configured)
|
|
56
|
+
assert.equal(configuredResult.code, 0, configuredResult.stderr)
|
|
57
|
+
assert.match(configuredResult.stdout, /removed the duplicate local plugin copy/i)
|
|
58
|
+
assert.equal(await exists(path.join(configured, "plugins", "opencode-loop.ts")), false)
|
|
59
|
+
assert.equal(await exists(path.join(configured, "plugins", "opencode-loop.js")), false)
|
|
60
|
+
assert.equal(await commandCount(configured), 30)
|
|
61
|
+
assert.equal(await exists(path.join(configured, "agents", "opencode-loop-local.md")), true)
|
|
62
|
+
|
|
63
|
+
const jsonc = path.join(temporaryRoot, "jsonc")
|
|
64
|
+
await fs.mkdir(jsonc, { recursive: true })
|
|
65
|
+
await fs.writeFile(path.join(jsonc, "opencode.jsonc"), `{
|
|
66
|
+
// A configured package is authoritative; a local copy would load twice.
|
|
67
|
+
"plugin": [
|
|
68
|
+
"other-plugin",
|
|
69
|
+
"@bybrawe/opencode-loop",
|
|
70
|
+
],
|
|
71
|
+
}`, "utf8")
|
|
72
|
+
const jsoncResult = await runInstaller(jsonc)
|
|
73
|
+
assert.equal(jsoncResult.code, 0, jsoncResult.stderr)
|
|
74
|
+
assert.equal(await exists(path.join(jsonc, "plugins", "opencode-loop.ts")), false)
|
|
75
|
+
|
|
76
|
+
const lookalike = path.join(temporaryRoot, "lookalike")
|
|
77
|
+
await fs.mkdir(lookalike, { recursive: true })
|
|
78
|
+
await fs.writeFile(path.join(lookalike, "opencode.json"), JSON.stringify({ plugin: ["@bybrawe/opencode-loop-extra"] }), "utf8")
|
|
79
|
+
const lookalikeResult = await runInstaller(lookalike)
|
|
80
|
+
assert.equal(lookalikeResult.code, 0, lookalikeResult.stderr)
|
|
81
|
+
assert.equal(await exists(path.join(lookalike, "plugins", "opencode-loop.ts")), true)
|
|
82
|
+
|
|
83
|
+
console.log("OpenCode Loop installer test passed")
|
|
84
|
+
} finally {
|
|
85
|
+
await fs.rm(temporaryRoot, { recursive: true, force: true })
|
|
86
|
+
}
|