@bybrawe/opencode-loop 0.5.20 → 0.5.22
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 +233 -216
- package/README.md +1235 -1235
- package/package.json +69 -69
- package/scripts/comprehensive-test.mjs +573 -570
- package/scripts/smoke-test.mjs +3 -1
- package/src/index.js +2328 -2330
|
@@ -1,570 +1,573 @@
|
|
|
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
|
-
stateFile,
|
|
110
|
-
statuses,
|
|
111
|
-
async command(command, argumentsText = "", output = { parts: [] }) {
|
|
112
|
-
await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)
|
|
113
|
-
return output
|
|
114
|
-
},
|
|
115
|
-
async commandEvent(command, argumentsText = "", messageID = `msg_${Date.now()}_${Math.random()}`) {
|
|
116
|
-
await hooks.event({ event: { type: "command.executed", properties: { name: command, sessionID, arguments: argumentsText, messageID } } })
|
|
117
|
-
},
|
|
118
|
-
async idle() {
|
|
119
|
-
statuses.set(sessionID, "idle")
|
|
120
|
-
await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
|
|
121
|
-
},
|
|
122
|
-
async readState() {
|
|
123
|
-
try {
|
|
124
|
-
return JSON.parse(await fs.readFile(stateFile, "utf8"))
|
|
125
|
-
} catch (error) {
|
|
126
|
-
if (error?.code === "ENOENT") return { version: 4, jobs: [] }
|
|
127
|
-
throw error
|
|
128
|
-
}
|
|
129
|
-
},
|
|
130
|
-
reportTexts() {
|
|
131
|
-
return records.prompts.filter((item) => item.noReply).map((item) => item.text)
|
|
132
|
-
},
|
|
133
|
-
actionTexts() {
|
|
134
|
-
return records.prompts.filter((item) => !item.noReply).map((item) => item.text)
|
|
135
|
-
},
|
|
136
|
-
async cleanup() {
|
|
137
|
-
await hooks.dispose?.()
|
|
138
|
-
await fs.rm(directory, { recursive: true, force: true })
|
|
139
|
-
},
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function jsonReport(harness) {
|
|
144
|
-
const report = [...harness.reportTexts()].reverse().find((text) => text.includes("```json"))
|
|
145
|
-
assert.ok(report, "expected a JSON report")
|
|
146
|
-
const match = report.match(/```json\s*([\s\S]*?)\s*```/)
|
|
147
|
-
assert.ok(match, "expected a fenced JSON object")
|
|
148
|
-
return JSON.parse(match[1])
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
async function testParserAndPresets() {
|
|
152
|
-
const h = await createHarness()
|
|
153
|
-
try {
|
|
154
|
-
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")
|
|
155
|
-
const job = jsonReport(h)
|
|
156
|
-
assert.equal(job.intervalMs, 180_000)
|
|
157
|
-
assert.equal(job.name, "matrix")
|
|
158
|
-
assert.equal(job.action, "perform matrix work")
|
|
159
|
-
assert.equal(job.kind, "prompt")
|
|
160
|
-
assert.equal(job.immediate, false)
|
|
161
|
-
assert.equal(job.noOverlap, false)
|
|
162
|
-
assert.equal(job.safe, true)
|
|
163
|
-
assert.equal(job.quiet, true)
|
|
164
|
-
assert.equal(job.askNever, true)
|
|
165
|
-
assert.equal(job.gitCheckpoint, true)
|
|
166
|
-
assert.equal(job.checkpointOnly, true)
|
|
167
|
-
assert.equal(job.pauseOnVerifyFail, true)
|
|
168
|
-
assert.equal(job.multi, true)
|
|
169
|
-
assert.equal(job.maxRuns, 4)
|
|
170
|
-
assert.equal(job.maxNoProgress, 2)
|
|
171
|
-
assert.equal(job.timeoutMs, 7_000)
|
|
172
|
-
assert.equal(job.maxRuntimeMs, 7_200_000)
|
|
173
|
-
assert.equal(job.maxFailures, 3)
|
|
174
|
-
assert.equal(job.stopFile, "stop.txt")
|
|
175
|
-
assert.equal(job.testCommand, "npm test")
|
|
176
|
-
assert.equal(job.verifyCommand, "npm run verify")
|
|
177
|
-
assert.equal(job.preflightCommand, "npm run preflight")
|
|
178
|
-
assert.equal(job.postrunCommand, "npm run postrun")
|
|
179
|
-
assert.equal(job.notifyCommand, "node notify.js")
|
|
180
|
-
assert.equal(job.branch, "feature/test")
|
|
181
|
-
assert.equal(job.batch, 2)
|
|
182
|
-
assert.equal(job.compactEveryRuns, 5)
|
|
183
|
-
assert.deepEqual(job.watchPaths, ["one.txt", "two.txt"])
|
|
184
|
-
assert.deepEqual(job.includeFiles, ["context.md"])
|
|
185
|
-
assert.deepEqual(job.goalAcceptance, ["criterion A", "criterion B"])
|
|
186
|
-
assert.deepEqual(job.goalChecks, ["npm test"])
|
|
187
|
-
|
|
188
|
-
await h.command("loop-compact", "0s --dry-run")
|
|
189
|
-
let preset = jsonReport(h)
|
|
190
|
-
assert.equal(preset.intervalMs, 0, "explicit 0s must not become the 200m default")
|
|
191
|
-
assert.equal(preset.action, "/compact")
|
|
192
|
-
assert.equal(preset.kind, "compact")
|
|
193
|
-
|
|
194
|
-
await h.command("loop-compact", "--dry-run")
|
|
195
|
-
preset = jsonReport(h)
|
|
196
|
-
assert.equal(preset.intervalMs, 12_000_000)
|
|
197
|
-
assert.equal(preset.action, "/compact", "a flag-only preset must keep its real default action")
|
|
198
|
-
|
|
199
|
-
await h.command("loop-safe-dev", "--dry-run --safe")
|
|
200
|
-
preset = jsonReport(h)
|
|
201
|
-
assert.match(preset.action, /Develop the project/)
|
|
202
|
-
assert.notEqual(preset.action, "--safe")
|
|
203
|
-
|
|
204
|
-
await h.command("loop-testfix", "5m pnpm test --dry-run")
|
|
205
|
-
preset = jsonReport(h)
|
|
206
|
-
assert.equal(preset.intervalMs, 300_000)
|
|
207
|
-
assert.equal(preset.verifyCommand, "pnpm test")
|
|
208
|
-
assert.match(preset.action, /Test command hint: pnpm test/)
|
|
209
|
-
|
|
210
|
-
await h.command("loop-testfix", "--verify \"npm run ci\" --dry-run")
|
|
211
|
-
preset = jsonReport(h)
|
|
212
|
-
assert.equal(preset.verifyCommand, "npm run ci")
|
|
213
|
-
assert.match(preset.action, /Test command hint: npm run ci/)
|
|
214
|
-
|
|
215
|
-
const toastCount = h.records.toasts.length
|
|
216
|
-
await h.command("loop", "nonsense")
|
|
217
|
-
await h.command("loop", "5m")
|
|
218
|
-
assert.equal(h.records.toasts.length, toastCount + 2)
|
|
219
|
-
assert.ok(h.records.toasts.slice(-2).every((item) => item.variant === "warning"))
|
|
220
|
-
} finally {
|
|
221
|
-
await h.cleanup()
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async function testLifecycleAndCommandDedupe() {
|
|
226
|
-
const h = await createHarness()
|
|
227
|
-
try {
|
|
228
|
-
await h.command("loop", "10m --no-now --name same first action")
|
|
229
|
-
await h.command("loop", "10m --no-now --name same replacement action")
|
|
230
|
-
let state = await h.readState()
|
|
231
|
-
assert.equal(state.jobs.length, 1)
|
|
232
|
-
assert.equal(state.jobs[0].action, "replacement action")
|
|
233
|
-
|
|
234
|
-
await h.command("loop", "10m --no-now --multi --name extra additional action")
|
|
235
|
-
state = await h.readState()
|
|
236
|
-
assert.equal(state.jobs.length, 2)
|
|
237
|
-
|
|
238
|
-
await h.command("loop-pause", "same")
|
|
239
|
-
state = await h.readState()
|
|
240
|
-
assert.equal(state.jobs.find((item) => item.name === "same").paused, true)
|
|
241
|
-
await h.command("loop-resume", "same")
|
|
242
|
-
state = await h.readState()
|
|
243
|
-
assert.equal(state.jobs.find((item) => item.name === "same").paused, false)
|
|
244
|
-
|
|
245
|
-
const beforeReports = h.reportTexts().length
|
|
246
|
-
|
|
247
|
-
await h.
|
|
248
|
-
assert.equal(
|
|
249
|
-
|
|
250
|
-
await h.commandEvent("loop-status", "", "
|
|
251
|
-
assert.equal(h.reportTexts().length, beforeReports +
|
|
252
|
-
await h.
|
|
253
|
-
await h.commandEvent("loop-status", "", "
|
|
254
|
-
assert.equal(h.reportTexts().length, beforeReports +
|
|
255
|
-
|
|
256
|
-
await h.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
await h.command("loop-
|
|
260
|
-
state = await h.readState()
|
|
261
|
-
assert.
|
|
262
|
-
|
|
263
|
-
await h.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
await
|
|
267
|
-
|
|
268
|
-
assert.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
assert.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
await
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
await
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
assert.equal(
|
|
296
|
-
assert.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
assert.equal(
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
await h.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
assert.equal(
|
|
418
|
-
await h.
|
|
419
|
-
state
|
|
420
|
-
assert.equal(state.jobs[0].
|
|
421
|
-
|
|
422
|
-
await h.
|
|
423
|
-
assert.equal(
|
|
424
|
-
|
|
425
|
-
await h.
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
await h.hooks.event({ event: synthetic })
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
await h.
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
await
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
await h.
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
await
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
await
|
|
563
|
-
await
|
|
564
|
-
await
|
|
565
|
-
await
|
|
566
|
-
await
|
|
567
|
-
await
|
|
568
|
-
await
|
|
569
|
-
|
|
570
|
-
|
|
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
|
+
stateFile,
|
|
110
|
+
statuses,
|
|
111
|
+
async command(command, argumentsText = "", output = { parts: [] }) {
|
|
112
|
+
await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)
|
|
113
|
+
return output
|
|
114
|
+
},
|
|
115
|
+
async commandEvent(command, argumentsText = "", messageID = `msg_${Date.now()}_${Math.random()}`) {
|
|
116
|
+
await hooks.event({ event: { type: "command.executed", properties: { name: command, sessionID, arguments: argumentsText, messageID } } })
|
|
117
|
+
},
|
|
118
|
+
async idle() {
|
|
119
|
+
statuses.set(sessionID, "idle")
|
|
120
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
|
|
121
|
+
},
|
|
122
|
+
async readState() {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(await fs.readFile(stateFile, "utf8"))
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error?.code === "ENOENT") return { version: 4, jobs: [] }
|
|
127
|
+
throw error
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
reportTexts() {
|
|
131
|
+
return records.prompts.filter((item) => item.noReply).map((item) => item.text)
|
|
132
|
+
},
|
|
133
|
+
actionTexts() {
|
|
134
|
+
return records.prompts.filter((item) => !item.noReply).map((item) => item.text)
|
|
135
|
+
},
|
|
136
|
+
async cleanup() {
|
|
137
|
+
await hooks.dispose?.()
|
|
138
|
+
await fs.rm(directory, { recursive: true, force: true })
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function jsonReport(harness) {
|
|
144
|
+
const report = [...harness.reportTexts()].reverse().find((text) => text.includes("```json"))
|
|
145
|
+
assert.ok(report, "expected a JSON report")
|
|
146
|
+
const match = report.match(/```json\s*([\s\S]*?)\s*```/)
|
|
147
|
+
assert.ok(match, "expected a fenced JSON object")
|
|
148
|
+
return JSON.parse(match[1])
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function testParserAndPresets() {
|
|
152
|
+
const h = await createHarness()
|
|
153
|
+
try {
|
|
154
|
+
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")
|
|
155
|
+
const job = jsonReport(h)
|
|
156
|
+
assert.equal(job.intervalMs, 180_000)
|
|
157
|
+
assert.equal(job.name, "matrix")
|
|
158
|
+
assert.equal(job.action, "perform matrix work")
|
|
159
|
+
assert.equal(job.kind, "prompt")
|
|
160
|
+
assert.equal(job.immediate, false)
|
|
161
|
+
assert.equal(job.noOverlap, false)
|
|
162
|
+
assert.equal(job.safe, true)
|
|
163
|
+
assert.equal(job.quiet, true)
|
|
164
|
+
assert.equal(job.askNever, true)
|
|
165
|
+
assert.equal(job.gitCheckpoint, true)
|
|
166
|
+
assert.equal(job.checkpointOnly, true)
|
|
167
|
+
assert.equal(job.pauseOnVerifyFail, true)
|
|
168
|
+
assert.equal(job.multi, true)
|
|
169
|
+
assert.equal(job.maxRuns, 4)
|
|
170
|
+
assert.equal(job.maxNoProgress, 2)
|
|
171
|
+
assert.equal(job.timeoutMs, 7_000)
|
|
172
|
+
assert.equal(job.maxRuntimeMs, 7_200_000)
|
|
173
|
+
assert.equal(job.maxFailures, 3)
|
|
174
|
+
assert.equal(job.stopFile, "stop.txt")
|
|
175
|
+
assert.equal(job.testCommand, "npm test")
|
|
176
|
+
assert.equal(job.verifyCommand, "npm run verify")
|
|
177
|
+
assert.equal(job.preflightCommand, "npm run preflight")
|
|
178
|
+
assert.equal(job.postrunCommand, "npm run postrun")
|
|
179
|
+
assert.equal(job.notifyCommand, "node notify.js")
|
|
180
|
+
assert.equal(job.branch, "feature/test")
|
|
181
|
+
assert.equal(job.batch, 2)
|
|
182
|
+
assert.equal(job.compactEveryRuns, 5)
|
|
183
|
+
assert.deepEqual(job.watchPaths, ["one.txt", "two.txt"])
|
|
184
|
+
assert.deepEqual(job.includeFiles, ["context.md"])
|
|
185
|
+
assert.deepEqual(job.goalAcceptance, ["criterion A", "criterion B"])
|
|
186
|
+
assert.deepEqual(job.goalChecks, ["npm test"])
|
|
187
|
+
|
|
188
|
+
await h.command("loop-compact", "0s --dry-run")
|
|
189
|
+
let preset = jsonReport(h)
|
|
190
|
+
assert.equal(preset.intervalMs, 0, "explicit 0s must not become the 200m default")
|
|
191
|
+
assert.equal(preset.action, "/compact")
|
|
192
|
+
assert.equal(preset.kind, "compact")
|
|
193
|
+
|
|
194
|
+
await h.command("loop-compact", "--dry-run")
|
|
195
|
+
preset = jsonReport(h)
|
|
196
|
+
assert.equal(preset.intervalMs, 12_000_000)
|
|
197
|
+
assert.equal(preset.action, "/compact", "a flag-only preset must keep its real default action")
|
|
198
|
+
|
|
199
|
+
await h.command("loop-safe-dev", "--dry-run --safe")
|
|
200
|
+
preset = jsonReport(h)
|
|
201
|
+
assert.match(preset.action, /Develop the project/)
|
|
202
|
+
assert.notEqual(preset.action, "--safe")
|
|
203
|
+
|
|
204
|
+
await h.command("loop-testfix", "5m pnpm test --dry-run")
|
|
205
|
+
preset = jsonReport(h)
|
|
206
|
+
assert.equal(preset.intervalMs, 300_000)
|
|
207
|
+
assert.equal(preset.verifyCommand, "pnpm test")
|
|
208
|
+
assert.match(preset.action, /Test command hint: pnpm test/)
|
|
209
|
+
|
|
210
|
+
await h.command("loop-testfix", "--verify \"npm run ci\" --dry-run")
|
|
211
|
+
preset = jsonReport(h)
|
|
212
|
+
assert.equal(preset.verifyCommand, "npm run ci")
|
|
213
|
+
assert.match(preset.action, /Test command hint: npm run ci/)
|
|
214
|
+
|
|
215
|
+
const toastCount = h.records.toasts.length
|
|
216
|
+
await h.command("loop", "nonsense")
|
|
217
|
+
await h.command("loop", "5m")
|
|
218
|
+
assert.equal(h.records.toasts.length, toastCount + 2)
|
|
219
|
+
assert.ok(h.records.toasts.slice(-2).every((item) => item.variant === "warning"))
|
|
220
|
+
} finally {
|
|
221
|
+
await h.cleanup()
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function testLifecycleAndCommandDedupe() {
|
|
226
|
+
const h = await createHarness()
|
|
227
|
+
try {
|
|
228
|
+
await h.command("loop", "10m --no-now --name same first action")
|
|
229
|
+
await h.command("loop", "10m --no-now --name same replacement action")
|
|
230
|
+
let state = await h.readState()
|
|
231
|
+
assert.equal(state.jobs.length, 1)
|
|
232
|
+
assert.equal(state.jobs[0].action, "replacement action")
|
|
233
|
+
|
|
234
|
+
await h.command("loop", "10m --no-now --multi --name extra additional action")
|
|
235
|
+
state = await h.readState()
|
|
236
|
+
assert.equal(state.jobs.length, 2)
|
|
237
|
+
|
|
238
|
+
await h.command("loop-pause", "same")
|
|
239
|
+
state = await h.readState()
|
|
240
|
+
assert.equal(state.jobs.find((item) => item.name === "same").paused, true)
|
|
241
|
+
await h.command("loop-resume", "same")
|
|
242
|
+
state = await h.readState()
|
|
243
|
+
assert.equal(state.jobs.find((item) => item.name === "same").paused, false)
|
|
244
|
+
|
|
245
|
+
const beforeReports = h.reportTexts().length
|
|
246
|
+
const statusOutput = { parts: [{ type: "text", text: "OpenCode Loop status command handled locally. Reply exactly: OK." }] }
|
|
247
|
+
await h.command("loop-status", "", statusOutput)
|
|
248
|
+
assert.equal(statusOutput.parts.length, 1, "handled commands must keep a valid acknowledgement prompt")
|
|
249
|
+
assert.match(statusOutput.parts[0].text, /Reply exactly: OK/)
|
|
250
|
+
await h.commandEvent("loop-status", "", "msg_status_1")
|
|
251
|
+
assert.equal(h.reportTexts().length, beforeReports + 1, "command.executed must not duplicate the before hook")
|
|
252
|
+
await h.command("loop-status")
|
|
253
|
+
await h.commandEvent("loop-status", "", "msg_status_2")
|
|
254
|
+
assert.equal(h.reportTexts().length, beforeReports + 2, "an intentional repeated command must not be swallowed")
|
|
255
|
+
await h.commandEvent("loop-status", "", "msg_event_only")
|
|
256
|
+
await h.commandEvent("loop-status", "", "msg_event_only")
|
|
257
|
+
assert.equal(h.reportTexts().length, beforeReports + 3, "the same event message must only be handled once")
|
|
258
|
+
|
|
259
|
+
await h.command("loop-stop", "same")
|
|
260
|
+
state = await h.readState()
|
|
261
|
+
assert.deepEqual(state.jobs.map((item) => item.name), ["extra"])
|
|
262
|
+
await h.command("loop-clear")
|
|
263
|
+
state = await h.readState()
|
|
264
|
+
assert.equal(state.jobs.length, 0)
|
|
265
|
+
|
|
266
|
+
await h.command("loop-init")
|
|
267
|
+
const progress = path.join(h.directory, "progress.md")
|
|
268
|
+
assert.match(await fs.readFile(progress, "utf8"), /Active TODO/)
|
|
269
|
+
await fs.writeFile(progress, "custom progress", "utf8")
|
|
270
|
+
await h.command("loop-init")
|
|
271
|
+
assert.equal(await fs.readFile(progress, "utf8"), "custom progress", "loop-init must not overwrite user progress")
|
|
272
|
+
|
|
273
|
+
for (const command of ["loop-help", "loop-doctor", "loop-export", "loop-logs"]) await h.command(command)
|
|
274
|
+
assert.ok(h.reportTexts().some((text) => text.includes("OpenCode Loop help")))
|
|
275
|
+
assert.ok(h.reportTexts().some((text) => text.includes("OpenCode Loop doctor")))
|
|
276
|
+
assert.ok(h.reportTexts().some((text) => text.includes("state export")))
|
|
277
|
+
} finally {
|
|
278
|
+
await h.cleanup()
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function testWatchScheduling() {
|
|
283
|
+
const h = await createHarness()
|
|
284
|
+
try {
|
|
285
|
+
const watched = path.join(h.directory, "watched.txt")
|
|
286
|
+
await fs.writeFile(watched, "one", "utf8")
|
|
287
|
+
await h.command("loop", "0s --watch watched.txt --max-runs 1 react to watched file")
|
|
288
|
+
await h.idle()
|
|
289
|
+
await delay(1_400)
|
|
290
|
+
assert.equal(h.actionTexts().length, 0, "watch loop must not run until a watched file changes")
|
|
291
|
+
|
|
292
|
+
await fs.writeFile(watched, "two and changed size", "utf8")
|
|
293
|
+
await h.idle()
|
|
294
|
+
await delay(1_500)
|
|
295
|
+
assert.equal(h.actionTexts().length, 1, "watch loop must run after a watched file changes")
|
|
296
|
+
assert.match(h.actionTexts()[0], /react to watched file/)
|
|
297
|
+
const actionPrompt = h.records.prompts.find((item) => !item.noReply)
|
|
298
|
+
assert.equal(actionPrompt.agent, "build", "scheduled work must restore the normal coding agent")
|
|
299
|
+
assert.deepEqual(actionPrompt.model, { providerID: "test-provider", modelID: "test-model" })
|
|
300
|
+
} finally {
|
|
301
|
+
await h.cleanup()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function testActionRoutingAndSafety() {
|
|
306
|
+
let h = await createHarness()
|
|
307
|
+
try {
|
|
308
|
+
await h.command("loop-shell", "0s --safe npm run format")
|
|
309
|
+
await h.command("loop-now", "shell")
|
|
310
|
+
await delay(25)
|
|
311
|
+
assert.equal(h.records.shells.length, 1)
|
|
312
|
+
assert.equal(h.records.shells[0].command, "npm run format", "safe mode must allow a harmless format script")
|
|
313
|
+
} finally {
|
|
314
|
+
await h.cleanup()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
h = await createHarness()
|
|
318
|
+
try {
|
|
319
|
+
await h.command("loop-shell", "0s --safe Remove-Item -Recurse ./important")
|
|
320
|
+
await h.command("loop-now", "shell")
|
|
321
|
+
await delay(25)
|
|
322
|
+
assert.equal(h.records.shells.length, 0)
|
|
323
|
+
const state = await h.readState()
|
|
324
|
+
assert.equal(state.jobs[0].paused, true, "a blocked synchronous action must pause instead of retrying forever")
|
|
325
|
+
assert.equal(state.jobs[0].failureCount, 1)
|
|
326
|
+
assert.equal(state.jobs[0].lastFailureReason, "safe_shell_blocked")
|
|
327
|
+
|
|
328
|
+
await h.command("loop-clear")
|
|
329
|
+
await h.command("loop-shell", "0s --safe rm -r -f ./important")
|
|
330
|
+
await h.command("loop-now", "shell")
|
|
331
|
+
const separateFlagsState = await h.readState()
|
|
332
|
+
assert.equal(h.records.shells.length, 0)
|
|
333
|
+
assert.equal(separateFlagsState.jobs[0].lastFailureReason, "safe_shell_blocked", "separate rm -r -f flags must be blocked")
|
|
334
|
+
} finally {
|
|
335
|
+
await h.cleanup()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
h = await createHarness()
|
|
339
|
+
try {
|
|
340
|
+
await fs.writeFile(path.join(h.directory, "empty.md"), "fallback context", "utf8")
|
|
341
|
+
await h.command("loop-command", "0s --prompt-file empty.md")
|
|
342
|
+
await h.command("loop-now", "command")
|
|
343
|
+
const state = await h.readState()
|
|
344
|
+
assert.equal(state.jobs[0].paused, true)
|
|
345
|
+
assert.equal(state.jobs[0].lastFailureReason, "empty_command")
|
|
346
|
+
} finally {
|
|
347
|
+
await h.cleanup()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
h = await createHarness({ failCompact: true })
|
|
351
|
+
try {
|
|
352
|
+
await h.command("loop-compact", "0s")
|
|
353
|
+
await h.command("loop-now", "compact")
|
|
354
|
+
const state = await h.readState()
|
|
355
|
+
assert.equal(state.jobs[0].paused, true)
|
|
356
|
+
assert.equal(state.jobs[0].lastFailureReason, "compact_failed")
|
|
357
|
+
} finally {
|
|
358
|
+
await h.cleanup()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
h = await createHarness()
|
|
362
|
+
try {
|
|
363
|
+
await h.command("loop-command", "0s /custom-command alpha beta")
|
|
364
|
+
await h.command("loop-now", "command")
|
|
365
|
+
await delay(25)
|
|
366
|
+
assert.deepEqual(h.records.commands[0], {
|
|
367
|
+
command: "custom-command",
|
|
368
|
+
arguments: "alpha beta",
|
|
369
|
+
agent: "build",
|
|
370
|
+
model: "test-provider/test-model",
|
|
371
|
+
})
|
|
372
|
+
} finally {
|
|
373
|
+
await h.cleanup()
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function testStopsPreflightAndGoalLifecycle() {
|
|
378
|
+
let h = await createHarness()
|
|
379
|
+
try {
|
|
380
|
+
await h.command("loop", "5m --no-now --stop-file STOP continue")
|
|
381
|
+
await fs.writeFile(path.join(h.directory, "STOP"), "stop", "utf8")
|
|
382
|
+
await h.command("loop-now")
|
|
383
|
+
assert.equal((await h.readState()).jobs.length, 0)
|
|
384
|
+
} finally {
|
|
385
|
+
await h.cleanup()
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
h = await createHarness()
|
|
389
|
+
try {
|
|
390
|
+
await h.command("loop", "5m --no-now --preflight \"node -e process.exitCode=7\" continue")
|
|
391
|
+
await h.command("loop-now")
|
|
392
|
+
const state = await h.readState()
|
|
393
|
+
assert.equal(state.jobs[0].paused, true)
|
|
394
|
+
assert.equal(state.jobs[0].failureCount, 1)
|
|
395
|
+
assert.match(state.jobs[0].lastPreflightFailure, /exit=7/)
|
|
396
|
+
} finally {
|
|
397
|
+
await h.cleanup()
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
h = await createHarness()
|
|
401
|
+
try {
|
|
402
|
+
await h.command("loop-goal", "Ship the feature --acceptance \"tests pass\" --max-turns 3")
|
|
403
|
+
await h.command("loop-now", "goal")
|
|
404
|
+
await delay(25)
|
|
405
|
+
assert.match(h.actionTexts()[0], /Goal objective:\nShip the feature/)
|
|
406
|
+
const progress = await h.hooks.tool.opencode_loop_goal_progress.execute(
|
|
407
|
+
{ summary: "Implemented the first part", next: "Run verification" },
|
|
408
|
+
{ directory: h.directory, sessionID: h.sessionID },
|
|
409
|
+
)
|
|
410
|
+
assert.equal(progress.title, "Goal progress")
|
|
411
|
+
let state = await h.readState()
|
|
412
|
+
assert.equal(state.jobs[0].goalProgress.length, 1)
|
|
413
|
+
const blocked = await h.hooks.tool.opencode_loop_goal_blocked.execute(
|
|
414
|
+
{ reason: "Credential is missing", needed: "Provide a test credential" },
|
|
415
|
+
{ directory: h.directory, sessionID: h.sessionID },
|
|
416
|
+
)
|
|
417
|
+
assert.equal(blocked.title, "Goal blocked")
|
|
418
|
+
state = await h.readState()
|
|
419
|
+
assert.equal(state.jobs[0].goalStatus, "blocked")
|
|
420
|
+
assert.equal(state.jobs[0].paused, true)
|
|
421
|
+
await h.command("loop-goal-resume")
|
|
422
|
+
state = await h.readState()
|
|
423
|
+
assert.equal(state.jobs[0].goalStatus, "active")
|
|
424
|
+
assert.equal(state.jobs[0].paused, false)
|
|
425
|
+
await h.command("loop-goal-clear")
|
|
426
|
+
assert.equal((await h.readState()).jobs.length, 0)
|
|
427
|
+
} finally {
|
|
428
|
+
await h.cleanup()
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt() {
|
|
433
|
+
const h = await createHarness()
|
|
434
|
+
const originalDateNow = Date.now
|
|
435
|
+
let fakeNow = originalDateNow()
|
|
436
|
+
Date.now = () => fakeNow
|
|
437
|
+
try {
|
|
438
|
+
await h.command("loop-goal", "--no-now Keep working until the objective is complete")
|
|
439
|
+
const synthetic = {
|
|
440
|
+
type: "message.updated",
|
|
441
|
+
properties: { info: { id: "msg_loop_owned", sessionID: h.sessionID, role: "user" } },
|
|
442
|
+
}
|
|
443
|
+
await h.hooks.event({ event: synthetic })
|
|
444
|
+
|
|
445
|
+
fakeNow += 60_000
|
|
446
|
+
await h.hooks.event({ event: synthetic })
|
|
447
|
+
let state = await h.readState()
|
|
448
|
+
assert.equal(state.jobs[0].paused, false, "a delayed update for the same loop-owned user message must remain ignored")
|
|
449
|
+
|
|
450
|
+
await h.hooks.event({ event: {
|
|
451
|
+
type: "message.updated",
|
|
452
|
+
properties: { info: { id: "msg_real_user", sessionID: h.sessionID, role: "user" } },
|
|
453
|
+
} })
|
|
454
|
+
state = await h.readState()
|
|
455
|
+
assert.equal(state.jobs[0].paused, true, "a distinct real user message must still pause an active goal")
|
|
456
|
+
} finally {
|
|
457
|
+
Date.now = originalDateNow
|
|
458
|
+
await h.cleanup()
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function testInitializationDoesNotWaitForLocalApi() {
|
|
463
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-init-deadlock-"))
|
|
464
|
+
const never = new Promise(() => {})
|
|
465
|
+
const client = {
|
|
466
|
+
app: { log: async () => never },
|
|
467
|
+
tui: { showToast: async () => ({ data: true }) },
|
|
468
|
+
session: { list: async () => never },
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
const hooks = await Promise.race([
|
|
472
|
+
OpenCodeLoopPlugin({ client, directory }),
|
|
473
|
+
delay(500).then(() => { throw new Error("plugin initialization waited for the local OpenCode API") }),
|
|
474
|
+
])
|
|
475
|
+
assert.equal(typeof hooks.event, "function")
|
|
476
|
+
await hooks.dispose?.()
|
|
477
|
+
} finally {
|
|
478
|
+
await fs.rm(directory, { recursive: true, force: true })
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function testWindowsSafeStatePersistence() {
|
|
483
|
+
const h = await createHarness()
|
|
484
|
+
try {
|
|
485
|
+
const stateDir = path.join(h.directory, ".opencode", "opencode-loop")
|
|
486
|
+
|
|
487
|
+
// Rapid replacements exercise atomic overwrite of an existing state file.
|
|
488
|
+
// On Windows this used to fail with EPERM when rename targeted a locked file
|
|
489
|
+
// next to a project-local *.tmp, leaving jobs empty and the heartbeat stuck.
|
|
490
|
+
for (let index = 0; index < 25; index++) {
|
|
491
|
+
await h.command("loop", `10m --no-now --name sticky action-${index}`)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const state = await h.readState()
|
|
495
|
+
assert.equal(state.jobs.length, 1)
|
|
496
|
+
assert.equal(state.jobs[0].name, "sticky")
|
|
497
|
+
assert.equal(state.jobs[0].action, "action-24")
|
|
498
|
+
|
|
499
|
+
// Temp payloads must live outside the project so OpenCode git snapshots and
|
|
500
|
+
// Windows file locks do not see opencode-loop/*.tmp pathspecs.
|
|
501
|
+
const leftovers = (await fs.readdir(stateDir)).filter((name) => name.endsWith(".tmp"))
|
|
502
|
+
assert.deepEqual(leftovers, [], "state writes must not leave project-local temp files")
|
|
503
|
+
|
|
504
|
+
await h.command("loop-clear")
|
|
505
|
+
const cleared = await h.readState()
|
|
506
|
+
assert.equal(cleared.jobs.length, 0)
|
|
507
|
+
} finally {
|
|
508
|
+
await h.cleanup()
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function testWindowsStateRenameRetriesBeforeFallback() {
|
|
513
|
+
const h = await createHarness()
|
|
514
|
+
const originalRename = fs.rename
|
|
515
|
+
const originalCopyFile = fs.copyFile
|
|
516
|
+
let renameAttempts = 0
|
|
517
|
+
let fallbackCopies = 0
|
|
518
|
+
fs.rename = async (source, target) => {
|
|
519
|
+
if (target === h.stateFile && renameAttempts++ < 2) {
|
|
520
|
+
const error = new Error("simulated Windows destination lock")
|
|
521
|
+
error.code = "EPERM"
|
|
522
|
+
throw error
|
|
523
|
+
}
|
|
524
|
+
return await originalRename(source, target)
|
|
525
|
+
}
|
|
526
|
+
fs.copyFile = async (source, target, ...rest) => {
|
|
527
|
+
if (target === h.stateFile) fallbackCopies++
|
|
528
|
+
return await originalCopyFile(source, target, ...rest)
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
await h.command("loop", "10m --no-now --name retry-safe keep-state")
|
|
532
|
+
const state = await h.readState()
|
|
533
|
+
assert.equal(state.jobs[0]?.name, "retry-safe")
|
|
534
|
+
assert.equal(renameAttempts, 3, "transient EPERM should retry rename")
|
|
535
|
+
assert.equal(fallbackCopies, 0, "successful rename retry should avoid non-atomic copy fallback")
|
|
536
|
+
} finally {
|
|
537
|
+
fs.rename = originalRename
|
|
538
|
+
fs.copyFile = originalCopyFile
|
|
539
|
+
await h.cleanup()
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function testStateReadRetriesTransientPartialJson() {
|
|
544
|
+
const h = await createHarness()
|
|
545
|
+
const originalReadFile = fs.readFile
|
|
546
|
+
try {
|
|
547
|
+
await h.command("loop", "10m --no-now --name sticky survive-partial-read")
|
|
548
|
+
let partialReads = 0
|
|
549
|
+
fs.readFile = async (filePath, ...rest) => {
|
|
550
|
+
if (filePath === h.stateFile && partialReads++ < 2) return "{"
|
|
551
|
+
return await originalReadFile(filePath, ...rest)
|
|
552
|
+
}
|
|
553
|
+
await h.command("loop-status")
|
|
554
|
+
assert.equal(partialReads, 3, "partial JSON should be retried before treating state as corrupt")
|
|
555
|
+
assert.equal(h.records.toasts.at(-1)?.message, "1 loop job(s).")
|
|
556
|
+
} finally {
|
|
557
|
+
fs.readFile = originalReadFile
|
|
558
|
+
await h.cleanup()
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
await testParserAndPresets()
|
|
563
|
+
await testLifecycleAndCommandDedupe()
|
|
564
|
+
await testWatchScheduling()
|
|
565
|
+
await testActionRoutingAndSafety()
|
|
566
|
+
await testStopsPreflightAndGoalLifecycle()
|
|
567
|
+
await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
|
|
568
|
+
await testInitializationDoesNotWaitForLocalApi()
|
|
569
|
+
await testWindowsSafeStatePersistence()
|
|
570
|
+
await testWindowsStateRenameRetriesBeforeFallback()
|
|
571
|
+
await testStateReadRetriesTransientPartialJson()
|
|
572
|
+
|
|
573
|
+
console.log("OpenCode Loop comprehensive test passed")
|