@bybrawe/opencode-loop 0.5.28 → 0.5.30

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 (59) hide show
  1. package/CHANGELOG.md +22 -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/publish-workflow-test.mjs +25 -0
  21. package/scripts/scheduler-runtime-test.mjs +147 -0
  22. package/scripts/session-activity-test.mjs +95 -0
  23. package/scripts/session-status-test.mjs +220 -0
  24. package/scripts/v2-command-adapter-test.mjs +113 -0
  25. package/scripts/v2-command-runtime-test.mjs +98 -0
  26. package/scripts/v2-diagnostics-test.mjs +74 -0
  27. package/scripts/v2-host-contract-test.mjs +27 -0
  28. package/scripts/v2-plugin-sentinel-test.mjs +60 -8
  29. package/scripts/v2-prompt-interval-test.mjs +171 -0
  30. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  31. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  32. package/src/index.js +2751 -1594
  33. package/src/source/legacy-v1.js +168 -1860
  34. package/src/source/opencode/command-router.js +103 -0
  35. package/src/source/opencode/commands.js +83 -0
  36. package/src/source/opencode/goal-commands.js +107 -0
  37. package/src/source/opencode/loop-commands.js +195 -0
  38. package/src/source/opencode/loop-registration.js +100 -0
  39. package/src/source/opencode/messages.js +55 -0
  40. package/src/source/opencode2/capabilities.js +3 -1
  41. package/src/source/opencode2/commands.js +72 -0
  42. package/src/source/opencode2/diagnostics.js +80 -0
  43. package/src/source/opencode2/event-bridge.js +19 -1
  44. package/src/source/opencode2/events.js +64 -0
  45. package/src/source/opencode2/experimental.js +41 -1
  46. package/src/source/opencode2/host-contract.js +26 -0
  47. package/src/source/opencode2/prompt-runtime.js +381 -0
  48. package/src/source/opencode2/runtime-adapter.js +56 -0
  49. package/src/source/opencode2/status.js +40 -0
  50. package/src/source/runtime/compaction.js +122 -0
  51. package/src/source/runtime/executor.js +558 -0
  52. package/src/source/runtime/goal-policy.js +77 -0
  53. package/src/source/runtime/goal-runtime.js +231 -0
  54. package/src/source/runtime/goal-steering.js +198 -0
  55. package/src/source/runtime/job-workspace.js +148 -0
  56. package/src/source/runtime/scheduler.js +212 -0
  57. package/src/source/runtime/session-activity.js +112 -0
  58. package/src/source/runtime/session-status.js +197 -0
  59. package/scripts/host-bridge-parity-test.mjs +0 -22
@@ -0,0 +1,310 @@
1
+ import assert from "node:assert/strict"
2
+ import { createLoopExecutor } from "../src/source/runtime/executor.js"
3
+ import { clearSessionActivity } from "../src/source/runtime/session-activity.js"
4
+
5
+ const noopWorkspace = {
6
+ buildPrompt: async () => "prompt",
7
+ ensureBranch: async (_directory, job) => job,
8
+ watchChanged: async () => false,
9
+ untilReached: async () => false,
10
+ createCheckpoint: async () => {},
11
+ }
12
+ const noopGoalPolicy = {
13
+ runGoalChecks: async (_directory, _sessionID, job) => job,
14
+ applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,
15
+ }
16
+ const noopScheduler = {
17
+ rememberSession: () => {},
18
+ scheduleDueWork: async () => {},
19
+ }
20
+
21
+ assert.throws(() => createLoopExecutor({}), /workspace\.buildPrompt/)
22
+ assert.throws(() => createLoopExecutor({ workspace: noopWorkspace }), /goalPolicy\.runGoalChecks/)
23
+ assert.throws(() => createLoopExecutor({ workspace: noopWorkspace, goalPolicy: noopGoalPolicy }), /scheduler\.rememberSession/)
24
+
25
+ let clock = 1_000_000
26
+ const states = new Map()
27
+ const writes = []
28
+ const schedules = []
29
+ const remembered = []
30
+ const toasts = []
31
+ const logs = []
32
+ const notifications = []
33
+ const guards = []
34
+ const sdkCalls = []
35
+ const fireCalls = []
36
+ const compactions = []
37
+ const checkpoints = []
38
+ const branchCalls = []
39
+ const goalChecks = []
40
+ const progressGuards = []
41
+
42
+ function stateKey(directory, sessionID) {
43
+ return `${directory}:${sessionID}`
44
+ }
45
+ function clone(value) {
46
+ return JSON.parse(JSON.stringify(value))
47
+ }
48
+
49
+ const workspace = {
50
+ buildPrompt: async (_directory, job) => `BUILT:${job.action}`,
51
+ ensureBranch: async (_directory, job, _client, sessionID) => {
52
+ branchCalls.push([sessionID, job.id])
53
+ return { ...job, branchDone: true }
54
+ },
55
+ watchChanged: async (_directory, job) => Boolean(job.testWatchChanged),
56
+ untilReached: async (_directory, job) => Boolean(job.testUntilReached),
57
+ createCheckpoint: async (...args) => { checkpoints.push(args) },
58
+ }
59
+ const goalPolicy = {
60
+ runGoalChecks: async (_directory, sessionID, job) => {
61
+ goalChecks.push([sessionID, job.id])
62
+ return { ...job, goalChecksRan: true }
63
+ },
64
+ applyGoalNoProgressGuard: async (_directory, _client, sessionID, job) => {
65
+ progressGuards.push([sessionID, job.id])
66
+ return { ...job, noProgressGuardRan: true }
67
+ },
68
+ }
69
+ const scheduler = {
70
+ rememberSession: (...args) => { remembered.push(args) },
71
+ scheduleDueWork: async (...args) => { schedules.push(args) },
72
+ }
73
+
74
+ const executor = createLoopExecutor({
75
+ workspace,
76
+ goalPolicy,
77
+ scheduler,
78
+ now: () => clock,
79
+ readState: async (directory, sessionID) => clone(states.get(stateKey(directory, sessionID)) || { jobs: [] }),
80
+ writeState: async (directory, sessionID, state) => {
81
+ const copy = clone(state)
82
+ states.set(stateKey(directory, sessionID), copy)
83
+ writes.push([directory, sessionID, copy])
84
+ },
85
+ pathExists: async (file) => String(file).includes("STOP-NOW"),
86
+ appendLoopLog: async (...args) => { logs.push(args) },
87
+ runShellCommand: async (command) => command.includes("fail")
88
+ ? { code: 1, stdout: "bad", stderr: "err" }
89
+ : { code: 0, stdout: "ok", stderr: "" },
90
+ notifyJob: async (...args) => { notifications.push(args) },
91
+ toast: async (...args) => { toasts.push(args) },
92
+ log: async (...args) => { logs.push(["host", ...args]) },
93
+ sdkCall: async (...args) => { sdkCalls.push(args); return {} },
94
+ normalizedModelRef: () => undefined,
95
+ compactTuiCommandName: (command) => command === "compact" ? "compact" : undefined,
96
+ fireSdk: (...args) => { fireCalls.push(args); return Promise.resolve({ ok: true }) },
97
+ guardLoopOwnedUserMessage: (sessionID) => { guards.push(sessionID) },
98
+ writeGoalReport: async (...args) => { logs.push(["goal-report", ...args]) },
99
+ dangerousShell: (command) => /danger/.test(command),
100
+ compactSession: async (...args) => { compactions.push(args); return true },
101
+ activeRunCompletionFromMessages: async () => "unknown",
102
+ activeGuardMs: 45_000,
103
+ busyRetryMs: 5_000,
104
+ })
105
+
106
+ const dueState = {
107
+ jobs: [
108
+ { id: "disabled", enabled: false, intervalMs: 0 },
109
+ { id: "paused", enabled: true, paused: true, intervalMs: 0 },
110
+ { id: "maxed", enabled: true, maxRuns: 1, runCount: 1, intervalMs: 0 },
111
+ { id: "watch-no", enabled: true, watchPaths: ["x"], watchTriggered: false, intervalMs: 0 },
112
+ { id: "watch-yes", enabled: true, watchPaths: ["x"], watchTriggered: true, intervalMs: 99_999 },
113
+ { id: "interval", enabled: true, intervalMs: 10_000, lastRunAt: clock - 20_000 },
114
+ ],
115
+ }
116
+ assert.deepEqual(executor.dueJobs(dueState).map((job) => job.id), ["watch-yes", "interval"])
117
+ assert.deepEqual(executor.dueJobs(dueState, true).map((job) => job.id), ["watch-no", "watch-yes", "interval"])
118
+ assert.deepEqual(executor.dueJobs({
119
+ jobs: [
120
+ { id: "natural", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 0 },
121
+ { id: "target", enabled: true, paused: false, intervalMs: 600_000, lastRunAt: clock, runCount: 0, maxRuns: 0, runNowRequestedAt: clock },
122
+ ],
123
+ }).map((job) => job.id), ["target", "natural"], "run-now target must outrank an earlier naturally-due job")
124
+
125
+ const client = {
126
+ session: {
127
+ status: async () => ({ data: {} }),
128
+ command: async () => ({ data: {} }),
129
+ shell: async () => ({ data: {} }),
130
+ prompt: async () => ({ data: {} }),
131
+ abort: async () => ({ data: {} }),
132
+ },
133
+ }
134
+
135
+ const blockedShell = await executor.fireAction("/repo", client, "shell-session", {
136
+ id: "shell-job",
137
+ action: "! danger --all",
138
+ safe: true,
139
+ })
140
+ assert.deepEqual(blockedShell, { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" })
141
+ assert.equal(fireCalls.length, 0)
142
+ assert.match(toasts.at(-1)[1], /Blocked dangerous shell command/)
143
+
144
+ const commandResult = await executor.fireAction("/repo", client, "command-session", {
145
+ id: "command-job",
146
+ action: "/review --quick",
147
+ agent: "build",
148
+ })
149
+ assert.deepEqual(commandResult, { startsAssistantTurn: true })
150
+ assert.equal(sdkCalls.length, 1)
151
+ assert.equal(guards.at(-1), "command-session")
152
+
153
+ const compactResult = await executor.fireAction("/repo", client, "compact-session", {
154
+ id: "compact-job",
155
+ action: "/compact",
156
+ })
157
+ assert.equal(compactResult.startsAssistantTurn, true)
158
+ assert.equal(compactResult.compaction, true)
159
+ assert.equal(compactions.length, 1)
160
+
161
+ const promptResult = await executor.fireAction("/repo", client, "prompt-session", {
162
+ id: "prompt-job",
163
+ action: "continue work",
164
+ enabled: true,
165
+ })
166
+ assert.equal(promptResult.startsAssistantTurn, true)
167
+ assert.ok(promptResult.dispatch instanceof Promise)
168
+ assert.equal(fireCalls.at(-1)[1], "session.prompt")
169
+ const promptBody = fireCalls.at(-1)[3].body
170
+ assert.match(promptBody.parts[0].text, /AUTONOMOUS OPENCODE LOOP ITERATION/)
171
+ assert.match(promptBody.parts[0].text, /BUILT:continue work/)
172
+
173
+ const sessionID = "run-session"
174
+ const directory = "/repo"
175
+ states.set(stateKey(directory, sessionID), {
176
+ jobs: [{
177
+ id: "run-job",
178
+ name: "run-job",
179
+ action: "implement feature",
180
+ enabled: true,
181
+ paused: false,
182
+ intervalMs: 0,
183
+ runCount: 0,
184
+ maxRuns: 0,
185
+ maxRuntimeMs: 0,
186
+ timeoutMs: 0,
187
+ safe: false,
188
+ }],
189
+ })
190
+ await executor.maybeRunDueJobs(directory, client, sessionID)
191
+ assert.equal(remembered.at(-1)[2], sessionID)
192
+ const active = executor.getActiveRun(sessionID)
193
+ assert.equal(active.jobId, "run-job")
194
+ assert.equal(active.job.runCount, 1)
195
+ assert.equal(executor.isRunLocked(sessionID), false)
196
+ assert.equal(states.get(stateKey(directory, sessionID)).jobs[0].runCount, 1)
197
+ assert.ok(schedules.some((call) => call[2] === sessionID && call[3] === 5_000))
198
+ assert.ok(branchCalls.some((call) => call[0] === sessionID && call[1] === "run-job"))
199
+
200
+ clock += 1_000
201
+ const finalized = await executor.finalizeActiveRun(directory, client, sessionID)
202
+ assert.equal(finalized, true)
203
+ assert.equal(executor.getActiveRun(sessionID), undefined)
204
+ assert.equal(states.get(stateKey(directory, sessionID)).jobs[0].lastFinishedAt, clock)
205
+ assert.ok(checkpoints.some((call) => call[2].id === "run-job"))
206
+
207
+ const runNowSession = "run-now-session"
208
+ states.set(stateKey(directory, runNowSession), {
209
+ jobs: [
210
+ {
211
+ id: "natural-job",
212
+ name: "natural",
213
+ action: "natural work",
214
+ enabled: true,
215
+ paused: false,
216
+ intervalMs: 0,
217
+ runCount: 0,
218
+ maxRuns: 0,
219
+ maxRuntimeMs: 0,
220
+ timeoutMs: 0,
221
+ },
222
+ {
223
+ id: "target-job",
224
+ name: "target",
225
+ action: "target work",
226
+ enabled: true,
227
+ paused: false,
228
+ intervalMs: 600_000,
229
+ immediate: false,
230
+ createdAt: new Date(clock).toISOString(),
231
+ lastRunAt: 0,
232
+ runCount: 0,
233
+ maxRuns: 0,
234
+ maxRuntimeMs: 0,
235
+ timeoutMs: 0,
236
+ runNowRequestedAt: clock,
237
+ },
238
+ ],
239
+ })
240
+ executor.markSessionStatus(runNowSession, "busy", clock)
241
+ await executor.maybeRunDueJobs(directory, client, runNowSession)
242
+ assert.equal(executor.getActiveRun(runNowSession), undefined)
243
+ let runNowState = states.get(stateKey(directory, runNowSession))
244
+ assert.equal(runNowState.jobs.find((job) => job.id === "target-job").runNowRequestedAt, clock, "busy retry must preserve the targeted run-now request")
245
+ assert.ok(schedules.some((call) => call[2] === runNowSession && call[3] === 5_000))
246
+
247
+ executor.markSessionStatus(runNowSession, "idle", clock)
248
+ await executor.maybeRunDueJobs(directory, client, runNowSession)
249
+ const runNowActive = executor.getActiveRun(runNowSession)
250
+ assert.equal(runNowActive.jobId, "target-job", "targeted run-now job must dispatch before the natural due job")
251
+ runNowState = states.get(stateKey(directory, runNowSession))
252
+ assert.equal(runNowState.jobs.find((job) => job.id === "target-job").runNowRequestedAt, undefined, "run-now request must be consumed when the target dispatch starts")
253
+ assert.equal(runNowState.jobs.find((job) => job.id === "target-job").runCount, 1)
254
+ assert.equal(runNowState.jobs.find((job) => job.id === "natural-job").runCount, 0)
255
+ clock += 1_000
256
+ assert.equal(await executor.finalizeActiveRun(directory, client, runNowSession), true)
257
+
258
+ const goalSession = "goal-session"
259
+ states.set(stateKey(directory, goalSession), {
260
+ jobs: [{
261
+ id: "goal-job",
262
+ name: "goal-job",
263
+ kind: "goal",
264
+ goal: "finish",
265
+ goalStatus: "active",
266
+ action: "finish",
267
+ enabled: true,
268
+ paused: false,
269
+ intervalMs: 0,
270
+ runCount: 0,
271
+ maxRuns: 0,
272
+ maxRuntimeMs: 0,
273
+ timeoutMs: 0,
274
+ }],
275
+ })
276
+ await executor.maybeRunDueJobs(directory, client, goalSession)
277
+ clock += 1_000
278
+ await executor.finalizeActiveRun(directory, client, goalSession)
279
+ assert.deepEqual(goalChecks.at(-1), [goalSession, "goal-job"])
280
+ assert.deepEqual(progressGuards.at(-1), [goalSession, "goal-job"])
281
+ const finalizedGoal = states.get(stateKey(directory, goalSession)).jobs[0]
282
+ assert.equal(finalizedGoal.goalChecksRan, true)
283
+ assert.equal(finalizedGoal.noProgressGuardRan, true)
284
+
285
+ const stopSession = "stop-session"
286
+ states.set(stateKey(directory, stopSession), {
287
+ jobs: [{
288
+ id: "stop-job",
289
+ enabled: true,
290
+ paused: false,
291
+ intervalMs: 0,
292
+ maxRuns: 0,
293
+ maxRuntimeMs: 0,
294
+ stopFile: "STOP-NOW",
295
+ }],
296
+ })
297
+ await executor.maybeRunDueJobs(directory, client, stopSession)
298
+ assert.equal(states.get(stateKey(directory, stopSession)).jobs.length, 0)
299
+ assert.ok(notifications.some((call) => call[1]?.id === "stop-job" && call[2] === "stop_file"))
300
+
301
+ executor.disposeSession(sessionID)
302
+ executor.disposeSession(runNowSession)
303
+ executor.disposeSession(goalSession)
304
+ executor.disposeSession(stopSession)
305
+ for (const id of ["shell-session", "command-session", "compact-session", "prompt-session", sessionID, runNowSession, goalSession, stopSession]) {
306
+ clearSessionActivity(id)
307
+ }
308
+
309
+ assert.ok(writes.length > 0)
310
+ console.log("executor runtime tests passed")
@@ -0,0 +1,70 @@
1
+ import { writeFile } from "node:fs/promises"
2
+
3
+ const COMMAND_SENTINEL = "__opencode_loop_missing_command_probe__"
4
+
5
+ export default {
6
+ id: "bybrawe.opencode-loop.v2.real-adapter-probe",
7
+ async setup(ctx) {
8
+ const pluginURL = process.env.OPENCODE_LOOP_V2_PLUGIN_URL
9
+ const marker = process.env.OPENCODE_LOOP_V2_MARKER
10
+ if (!pluginURL) throw new Error("OPENCODE_LOOP_V2_PLUGIN_URL is required")
11
+ if (!marker) throw new Error("OPENCODE_LOOP_V2_MARKER is required")
12
+
13
+ const module = await import(pluginURL)
14
+ const plugin = module.default
15
+ if (!plugin || typeof plugin.setup !== "function") throw new Error("experimental V2 plugin has no setup function")
16
+ const cleanup = await plugin.setup(ctx)
17
+
18
+ const sessionMethods = Object.fromEntries(
19
+ Object.keys(ctx?.session || {})
20
+ .sort()
21
+ .map((key) => [key, typeof ctx.session[key]]),
22
+ )
23
+
24
+ let commandFieldProbe = { matched: false, error: "session command probe did not run" }
25
+ if (typeof ctx?.session?.create === "function" && typeof ctx?.session?.command === "function") {
26
+ try {
27
+ const created = await ctx.session.create()
28
+ const sessionID = String(created?.id || created?.data?.id || created?.sessionID || "")
29
+ if (!sessionID) throw new Error("session.create returned no session id")
30
+ try {
31
+ await ctx.session.command({ sessionID, command: COMMAND_SENTINEL })
32
+ commandFieldProbe = {
33
+ matched: false,
34
+ sessionID,
35
+ error: "missing command unexpectedly succeeded",
36
+ }
37
+ } catch (error) {
38
+ const message = error instanceof Error ? error.message : String(error)
39
+ commandFieldProbe = {
40
+ matched: message.includes(`Command not found: ${COMMAND_SENTINEL}`),
41
+ sessionID,
42
+ error: message,
43
+ }
44
+ }
45
+ } catch (error) {
46
+ commandFieldProbe = {
47
+ matched: false,
48
+ error: error instanceof Error ? error.message : String(error),
49
+ }
50
+ }
51
+ }
52
+
53
+ await writeFile(marker, JSON.stringify({
54
+ id: plugin.id,
55
+ activated: true,
56
+ commandTransform: typeof ctx?.command?.transform === "function",
57
+ eventSubscribe: typeof ctx?.event?.subscribe === "function",
58
+ sessionPrompt: typeof ctx?.session?.prompt === "function",
59
+ sessionCommand: typeof ctx?.session?.command === "function",
60
+ sessionShell: typeof ctx?.session?.shell === "function",
61
+ sessionMethods,
62
+ commandFieldProbe,
63
+ toolTransform: typeof ctx?.tool?.transform === "function",
64
+ }, null, 2), "utf8")
65
+
66
+ return async () => {
67
+ if (typeof cleanup === "function") await cleanup()
68
+ }
69
+ },
70
+ }
@@ -0,0 +1,236 @@
1
+ import assert from "node:assert/strict"
2
+ import { createGoalCommandHandlers } from "../src/source/opencode/goal-commands.js"
3
+
4
+ function goal(id, overrides = {}) {
5
+ return {
6
+ id,
7
+ name: id,
8
+ kind: "goal",
9
+ goalStatus: "active",
10
+ enabled: true,
11
+ paused: false,
12
+ action: `Goal ${id}`,
13
+ runCount: 0,
14
+ noProgressCount: 0,
15
+ ...overrides,
16
+ }
17
+ }
18
+
19
+ function harness(initialStates = {}) {
20
+ const states = new Map(Object.entries(initialStates).map(([key, value]) => [key, structuredClone(value)]))
21
+ const writes = []
22
+ const due = []
23
+ const idle = []
24
+ const toasts = []
25
+ const messages = []
26
+ const addLoopCalls = []
27
+ const completeCalls = []
28
+ const blockedCalls = []
29
+
30
+ const handlers = createGoalCommandHandlers({
31
+ addLoop: async (...args) => { addLoopCalls.push(args); return "added" },
32
+ scheduleDueWork: async (...args) => { due.push(args) },
33
+ scheduleIdleWork: (...args) => { idle.push(args) },
34
+ toast: async (...args) => { toasts.push(args) },
35
+ say: async (...args) => { messages.push(args) },
36
+ readState: async (_directory, sessionID) => structuredClone(states.get(sessionID) || { jobs: [] }),
37
+ writeState: async (_directory, sessionID, state) => {
38
+ const copy = structuredClone(state)
39
+ states.set(sessionID, copy)
40
+ writes.push({ sessionID, state: copy })
41
+ },
42
+ setGoalComplete: async (...args) => { completeCalls.push(args); return { ok: true, message: "completed" } },
43
+ setGoalBlocked: async (...args) => { blockedCalls.push(args); return { ok: true, message: "blocked" } },
44
+ })
45
+
46
+ return { handlers, states, writes, due, idle, toasts, messages, addLoopCalls, completeCalls, blockedCalls }
47
+ }
48
+
49
+ assert.throws(() => createGoalCommandHandlers({}), /addLoop/)
50
+ assert.throws(() => createGoalCommandHandlers({ addLoop() {} }), /scheduleDueWork/)
51
+
52
+ {
53
+ const sessionID = "status"
54
+ const h = harness({
55
+ [sessionID]: {
56
+ jobs: [
57
+ { id: "regular", kind: "prompt" },
58
+ goal("g1", {
59
+ name: "feature",
60
+ action: "Ship feature",
61
+ runCount: 2,
62
+ goalChecks: ["npm test", "npm run check"],
63
+ goalAcceptance: ["works"],
64
+ goalProgress: [{ summary: "patched" }],
65
+ maxNoProgress: 3,
66
+ noProgressCount: 1,
67
+ goalCompletionRejectedReason: "not yet",
68
+ }),
69
+ goal("g2", { goalStatus: "blocked", paused: true, action: "Fix blocker" }),
70
+ ],
71
+ },
72
+ })
73
+ await h.handlers.statusGoal("/work", { id: "client" }, sessionID)
74
+ assert.equal(h.writes.length, 0)
75
+ assert.deepEqual(h.toasts, [[{ id: "client" }, "2 experimental goal(s).", "info"]])
76
+ assert.equal(h.messages.length, 1)
77
+ const text = h.messages[0][2]
78
+ assert.match(text, /OpenCode Loop experimental goal status:/)
79
+ assert.match(text, /1\. g1 \(feature\): active \| turns=2 \| objective=Ship feature/)
80
+ assert.match(text, /checks=2/)
81
+ assert.match(text, /acceptance=1/)
82
+ assert.match(text, /progress=1/)
83
+ assert.match(text, /no-progress=1\/3/)
84
+ assert.match(text, /completion-rejected/)
85
+ assert.match(text, /2\. g2 \(g2\): blocked/)
86
+ }
87
+
88
+ {
89
+ const sessionID = "empty-status"
90
+ const h = harness({ [sessionID]: { jobs: [{ id: "regular", kind: "prompt" }] } })
91
+ await h.handlers.statusGoal("/work", {}, sessionID)
92
+ assert.deepEqual(h.toasts, [[{}, "No experimental goal jobs.", "warning"]])
93
+ assert.match(h.messages[0][2], /No experimental goal jobs\./)
94
+ }
95
+
96
+ {
97
+ const sessionID = "pause"
98
+ const client = {}
99
+ const h = harness({
100
+ [sessionID]: {
101
+ jobs: [goal("g1"), goal("g2"), { id: "regular", kind: "prompt", paused: false }],
102
+ },
103
+ })
104
+ await h.handlers.pauseGoal("/work", client, sessionID, "g1")
105
+ const jobs = h.states.get(sessionID).jobs
106
+ assert.equal(jobs[0].paused, true)
107
+ assert.equal(jobs[1].paused, false)
108
+ assert.equal(jobs[2].paused, false)
109
+ assert.equal(h.writes.length, 1)
110
+ assert.deepEqual(h.due, [["/work", client, sessionID]])
111
+ assert.deepEqual(h.toasts, [[client, "Paused 1 experimental goal(s).", "success"]])
112
+ }
113
+
114
+ {
115
+ const sessionID = "resume"
116
+ const client = {}
117
+ const h = harness({
118
+ [sessionID]: {
119
+ jobs: [goal("g1", {
120
+ goalStatus: "blocked",
121
+ enabled: false,
122
+ paused: true,
123
+ lastRunAt: 99,
124
+ noProgressCount: 3,
125
+ goalNoProgressReason: "stalled",
126
+ goalInterruptedReason: "interrupted",
127
+ })],
128
+ },
129
+ })
130
+ await h.handlers.resumeGoal("/work", client, sessionID, "g1")
131
+ const resumed = h.states.get(sessionID).jobs[0]
132
+ assert.equal(resumed.goalStatus, "active")
133
+ assert.equal(resumed.enabled, true)
134
+ assert.equal(resumed.paused, false)
135
+ assert.equal(resumed.lastRunAt, 0)
136
+ assert.equal(resumed.noProgressCount, 0)
137
+ assert.equal(resumed.goalNoProgressReason, "")
138
+ assert.equal(resumed.goalInterruptedReason, "")
139
+ assert.deepEqual(h.due, [["/work", client, sessionID]])
140
+ assert.deepEqual(h.idle, [["/work", client, sessionID]])
141
+ assert.deepEqual(h.toasts, [[client, "Resumed 1 experimental goal(s).", "success"]])
142
+ }
143
+
144
+ {
145
+ const sessionID = "resume-missing"
146
+ const client = {}
147
+ const h = harness({ [sessionID]: { jobs: [goal("g1")] } })
148
+ await h.handlers.resumeGoal("/work", client, sessionID, "missing")
149
+ assert.equal(h.due.length, 0)
150
+ assert.equal(h.idle.length, 0)
151
+ assert.deepEqual(h.toasts, [[client, "Resumed 0 experimental goal(s).", "warning"]])
152
+ }
153
+
154
+ {
155
+ const sessionID = "clear"
156
+ const client = {}
157
+ const h = harness({
158
+ [sessionID]: {
159
+ jobs: [goal("g1"), { id: "regular", kind: "prompt" }, goal("g2")],
160
+ },
161
+ })
162
+ await h.handlers.clearGoal("/work", client, sessionID, "")
163
+ assert.deepEqual(h.states.get(sessionID).jobs.map((job) => job.id), ["regular"])
164
+ assert.deepEqual(h.due, [["/work", client, sessionID]])
165
+ assert.deepEqual(h.toasts, [[client, "Cleared 2 experimental goal(s).", "success"]])
166
+ }
167
+
168
+ {
169
+ const sessionID = "clear-one"
170
+ const h = harness({ [sessionID]: { jobs: [goal("g1"), goal("g2")] } })
171
+ await h.handlers.clearGoal("/work", {}, sessionID, "g1")
172
+ assert.deepEqual(h.states.get(sessionID).jobs.map((job) => job.id), ["g2"])
173
+ }
174
+
175
+ {
176
+ const h = harness()
177
+ const client = {}
178
+ await h.handlers.completeGoalCommand("/work", client, "complete", " shipped ")
179
+ assert.deepEqual(h.completeCalls, [["/work", "complete", {
180
+ summary: "shipped",
181
+ evidence: "Marked complete by /loop-goal-done.",
182
+ manual: true,
183
+ }]])
184
+ assert.deepEqual(h.toasts, [[client, "completed", "success"]])
185
+
186
+ await h.handlers.completeGoalCommand("/work", client, "complete-default", "")
187
+ assert.equal(h.completeCalls[1][2].summary, "Goal manually marked complete.")
188
+ }
189
+
190
+ {
191
+ const h = harness()
192
+ const client = {}
193
+ await h.handlers.blockGoalCommand("/work", client, "blocked", " need token ")
194
+ assert.deepEqual(h.blockedCalls, [["/work", "blocked", {
195
+ reason: "need token",
196
+ needed: "User input or manual intervention.",
197
+ }]])
198
+ assert.deepEqual(h.toasts, [[client, "blocked", "warning"]])
199
+ }
200
+
201
+ {
202
+ const h = harness()
203
+ const client = {}
204
+ const result = await h.handlers.addGoal("/repo", client, "create", "finish the feature")
205
+ assert.equal(result, "added")
206
+ assert.equal(h.addLoopCalls.length, 1)
207
+ assert.equal(h.addLoopCalls[0][0], "/repo")
208
+ assert.equal(h.addLoopCalls[0][1], client)
209
+ assert.equal(h.addLoopCalls[0][2], "create")
210
+ assert.equal(h.addLoopCalls[0][3], "finish the feature")
211
+ assert.deepEqual(h.addLoopCalls[0][4], {
212
+ intervalMs: 0,
213
+ kind: "goal",
214
+ name: "goal",
215
+ immediate: true,
216
+ safe: true,
217
+ askNever: true,
218
+ noOverlap: true,
219
+ goalStatus: "active",
220
+ })
221
+ }
222
+
223
+ {
224
+ const sessionID = "subcommands"
225
+ const h = harness({ [sessionID]: { jobs: [goal("g1", { paused: false })] } })
226
+ await h.handlers.addGoal("/work", {}, sessionID, "pause g1")
227
+ assert.equal(h.states.get(sessionID).jobs[0].paused, true)
228
+
229
+ await h.handlers.addGoal("/work", {}, sessionID, "completed final summary")
230
+ assert.equal(h.completeCalls.at(-1)[2].summary, "final summary")
231
+
232
+ await h.handlers.addGoal("/work", {}, sessionID, "block waiting on user")
233
+ assert.equal(h.blockedCalls.at(-1)[2].reason, "waiting on user")
234
+ }
235
+
236
+ console.log("goal command handler tests passed")