@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +11 -3
  8. package/scripts/executor-runtime-test.mjs +310 -0
  9. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  10. package/scripts/goal-command-handlers-test.mjs +236 -0
  11. package/scripts/goal-policy-test.mjs +143 -0
  12. package/scripts/goal-runtime-test.mjs +203 -0
  13. package/scripts/goal-steering-test.mjs +153 -0
  14. package/scripts/host-goal-steering-canary.mjs +443 -0
  15. package/scripts/host-loop-canary.mjs +54 -12
  16. package/scripts/job-workspace-test.mjs +164 -0
  17. package/scripts/loop-command-handlers-test.mjs +279 -0
  18. package/scripts/loop-registration-test.mjs +220 -0
  19. package/scripts/opencode2-loop-canary.mjs +433 -0
  20. package/scripts/scheduler-runtime-test.mjs +147 -0
  21. package/scripts/session-activity-test.mjs +95 -0
  22. package/scripts/session-status-test.mjs +220 -0
  23. package/scripts/v2-command-adapter-test.mjs +113 -0
  24. package/scripts/v2-command-runtime-test.mjs +98 -0
  25. package/scripts/v2-host-contract-test.mjs +27 -0
  26. package/scripts/v2-plugin-sentinel-test.mjs +57 -8
  27. package/scripts/v2-prompt-interval-test.mjs +171 -0
  28. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  29. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  30. package/src/index.js +2751 -1594
  31. package/src/source/legacy-v1.js +168 -1860
  32. package/src/source/opencode/command-router.js +103 -0
  33. package/src/source/opencode/commands.js +83 -0
  34. package/src/source/opencode/goal-commands.js +107 -0
  35. package/src/source/opencode/loop-commands.js +195 -0
  36. package/src/source/opencode/loop-registration.js +100 -0
  37. package/src/source/opencode/messages.js +55 -0
  38. package/src/source/opencode2/capabilities.js +3 -1
  39. package/src/source/opencode2/commands.js +60 -0
  40. package/src/source/opencode2/event-bridge.js +19 -1
  41. package/src/source/opencode2/events.js +64 -0
  42. package/src/source/opencode2/experimental.js +32 -1
  43. package/src/source/opencode2/host-contract.js +26 -0
  44. package/src/source/opencode2/prompt-runtime.js +381 -0
  45. package/src/source/opencode2/runtime-adapter.js +56 -0
  46. package/src/source/opencode2/status.js +40 -0
  47. package/src/source/runtime/compaction.js +122 -0
  48. package/src/source/runtime/executor.js +558 -0
  49. package/src/source/runtime/goal-policy.js +77 -0
  50. package/src/source/runtime/goal-runtime.js +231 -0
  51. package/src/source/runtime/goal-steering.js +198 -0
  52. package/src/source/runtime/job-workspace.js +148 -0
  53. package/src/source/runtime/scheduler.js +212 -0
  54. package/src/source/runtime/session-activity.js +112 -0
  55. package/src/source/runtime/session-status.js +197 -0
  56. package/scripts/host-bridge-parity-test.mjs +0 -22
@@ -0,0 +1,164 @@
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
+ import { stateDir } from "../src/source/core/state.js"
6
+ import { createJobWorkspaceRuntime, dangerousShell } from "../src/source/runtime/job-workspace.js"
7
+
8
+ assert.equal(dangerousShell("npm test"), false)
9
+ assert.equal(dangerousShell("git status"), false)
10
+ assert.equal(dangerousShell("git push origin main"), true)
11
+ assert.equal(dangerousShell("git reset --hard HEAD"), true)
12
+ assert.equal(dangerousShell("rm -rf build"), true)
13
+ assert.equal(dangerousShell("Remove-Item cache -Recurse -Force"), true)
14
+ assert.equal(dangerousShell("terraform destroy"), true)
15
+ assert.equal(dangerousShell("kubectl delete pod app"), true)
16
+ assert.equal(dangerousShell("deploy production"), true)
17
+ assert.throws(() => createJobWorkspaceRuntime({}), /toast/)
18
+
19
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-workspace-"))
20
+ try {
21
+ const processCalls = []
22
+ const processResults = []
23
+ const toasts = []
24
+ const logs = []
25
+ const goalPromptCalls = []
26
+
27
+ const runtime = createJobWorkspaceRuntime({
28
+ toast: async (...args) => { toasts.push(args) },
29
+ appendLoopLog: async (...args) => { logs.push(args) },
30
+ runProcess: async (...args) => {
31
+ processCalls.push(args)
32
+ return processResults.shift() || { code: 0, stdout: "", stderr: "" }
33
+ },
34
+ buildGoalPrompt: async (...args) => {
35
+ goalPromptCalls.push(args)
36
+ return "GOAL PROMPT"
37
+ },
38
+ })
39
+
40
+ await fs.writeFile(path.join(directory, "PROMPT.md"), "Extra instructions\n", "utf8")
41
+ await fs.writeFile(path.join(directory, "CONTEXT.md"), "Context contents\n", "utf8")
42
+
43
+ const prompt = await runtime.buildPrompt(directory, {
44
+ kind: "prompt",
45
+ action: "Implement the change",
46
+ promptFile: "PROMPT.md",
47
+ includeFiles: ["CONTEXT.md"],
48
+ })
49
+ assert.match(prompt, /Instructions from PROMPT\.md:\nExtra instructions/)
50
+ assert.match(prompt, /Implement the change/)
51
+ assert.match(prompt, /Context from CONTEXT\.md:\nContext contents/)
52
+ assert.equal(goalPromptCalls.length, 0)
53
+
54
+ const goalPrompt = await runtime.buildPrompt(directory, { kind: "goal", action: "Ship it" })
55
+ assert.equal(goalPrompt, "GOAL PROMPT")
56
+ assert.equal(goalPromptCalls.length, 1)
57
+ assert.equal(goalPromptCalls[0][0], directory)
58
+ assert.equal(goalPromptCalls[0][1].action, "Ship it")
59
+
60
+ const missingPrompt = await runtime.buildPrompt(directory, {
61
+ kind: "prompt",
62
+ action: "Fallback action",
63
+ promptFile: "MISSING.md",
64
+ })
65
+ assert.match(missingPrompt, /could not be read/)
66
+ assert.match(missingPrompt, /Fallback action/)
67
+
68
+ const noBranch = { id: "none" }
69
+ assert.equal(await runtime.ensureBranch(directory, noBranch, {}, "session"), noBranch)
70
+ assert.equal(processCalls.length, 0)
71
+
72
+ processResults.push({ code: 1, stdout: "", stderr: "not a repo" })
73
+ const nonRepo = { id: "nonrepo", branch: "feature/test" }
74
+ await runtime.ensureBranch(directory, nonRepo, {}, "session")
75
+ assert.equal(nonRepo.branchDone, true)
76
+ assert.equal(processCalls.at(-1)[0], "git")
77
+ assert.deepEqual(processCalls.at(-1)[1], ["rev-parse", "--is-inside-work-tree"])
78
+ assert.equal(toasts.length, 0)
79
+ assert.equal(logs.length, 0)
80
+
81
+ processResults.push(
82
+ { code: 0, stdout: "true", stderr: "" },
83
+ { code: 1, stdout: "", stderr: "missing" },
84
+ { code: 0, stdout: "", stderr: "" },
85
+ )
86
+ const branchJob = { id: "branch", branch: "feature/test" }
87
+ const client = { id: "client" }
88
+ await runtime.ensureBranch(directory, branchJob, client, "session")
89
+ assert.equal(branchJob.branchDone, true)
90
+ assert.deepEqual(processCalls.slice(-3).map((call) => call[1]), [
91
+ ["rev-parse", "--is-inside-work-tree"],
92
+ ["switch", "feature-test"],
93
+ ["switch", "-c", "feature-test"],
94
+ ])
95
+ assert.deepEqual(toasts.at(-1), [client, "Loop branch active: feature-test", "success"])
96
+ assert.deepEqual(logs.at(-1), [directory, "branch", { sessionID: "session", branch: "feature-test", code: 0 }])
97
+
98
+ const watched = path.join(directory, "watched.txt")
99
+ await fs.writeFile(watched, "one", "utf8")
100
+ const initialSnapshot = await runtime.snapshotPaths(directory, ["watched.txt", "missing.txt"])
101
+ assert.notEqual(initialSnapshot["watched.txt"], "missing")
102
+ assert.equal(initialSnapshot["missing.txt"], "missing")
103
+
104
+ const watchJob = { watchPaths: ["watched.txt"], watchSnapshot: initialSnapshot }
105
+ assert.equal(await runtime.watchChanged(directory, watchJob), false)
106
+ await new Promise((resolve) => setTimeout(resolve, 20))
107
+ await fs.writeFile(watched, "two-and-longer", "utf8")
108
+ assert.equal(await runtime.watchChanged(directory, watchJob), true)
109
+ assert.notEqual(watchJob.watchSnapshot["watched.txt"], initialSnapshot["watched.txt"])
110
+ assert.equal(await runtime.watchChanged(directory, { watchPaths: [] }), false)
111
+
112
+ assert.equal(await runtime.untilReached(directory, { until: "never" }), false)
113
+ await fs.writeFile(path.join(directory, "progress.md"), "status: DONE_MARKER\n", "utf8")
114
+ assert.equal(await runtime.untilReached(directory, { until: "DONE_MARKER" }), true)
115
+
116
+ await fs.mkdir(path.join(directory, "nested"), { recursive: true })
117
+ await fs.writeFile(path.join(directory, "nested", "state.yaml"), "value: NESTED_MARKER\n", "utf8")
118
+ assert.equal(await runtime.untilReached(directory, { until: "NESTED_MARKER" }), true)
119
+
120
+ await fs.mkdir(path.join(directory, "node_modules", "hidden"), { recursive: true })
121
+ await fs.writeFile(path.join(directory, "node_modules", "hidden", "secret.md"), "IGNORED_MARKER", "utf8")
122
+ assert.equal(await runtime.untilReached(directory, { until: "IGNORED_MARKER" }), false)
123
+ assert.equal(await runtime.untilReached(directory, {}), false)
124
+
125
+ const checkpointProcessStart = processCalls.length
126
+ processResults.push(
127
+ { code: 0, stdout: "true", stderr: "" },
128
+ { code: 0, stdout: " M changed.js\n", stderr: "" },
129
+ { code: 0, stdout: "diff-data", stderr: "" },
130
+ { code: 0, stdout: "staged-data", stderr: "" },
131
+ { code: 0, stdout: "", stderr: "" },
132
+ { code: 0, stdout: "", stderr: "" },
133
+ )
134
+ await runtime.createCheckpoint(directory, "session/checkpoint", { id: "job", name: "workspace", checkpointOnly: true, gitCheckpoint: true }, client)
135
+ const checkpointCalls = processCalls.slice(checkpointProcessStart)
136
+ assert.deepEqual(checkpointCalls.slice(0, 5).map((call) => call[1]), [
137
+ ["rev-parse", "--is-inside-work-tree"],
138
+ ["status", "--short"],
139
+ ["diff", "--binary"],
140
+ ["diff", "--cached", "--binary"],
141
+ ["add", "-A"],
142
+ ])
143
+ assert.deepEqual(checkpointCalls[5][1].slice(0, 2), ["commit", "-m"])
144
+ assert.match(checkpointCalls[5][1][2], /^chore: opencode loop checkpoint /)
145
+
146
+ const checkpointDir = path.join(stateDir(directory), "checkpoints", "session-checkpoint")
147
+ const checkpointFiles = await fs.readdir(checkpointDir)
148
+ const statusFile = checkpointFiles.find((file) => file.endsWith(".status.txt"))
149
+ const patchFile = checkpointFiles.find((file) => file.endsWith(".patch"))
150
+ assert.ok(statusFile)
151
+ assert.ok(patchFile)
152
+ assert.equal(await fs.readFile(path.join(checkpointDir, statusFile), "utf8"), " M changed.js\n")
153
+ assert.equal(await fs.readFile(path.join(checkpointDir, patchFile), "utf8"), "diff-data\nstaged-data")
154
+ assert.match(toasts.at(-1)[1], /^Loop checkpoint saved:/)
155
+ assert.equal(toasts.at(-1)[2], "success")
156
+
157
+ const beforeNoopCalls = processCalls.length
158
+ await runtime.createCheckpoint(directory, "session", { id: "none" }, client)
159
+ assert.equal(processCalls.length, beforeNoopCalls)
160
+
161
+ console.log("job workspace tests passed")
162
+ } finally {
163
+ await fs.rm(directory, { recursive: true, force: true })
164
+ }
@@ -0,0 +1,279 @@
1
+ import assert from "node:assert/strict"
2
+ import path from "node:path"
3
+ import { createLoopCommandHandlers } from "../src/source/opencode/loop-commands.js"
4
+
5
+ function loopJob(id, overrides = {}) {
6
+ return {
7
+ id,
8
+ name: id,
9
+ kind: "prompt",
10
+ intervalMs: 10_000,
11
+ lastRunAt: 5_000,
12
+ action: `Work on ${id}`,
13
+ paused: false,
14
+ enabled: true,
15
+ runCount: 0,
16
+ failureCount: 0,
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ function harness(initialStates = {}, overrides = {}) {
22
+ const states = new Map(Object.entries(initialStates).map(([key, value]) => [key, structuredClone(value)]))
23
+ const writes = []
24
+ const removed = []
25
+ const cleared = []
26
+ const cancelled = []
27
+ const watchdogs = []
28
+ const due = []
29
+ const forcedRuns = []
30
+ const toasts = []
31
+ const messages = []
32
+ const logs = []
33
+ const fileWrites = []
34
+ const existingPaths = new Set(overrides.existingPaths || [])
35
+
36
+ const handlers = createLoopCommandHandlers({
37
+ clearActiveRun: (sessionID) => cleared.push(sessionID),
38
+ cancelDueWork: (sessionID) => cancelled.push(sessionID),
39
+ stopWatchdog: (sessionID) => watchdogs.push(sessionID),
40
+ scheduleDueWork: async (...args) => { due.push(args) },
41
+ maybeRunDueJobs: async (...args) => { forcedRuns.push(args) },
42
+ toast: async (...args) => { toasts.push(args) },
43
+ say: async (...args) => { messages.push(args) },
44
+ now: overrides.now || (() => 10_000),
45
+ readState: async (_directory, sessionID) => structuredClone(states.get(sessionID) || { jobs: [] }),
46
+ writeState: async (_directory, sessionID, state) => {
47
+ const copy = structuredClone(state)
48
+ states.set(sessionID, copy)
49
+ writes.push({ sessionID, state: copy })
50
+ },
51
+ removeState: async (...args) => { removed.push(args); states.delete(args[1]) },
52
+ pathExists: async (target) => existingPaths.has(target),
53
+ appendLoopLog: async (...args) => { logs.push(args) },
54
+ readFile: overrides.readFile || (async () => { throw new Error("missing") }),
55
+ writeFile: async (...args) => { fileWrites.push(args) },
56
+ runtimeVersion: "v-test",
57
+ runtimePlatform: "test-platform",
58
+ })
59
+
60
+ return {
61
+ handlers,
62
+ states,
63
+ writes,
64
+ removed,
65
+ cleared,
66
+ cancelled,
67
+ watchdogs,
68
+ due,
69
+ forcedRuns,
70
+ toasts,
71
+ messages,
72
+ logs,
73
+ fileWrites,
74
+ }
75
+ }
76
+
77
+ assert.throws(() => createLoopCommandHandlers({}), /clearActiveRun/)
78
+ assert.throws(() => createLoopCommandHandlers({ clearActiveRun() {} }), /cancelDueWork/)
79
+
80
+ {
81
+ const sessionID = "stop-all"
82
+ const h = harness({ [sessionID]: { jobs: [loopJob("a")] } })
83
+ const client = {}
84
+ await h.handlers.stopLoop("/work", client, sessionID, "all")
85
+ assert.deepEqual(h.removed, [["/work", sessionID]])
86
+ assert.deepEqual(h.cleared, [sessionID])
87
+ assert.deepEqual(h.cancelled, [sessionID])
88
+ assert.deepEqual(h.watchdogs, [sessionID])
89
+ assert.equal(h.due.length, 0)
90
+ assert.deepEqual(h.toasts, [[client, "All loops stopped for this session.", "success"]])
91
+ }
92
+
93
+ {
94
+ const sessionID = "stop-empty"
95
+ const h = harness({ [sessionID]: { jobs: [loopJob("a")] } })
96
+ await h.handlers.stopLoop("/work", {}, sessionID, "")
97
+ assert.equal(h.removed.length, 1, "empty stop target keeps existing stop-all semantics")
98
+ }
99
+
100
+ {
101
+ const sessionID = "stop-one"
102
+ const client = {}
103
+ const h = harness({ [sessionID]: { jobs: [loopJob("a"), loopJob("b")] } })
104
+ await h.handlers.stopLoop("/work", client, sessionID, "a")
105
+ assert.deepEqual(h.states.get(sessionID).jobs.map((job) => job.id), ["b"])
106
+ assert.equal(h.writes.length, 1)
107
+ assert.deepEqual(h.due, [["/work", client, sessionID]])
108
+ assert.deepEqual(h.toasts, [[client, "Stopped 1 loop(s).", "success"]])
109
+ assert.equal(h.removed.length, 0)
110
+ }
111
+
112
+ {
113
+ const sessionID = "update"
114
+ const client = {}
115
+ const h = harness({ [sessionID]: { jobs: [loopJob("a"), loopJob("b")] } })
116
+ await h.handlers.updateJobState("/work", client, sessionID, "b", (job) => ({ ...job, paused: true, marker: "yes" }), "Paused")
117
+ const jobs = h.states.get(sessionID).jobs
118
+ assert.equal(jobs[0].paused, false)
119
+ assert.equal(jobs[1].paused, true)
120
+ assert.equal(jobs[1].marker, "yes")
121
+ assert.deepEqual(h.due, [["/work", client, sessionID]])
122
+ assert.deepEqual(h.toasts, [[client, "Paused: 1 loop(s).", "success"]])
123
+ }
124
+
125
+ {
126
+ const sessionID = "update-missing"
127
+ const client = {}
128
+ const h = harness({ [sessionID]: { jobs: [loopJob("a")] } })
129
+ await h.handlers.updateJobState("/work", client, sessionID, "missing", (job) => ({ ...job, paused: true }), "Paused")
130
+ assert.deepEqual(h.toasts, [[client, "Paused: 0 loop(s).", "warning"]])
131
+ assert.equal(h.due.length, 1, "state updates keep rescheduling even when no job matches")
132
+ }
133
+
134
+ {
135
+ const sessionID = "status"
136
+ const client = { id: "client" }
137
+ const h = harness({
138
+ [sessionID]: {
139
+ jobs: [
140
+ loopJob("dev", { safe: true, askNever: true, noOverlap: true, runCount: 3, failureCount: 1 }),
141
+ loopJob("goal", { kind: "goal", intervalMs: 0, lastRunAt: 0, goalStatus: "blocked", paused: true, checkpointOnly: true, gitCheckpoint: true }),
142
+ ],
143
+ },
144
+ })
145
+ await h.handlers.statusLoop("/work", client, sessionID)
146
+ assert.deepEqual(h.toasts, [[client, "2 loop job(s).", "info"]])
147
+ assert.equal(h.messages.length, 1)
148
+ const text = h.messages[0][2]
149
+ assert.match(text, /OpenCode loop status:/)
150
+ assert.match(text, /1\. dev \(dev\): dev: 10s \[prompt\] -> Work on dev/)
151
+ assert.match(text, /runs=3 \| failures=1 \| due in 5s/)
152
+ assert.match(text, /active,safe,ask-never,no-overlap/)
153
+ assert.match(text, /goal:blocked,paused,checkpoint-only,git-checkpoint/)
154
+ }
155
+
156
+ {
157
+ const sessionID = "status-no-now"
158
+ const h = harness({
159
+ [sessionID]: {
160
+ jobs: [loopJob("delayed", {
161
+ intervalMs: 10_000,
162
+ lastRunAt: 0,
163
+ immediate: false,
164
+ createdAt: new Date(5_000).toISOString(),
165
+ })],
166
+ },
167
+ }, { now: () => 10_000 })
168
+ await h.handlers.statusLoop("/work", {}, sessionID)
169
+ assert.match(h.messages[0][2], /due in 5s/, "--no-now status must count the first interval from createdAt")
170
+ }
171
+
172
+ {
173
+ const sessionID = "status-empty"
174
+ const h = harness({ [sessionID]: { jobs: [] } })
175
+ await h.handlers.statusLoop("/work", {}, sessionID)
176
+ assert.deepEqual(h.toasts, [[{}, "No active loop jobs.", "warning"]])
177
+ assert.match(h.messages[0][2], /No active loop jobs\./)
178
+ }
179
+
180
+ {
181
+ const lines = Array.from({ length: 82 }, (_, index) => `line-${index + 1}`).join("\n")
182
+ const h = harness({}, { readFile: async () => lines })
183
+ await h.handlers.logsLoop("/project", {}, "logs")
184
+ const text = h.messages[0][2]
185
+ assert.doesNotMatch(text, /line-1\n/)
186
+ assert.doesNotMatch(text, /line-2\n/)
187
+ assert.match(text, /line-3/)
188
+ assert.match(text, /line-82/)
189
+ }
190
+
191
+ {
192
+ const h = harness()
193
+ await h.handlers.logsLoop("/project", {}, "missing-logs")
194
+ assert.equal(h.messages[0][2], "OpenCode loop logs:\nNo loop log found.")
195
+ }
196
+
197
+ {
198
+ const h = harness()
199
+ await h.handlers.helpLoop({}, "help")
200
+ const text = h.messages[0][2]
201
+ assert.match(text, /OpenCode Loop help:/)
202
+ assert.match(text, /\/loop-goal finish the feature/)
203
+ assert.match(text, /\/loop-doctor \| \/loop-init \| \/loop-export/)
204
+ }
205
+
206
+ {
207
+ const sessionID = "now"
208
+ const client = {}
209
+ const h = harness({ [sessionID]: { jobs: [loopJob("a", { paused: true, lastRunAt: 999 }), loopJob("b", { paused: true, lastRunAt: 888 })] } })
210
+ await h.handlers.runNow("/work", client, sessionID, "b")
211
+ const jobs = h.states.get(sessionID).jobs
212
+ assert.equal(jobs[0].lastRunAt, 999)
213
+ assert.equal(jobs[0].paused, true)
214
+ assert.equal(jobs[0].runNowRequestedAt, undefined)
215
+ assert.equal(jobs[1].lastRunAt, 0)
216
+ assert.equal(jobs[1].paused, false)
217
+ assert.equal(jobs[1].runNowRequestedAt, 10_000)
218
+ assert.deepEqual(h.toasts, [[client, "Marked 1 loop job(s) due now.", "success"]])
219
+ assert.deepEqual(h.due, [["/work", client, sessionID]], "run-now must defer dispatch through the idle-safe scheduler")
220
+ assert.equal(h.forcedRuns.length, 0, "run-now must not start a model turn re-entrantly inside command.execute.before")
221
+ }
222
+
223
+ {
224
+ const sessionID = "now-missing"
225
+ const h = harness({ [sessionID]: { jobs: [loopJob("a")] } })
226
+ await h.handlers.runNow("/work", {}, sessionID, "missing")
227
+ assert.deepEqual(h.toasts, [[{}, "Marked 0 loop job(s) due now.", "warning"]])
228
+ assert.equal(h.due.length, 0, "missing run-now target must not schedule unrelated jobs")
229
+ assert.equal(h.forcedRuns.length, 0, "missing run-now target must not run unrelated due jobs")
230
+ }
231
+
232
+ {
233
+ const sessionID = "doctor"
234
+ const h = harness({ [sessionID]: { jobs: [loopJob("a"), loopJob("b")] } })
235
+ await h.handlers.doctorLoop("/repo", {}, sessionID)
236
+ const text = h.messages[0][2]
237
+ assert.match(text, /OpenCode Loop doctor:/)
238
+ assert.match(text, /- plugin: opencode-loop/)
239
+ assert.match(text, /- project directory: \/repo/)
240
+ assert.match(text, /- active jobs: 2/)
241
+ assert.match(text, /- node: v-test/)
242
+ assert.match(text, /- platform: test-platform/)
243
+ assert.match(text, /experimental goal smoke test/)
244
+ }
245
+
246
+ {
247
+ const sessionID = "init"
248
+ const client = {}
249
+ const h = harness()
250
+ await h.handlers.initLoop("/repo", client, sessionID, "notes.md")
251
+ assert.equal(h.fileWrites.length, 1)
252
+ assert.equal(h.fileWrites[0][0], path.resolve("/repo", "notes.md"))
253
+ assert.match(h.fileWrites[0][1], /^# Progress/)
254
+ assert.equal(h.fileWrites[0][2], "utf8")
255
+ assert.deepEqual(h.toasts, [[client, "Created notes.md.", "success"]])
256
+ assert.deepEqual(h.logs, [["/repo", "init", { sessionID, file: "notes.md" }]])
257
+ }
258
+
259
+ {
260
+ const target = path.resolve("/repo", "progress.md")
261
+ const client = {}
262
+ const h = harness({}, { existingPaths: [target] })
263
+ await h.handlers.initLoop("/repo", client, "init-existing", "")
264
+ assert.equal(h.fileWrites.length, 0)
265
+ assert.deepEqual(h.toasts, [[client, "progress.md already exists.", "warning"]])
266
+ assert.equal(h.logs.length, 0)
267
+ }
268
+
269
+ {
270
+ const sessionID = "export"
271
+ const h = harness({ [sessionID]: { version: 1, jobs: [loopJob("a")] } })
272
+ await h.handlers.exportLoop("/repo", {}, sessionID)
273
+ const text = h.messages[0][2]
274
+ assert.match(text, /OpenCode loop state export:/)
275
+ assert.match(text, /```json/)
276
+ assert.match(text, /"id": "a"/)
277
+ }
278
+
279
+ console.log("loop command handler tests passed")
@@ -0,0 +1,220 @@
1
+ import assert from "node:assert/strict"
2
+ import { createLoopRegistration, normalizeActionForCompare, sameLoopDefinition } from "../src/source/opencode/loop-registration.js"
3
+
4
+ function harness(initialStates = {}, overrides = {}) {
5
+ const states = new Map(Object.entries(initialStates).map(([key, value]) => [key, structuredClone(value)]))
6
+ const writes = []
7
+ const snapshots = []
8
+ const due = []
9
+ const idle = []
10
+ const toasts = []
11
+ const messages = []
12
+ const logs = []
13
+ const contexts = new Map(Object.entries(overrides.contexts || {}))
14
+
15
+ const { addLoop } = createLoopRegistration({
16
+ snapshotPaths: async (directory, files) => {
17
+ snapshots.push({ directory, files: [...files] })
18
+ return Object.fromEntries(files.map((file) => [file, `snapshot:${file}`]))
19
+ },
20
+ scheduleDueWork: async (...args) => { due.push(args) },
21
+ scheduleIdleWork: (...args) => { idle.push(args) },
22
+ toast: async (...args) => { toasts.push(args) },
23
+ say: async (...args) => { messages.push(args) },
24
+ readState: async (_directory, sessionID) => structuredClone(states.get(sessionID) || { jobs: [] }),
25
+ writeState: async (_directory, sessionID, state) => {
26
+ const copy = structuredClone(state)
27
+ states.set(sessionID, copy)
28
+ writes.push({ sessionID, state: copy })
29
+ },
30
+ appendLoopLog: async (...args) => { logs.push(args) },
31
+ normalizedModelRef: (value) => value ? `normalized:${value}` : undefined,
32
+ getSessionExecutionContext: (sessionID) => contexts.get(sessionID),
33
+ defaultActiveGuardMs: overrides.defaultActiveGuardMs ?? 45_000,
34
+ })
35
+
36
+ return { addLoop, states, writes, snapshots, due, idle, toasts, messages, logs }
37
+ }
38
+
39
+ assert.throws(() => createLoopRegistration({}), /snapshotPaths/)
40
+ assert.throws(() => createLoopRegistration({ snapshotPaths() {} }), /scheduleDueWork/)
41
+ assert.equal(normalizeActionForCompare(" hello\n world "), "hello world")
42
+ assert.equal(sameLoopDefinition(
43
+ { name: "dev", intervalMs: 1000, action: "hello world", kind: "prompt", promptFile: "" },
44
+ { name: "dev", intervalMs: 1000, action: " hello world ", kind: "prompt", promptFile: undefined },
45
+ ), true)
46
+ assert.equal(sameLoopDefinition({ name: "a" }, { name: "b" }), false)
47
+ assert.equal(sameLoopDefinition(null, {}), false)
48
+
49
+ {
50
+ const sessionID = "invalid"
51
+ const h = harness()
52
+ const client = {}
53
+ await h.addLoop("/work", client, sessionID, "not-a-duration and no defaults")
54
+ assert.equal(h.writes.length, 0)
55
+ assert.equal(h.due.length, 0)
56
+ assert.equal(h.idle.length, 0)
57
+ assert.equal(h.logs.length, 0)
58
+ assert.equal(h.toasts.length, 1)
59
+ assert.equal(h.toasts[0][0], client)
60
+ assert.equal(h.toasts[0][2], "warning")
61
+ assert.match(h.toasts[0][1], /Usage: \/loop/)
62
+ }
63
+
64
+ {
65
+ const sessionID = "basic"
66
+ const client = { id: "client" }
67
+ const h = harness({}, { contexts: { [sessionID]: { agent: "builder", model: "ctx-model" } } })
68
+ await h.addLoop("/work", client, sessionID, "0s do useful work")
69
+ const job = h.states.get(sessionID).jobs[0]
70
+ assert.equal(job.name, "default")
71
+ assert.equal(job.action, "do useful work")
72
+ assert.equal(job.agent, "builder")
73
+ assert.equal(job.model, "ctx-model")
74
+ assert.equal(job.activeRecoveryMs, 45_000)
75
+ assert.equal(job.immediate, true)
76
+ assert.equal(h.writes.length, 1)
77
+ assert.deepEqual(h.due, [["/work", client, sessionID]])
78
+ assert.deepEqual(h.idle, [["/work", client, sessionID]])
79
+ assert.equal(h.toasts[0][2], "success")
80
+ assert.match(h.toasts[0][1], /^Loop added:/)
81
+ assert.equal(h.logs[0][1], "add")
82
+ }
83
+
84
+ {
85
+ const sessionID = "replace"
86
+ const h = harness({}, { contexts: { [sessionID]: { agent: "build" } } })
87
+ await h.addLoop("/work", {}, sessionID, "0s first action")
88
+ await h.addLoop("/work", {}, sessionID, "0s second action")
89
+ const jobs = h.states.get(sessionID).jobs
90
+ assert.equal(jobs.length, 1)
91
+ assert.equal(jobs[0].name, "default")
92
+ assert.equal(jobs[0].action, "second action")
93
+ assert.match(h.toasts.at(-1)[1], /^Loop replaced:/)
94
+ assert.equal(h.logs.at(-1)[1], "replace")
95
+ }
96
+
97
+ {
98
+ const sessionID = "multi"
99
+ const h = harness({ [sessionID]: { jobs: [{ id: "old", name: "default", intervalMs: 0, action: "old" }] } })
100
+ await h.addLoop("/work", {}, sessionID, "0s --multi another action")
101
+ const jobs = h.states.get(sessionID).jobs
102
+ assert.equal(jobs.length, 2)
103
+ assert.equal(jobs[0].id, "old")
104
+ assert.equal(jobs[1].action, "another action")
105
+ assert.equal(jobs[1].multi, true)
106
+ assert.match(h.toasts.at(-1)[1], /^Loop added:/)
107
+ }
108
+
109
+ {
110
+ const sessionID = "watch"
111
+ const h = harness()
112
+ await h.addLoop("/repo", {}, sessionID, '0s --watch "src" --watch package.json inspect changes')
113
+ const job = h.states.get(sessionID).jobs[0]
114
+ assert.deepEqual(job.watchPaths, ["src", "package.json"])
115
+ assert.deepEqual(job.watchSnapshot, { src: "snapshot:src", "package.json": "snapshot:package.json" })
116
+ assert.deepEqual(h.snapshots, [{ directory: "/repo", files: ["src", "package.json"] }])
117
+ }
118
+
119
+ {
120
+ const sessionID = "goal"
121
+ const h = harness()
122
+ await h.addLoop("/work", {}, sessionID, "ship the feature", {
123
+ intervalMs: 0,
124
+ kind: "goal",
125
+ name: "goal",
126
+ immediate: true,
127
+ safe: true,
128
+ askNever: true,
129
+ noOverlap: true,
130
+ goalStatus: "active",
131
+ })
132
+ const job = h.states.get(sessionID).jobs[0]
133
+ assert.equal(job.kind, "goal")
134
+ assert.equal(job.goalStatus, "active")
135
+ assert.equal(job.safe, true)
136
+ assert.equal(job.askNever, true)
137
+ assert.equal(job.noOverlap, true)
138
+ assert.equal(job.agent, "build")
139
+ assert.equal(job.activeRecoveryMs, 180_000)
140
+ }
141
+
142
+ {
143
+ const sessionID = "long-interval"
144
+ const h = harness()
145
+ await h.addLoop("/work", {}, sessionID, "5m recurring work")
146
+ assert.equal(h.states.get(sessionID).jobs[0].activeRecoveryMs, 90_000)
147
+ }
148
+
149
+ {
150
+ const sessionID = "explicit-owner"
151
+ const h = harness({}, { contexts: { [sessionID]: { agent: "context-agent", model: "context-model" } } })
152
+ await h.addLoop("/work", {}, sessionID, "explicit work", { intervalMs: 0, agent: "special-agent", model: "model-x" })
153
+ const job = h.states.get(sessionID).jobs[0]
154
+ assert.equal(job.agent, "special-agent")
155
+ assert.equal(job.model, "normalized:model-x")
156
+ }
157
+
158
+ {
159
+ const sessionID = "testfix-action"
160
+ const defaults = {
161
+ intervalMs: 0,
162
+ name: "testfix",
163
+ safe: true,
164
+ askNever: true,
165
+ verifyCommand: "npm test",
166
+ testfixPreset: true,
167
+ action: "Run the project tests. Fix failures. Re-run the tests. Test command hint: npm test",
168
+ }
169
+ const h = harness()
170
+ await h.addLoop("/work", {}, sessionID, "pnpm test", defaults)
171
+ const job = h.states.get(sessionID).jobs[0]
172
+ assert.equal(job.verifyCommand, "pnpm test")
173
+ assert.equal(job.action, "Run the project tests. Fix failures. Re-run the tests. Test command hint: pnpm test")
174
+ }
175
+
176
+ {
177
+ const sessionID = "testfix-verify"
178
+ const defaults = {
179
+ intervalMs: 0,
180
+ name: "testfix",
181
+ safe: true,
182
+ askNever: true,
183
+ verifyCommand: "npm test",
184
+ testfixPreset: true,
185
+ action: "Run the project tests. Fix failures. Re-run the tests. Test command hint: npm test",
186
+ }
187
+ const h = harness()
188
+ await h.addLoop("/work", {}, sessionID, '--verify "pnpm test"', defaults)
189
+ const job = h.states.get(sessionID).jobs[0]
190
+ assert.equal(job.verifyCommand, "pnpm test")
191
+ assert.equal(job.action, "Run the project tests. Fix failures. Re-run the tests. Test command hint: pnpm test")
192
+ }
193
+
194
+ {
195
+ const sessionID = "dry-run"
196
+ const client = {}
197
+ const h = harness()
198
+ await h.addLoop("/work", client, sessionID, "0s --dry-run inspect only")
199
+ assert.equal(h.writes.length, 0)
200
+ assert.equal(h.due.length, 0)
201
+ assert.equal(h.idle.length, 0)
202
+ assert.equal(h.logs.length, 0)
203
+ assert.equal(h.toasts.length, 1)
204
+ assert.equal(h.toasts[0][2], "info")
205
+ assert.match(h.toasts[0][1], /^Loop dry run:/)
206
+ assert.equal(h.messages.length, 1)
207
+ assert.match(h.messages[0][2], /OpenCode loop dry run:/)
208
+ assert.match(h.messages[0][2], /"action": "inspect only"/)
209
+ }
210
+
211
+ {
212
+ const sessionID = "no-now"
213
+ const h = harness()
214
+ await h.addLoop("/work", {}, sessionID, "1m --no-now later")
215
+ assert.equal(h.states.get(sessionID).jobs[0].immediate, false)
216
+ assert.equal(h.idle.length, 0)
217
+ assert.equal(h.due.length, 1)
218
+ }
219
+
220
+ console.log("loop registration tests passed")