@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,95 @@
1
+ import assert from "node:assert/strict"
2
+ import {
3
+ activeToolCalls,
4
+ sessionParents,
5
+ sessionStatuses,
6
+ sessionStatusSeenAt,
7
+ hasActiveToolCalls,
8
+ markToolCallActive,
9
+ markToolCallFinished,
10
+ updateSessionRelationship,
11
+ updateSessionRelationshipFromEvent,
12
+ isDescendantSession,
13
+ hasBusyDescendant,
14
+ refreshSessionRelationships,
15
+ updateToolActivityFromEvent,
16
+ clearSessionActivity,
17
+ } from "../src/source/runtime/session-activity.js"
18
+
19
+ const parentID = "ses_parent"
20
+ const childID = "ses_child"
21
+ const listedChildID = "ses_listed_child"
22
+
23
+ for (const sessionID of [parentID, childID, listedChildID]) clearSessionActivity(sessionID)
24
+
25
+ updateSessionRelationship({ id: childID, parentID })
26
+ assert.equal(sessionParents.get(childID), parentID)
27
+ assert.equal(isDescendantSession(childID, parentID), true)
28
+ assert.equal(isDescendantSession(parentID, childID), false)
29
+
30
+ markToolCallActive({ sessionID: childID, callID: "call_1" })
31
+ assert.equal(hasActiveToolCalls(childID), true)
32
+ assert.equal(sessionStatuses.get(childID), "busy")
33
+ assert.ok((sessionStatusSeenAt.get(childID) || 0) > 0)
34
+ assert.equal(hasBusyDescendant(parentID), true)
35
+
36
+ markToolCallFinished({ sessionID: childID, callID: "call_1" })
37
+ assert.equal(hasActiveToolCalls(childID), false)
38
+ sessionStatuses.set(childID, "idle")
39
+ assert.equal(hasBusyDescendant(parentID), false)
40
+
41
+ updateToolActivityFromEvent({
42
+ type: "message.part.updated",
43
+ properties: {
44
+ part: {
45
+ type: "tool",
46
+ sessionID: childID,
47
+ callID: "call_2",
48
+ id: "part_2",
49
+ state: { status: "running" },
50
+ },
51
+ },
52
+ })
53
+ assert.equal(activeToolCalls.get(childID)?.has("call_2"), true)
54
+
55
+ updateToolActivityFromEvent({
56
+ type: "message.part.updated",
57
+ properties: {
58
+ part: {
59
+ type: "tool",
60
+ sessionID: childID,
61
+ callID: "call_2",
62
+ id: "part_2",
63
+ state: { status: "completed" },
64
+ },
65
+ },
66
+ })
67
+ assert.equal(hasActiveToolCalls(childID), false)
68
+
69
+ await refreshSessionRelationships({
70
+ session: {
71
+ list: async () => [{ id: listedChildID, parentID }],
72
+ },
73
+ }, "/tmp/opencode-loop-session-activity")
74
+ assert.equal(sessionParents.get(listedChildID), parentID)
75
+
76
+ updateSessionRelationshipFromEvent({
77
+ type: "session.deleted",
78
+ properties: { info: { id: listedChildID, parentID } },
79
+ })
80
+ assert.equal(sessionParents.has(listedChildID), false)
81
+
82
+ sessionParents.set(parentID, childID)
83
+ sessionParents.set(childID, parentID)
84
+ assert.equal(isDescendantSession(parentID, "ses_missing"), false)
85
+ sessionParents.delete(parentID)
86
+
87
+ clearSessionActivity(childID)
88
+ assert.equal(activeToolCalls.has(childID), false)
89
+ assert.equal(sessionParents.has(childID), false)
90
+ assert.equal(sessionStatuses.has(childID), false)
91
+ assert.equal(sessionStatusSeenAt.has(childID), false)
92
+ clearSessionActivity(parentID)
93
+ clearSessionActivity(listedChildID)
94
+
95
+ console.log("session activity runtime tests passed")
@@ -0,0 +1,220 @@
1
+ import assert from "node:assert/strict"
2
+ import {
3
+ clearSessionActivity,
4
+ markToolCallActive,
5
+ markToolCallFinished,
6
+ sessionParents,
7
+ sessionStatuses,
8
+ sessionStatusSeenAt,
9
+ updateSessionRelationship,
10
+ } from "../src/source/runtime/session-activity.js"
11
+ import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
12
+
13
+ assert.throws(() => createSessionStatusRuntime({}), /activeRuns Map/)
14
+
15
+ const activeRuns = new Map()
16
+ const logs = []
17
+ let clock = 100_000
18
+ let completion = "unknown"
19
+ const runtime = createSessionStatusRuntime({
20
+ activeRuns,
21
+ now: () => clock,
22
+ sessionStatusCacheMs: 0,
23
+ appendLoopLog: async (...args) => { logs.push(args) },
24
+ activeRunCompletionFromMessages: async () => completion,
25
+ })
26
+
27
+ const ids = new Set()
28
+ const track = (...values) => {
29
+ for (const value of values) if (typeof value === "string") ids.add(value)
30
+ }
31
+
32
+ try {
33
+ {
34
+ const sessionID = "event-idle"
35
+ track(sessionID)
36
+ const result = runtime.updateSessionStatusFromEvent({ type: "session.idle", properties: { sessionID } })
37
+ assert.deepEqual(result, { sessionID, idle: true })
38
+ assert.equal(sessionStatuses.get(sessionID), "idle")
39
+ assert.equal(sessionStatusSeenAt.get(sessionID), clock)
40
+
41
+ clock++
42
+ assert.deepEqual(
43
+ runtime.updateSessionStatusFromEvent({ type: "session.status", properties: { sessionID, status: { type: "retry" } } }),
44
+ { sessionID, idle: false },
45
+ )
46
+ assert.equal(sessionStatuses.get(sessionID), "retry")
47
+ assert.equal(sessionStatusSeenAt.get(sessionID), clock)
48
+ assert.equal(runtime.updateSessionStatusFromEvent({ type: "message.updated", properties: { sessionID } }), undefined)
49
+ assert.equal(runtime.updateSessionStatusFromEvent({ type: "session.idle", properties: {} }), undefined)
50
+ }
51
+
52
+ {
53
+ const sessionID = "mark-clear"
54
+ track(sessionID)
55
+ assert.equal(runtime.markSessionStatus(sessionID, "busy", 1234), true)
56
+ assert.equal(sessionStatuses.get(sessionID), "busy")
57
+ assert.equal(sessionStatusSeenAt.get(sessionID), 1234)
58
+ assert.equal(runtime.markSessionStatus(undefined, "busy"), false)
59
+ runtime.clearSessionStatus(sessionID)
60
+ assert.equal(sessionStatuses.has(sessionID), false)
61
+ assert.equal(sessionStatusSeenAt.has(sessionID), false)
62
+ }
63
+
64
+ {
65
+ const sessionID = "sdk-data-wrapper"
66
+ track(sessionID)
67
+ const calls = []
68
+ const client = {
69
+ session: {
70
+ status: async (args) => {
71
+ calls.push(args)
72
+ return { data: { [sessionID]: { type: "busy" } } }
73
+ },
74
+ },
75
+ }
76
+ const live = await runtime.readLiveSessionStatus(client, sessionID, "/repo")
77
+ assert.deepEqual(live, { type: "busy", source: "sdk" })
78
+ assert.deepEqual(calls, [{ query: { directory: "/repo" } }])
79
+ assert.equal(sessionStatuses.get(sessionID), "busy")
80
+ assert.equal(sessionStatusSeenAt.get(sessionID), clock)
81
+ }
82
+
83
+ {
84
+ const sessionID = "sdk-error-fallback"
85
+ track(sessionID)
86
+ const calls = []
87
+ const client = {
88
+ session: {
89
+ status: async (args) => {
90
+ calls.push(args)
91
+ if (calls.length === 1) return { error: { message: "unsupported query shape" } }
92
+ return { data: { [sessionID]: { type: "idle" } } }
93
+ },
94
+ },
95
+ }
96
+ const live = await runtime.readLiveSessionStatus(client, sessionID, "/repo")
97
+ assert.deepEqual(live, { type: "idle", source: "sdk" })
98
+ assert.equal(calls.length, 2)
99
+ assert.deepEqual(calls[0], { query: { directory: "/repo" } })
100
+ assert.deepEqual(calls[1], { directory: "/repo" })
101
+ }
102
+
103
+ {
104
+ const parentID = "parent-busy-child"
105
+ const childID = "child-busy"
106
+ track(parentID, childID)
107
+ updateSessionRelationship({ id: childID, parentID })
108
+ const client = {
109
+ session: {
110
+ status: async () => ({ data: { [childID]: { type: "busy" } } }),
111
+ },
112
+ }
113
+ const live = await runtime.readLiveSessionStatus(client, parentID, "/repo")
114
+ assert.deepEqual(live, { type: "busy", source: "descendant" })
115
+ assert.equal(sessionParents.get(childID), parentID)
116
+ assert.equal(sessionStatuses.get(childID), "busy")
117
+ }
118
+
119
+ {
120
+ const parentID = "parent-finished-child"
121
+ const childID = "child-finished"
122
+ track(parentID, childID)
123
+ updateSessionRelationship({ id: childID, parentID })
124
+ runtime.markSessionStatus(childID, "busy")
125
+ const client = {
126
+ session: {
127
+ status: async () => ({ data: { [parentID]: { type: "idle" } } }),
128
+ },
129
+ }
130
+ const live = await runtime.readLiveSessionStatus(client, parentID, "/repo")
131
+ assert.deepEqual(live, { type: "idle", source: "sdk" })
132
+ assert.equal(sessionStatuses.get(childID), "idle", "omitted descendant must be cleared to idle")
133
+ }
134
+
135
+ {
136
+ const sessionID = "active-tool"
137
+ track(sessionID)
138
+ runtime.clearSessionStatus(sessionID)
139
+ markToolCallActive({ sessionID, callID: "tool-1" })
140
+ const client = { session: { status: async () => ({ data: { [sessionID]: { type: "idle" } } }) } }
141
+ assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "busy")
142
+ assert.equal(sessionStatuses.get(sessionID), "busy")
143
+ markToolCallFinished({ sessionID, callID: "tool-1" })
144
+ }
145
+
146
+ {
147
+ const sessionID = "message-complete-recovery"
148
+ track(sessionID)
149
+ runtime.clearSessionStatus(sessionID)
150
+ activeRuns.set(sessionID, { jobId: "job-1", job: { id: "job-1", name: "dev" }, startedAt: 1_000 })
151
+ completion = "completed"
152
+ const client = { session: { status: async () => ({ data: { [sessionID]: { type: "busy" } } }) } }
153
+ assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "idle")
154
+ assert.equal(sessionStatuses.get(sessionID), "idle")
155
+ assert.deepEqual(logs.at(-1), ["/repo", "status-message-complete-recovery", { sessionID, job: "dev", startedAt: 1_000 }])
156
+ activeRuns.delete(sessionID)
157
+ completion = "unknown"
158
+ }
159
+
160
+ {
161
+ const sessionID = "stale-recovery"
162
+ track(sessionID)
163
+ runtime.clearSessionStatus(sessionID)
164
+ activeRuns.set(sessionID, { jobId: "job-2", job: { id: "job-2" }, startedAt: 1_000 })
165
+ completion = "unknown"
166
+ const client = { session: { status: async () => ({ data: { [sessionID]: { type: "retry" } } }) } }
167
+ assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "idle")
168
+ assert.deepEqual(logs.at(-1), ["/repo", "status-stale-recovery", { sessionID, job: "job-2", startedAt: 1_000 }])
169
+ activeRuns.delete(sessionID)
170
+ }
171
+
172
+ {
173
+ const sessionID = "fallback-fresh"
174
+ track(sessionID)
175
+ runtime.clearSessionStatus(sessionID)
176
+ activeRuns.set(sessionID, { jobId: "fresh", job: {}, startedAt: clock - 1_000 })
177
+ const client = { session: { status: async () => { throw new Error("offline") } } }
178
+ assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "busy")
179
+ activeRuns.set(sessionID, { jobId: "stale", job: {}, startedAt: clock - 60_000 })
180
+ runtime.clearSessionStatus(sessionID)
181
+ assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "idle")
182
+ activeRuns.delete(sessionID)
183
+ }
184
+
185
+ {
186
+ const sessionID = "finalize-basic"
187
+ track(sessionID)
188
+ runtime.clearSessionStatus(sessionID)
189
+ const active = { jobId: "job", job: {}, startedAt: clock - 100 }
190
+ assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), true)
191
+
192
+ markToolCallActive({ sessionID, callID: "tool-finalize" })
193
+ assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), false)
194
+ markToolCallFinished({ sessionID, callID: "tool-finalize" })
195
+
196
+ runtime.markSessionStatus(sessionID, "idle", clock + 1)
197
+ const client = { session: { status: async () => { throw new Error("status unavailable") } } }
198
+ assert.equal(await runtime.canFinalizeActiveRun("/repo", client, sessionID, active, { requireIdle: true }), true)
199
+ }
200
+
201
+ {
202
+ const sessionID = "force-stale"
203
+ track(sessionID)
204
+ runtime.clearSessionStatus(sessionID)
205
+ activeRuns.set(sessionID, { jobId: "force", job: {}, startedAt: clock - 60_000 })
206
+ completion = "unknown"
207
+ const active = activeRuns.get(sessionID)
208
+ const client = { session: { status: async () => ({ data: { [sessionID]: { type: "busy" } } }) } }
209
+ assert.equal(await runtime.canFinalizeActiveRun("/repo", client, sessionID, active, { requireIdle: true, forceStale: true }), true)
210
+ activeRuns.delete(sessionID)
211
+ }
212
+
213
+ assert.equal(await runtime.readLiveSessionStatus({}, "missing-client", "/repo"), undefined)
214
+ assert.equal(await runtime.sessionIsIdle({ session: { status: async () => ({ data: {} }) } }, "unlisted", "/repo"), true)
215
+
216
+ console.log("session status tests passed")
217
+ } finally {
218
+ activeRuns.clear()
219
+ for (const sessionID of ids) clearSessionActivity(sessionID)
220
+ }
@@ -0,0 +1,113 @@
1
+ import assert from "node:assert/strict"
2
+ import { mkdtemp, rm } from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import OpenCodeLoopV2ExperimentalPlugin from "../src/source/opencode2/experimental.js"
6
+ import { createOpenCode2RuntimeAdapter } from "../src/source/opencode2/runtime-adapter.js"
7
+
8
+ function controllableStream() {
9
+ const queued = []
10
+ const waiting = []
11
+ let closed = false
12
+ const iterator = {
13
+ next() {
14
+ if (queued.length) return Promise.resolve({ done: false, value: queued.shift() })
15
+ if (closed) return Promise.resolve({ done: true, value: undefined })
16
+ return new Promise((resolve) => waiting.push(resolve))
17
+ },
18
+ async return() {
19
+ closed = true
20
+ while (waiting.length) waiting.shift()({ done: true, value: undefined })
21
+ return { done: true, value: undefined }
22
+ },
23
+ }
24
+ return {
25
+ stream: { [Symbol.asyncIterator]: () => iterator },
26
+ push(value) {
27
+ if (waiting.length) waiting.shift()({ done: false, value })
28
+ else queued.push(value)
29
+ },
30
+ }
31
+ }
32
+
33
+ async function waitFor(predicate, description, timeoutMs = 2_000) {
34
+ const deadline = Date.now() + timeoutMs
35
+ while (Date.now() < deadline) {
36
+ if (await predicate()) return
37
+ await new Promise((resolve) => setTimeout(resolve, 10))
38
+ }
39
+ throw new Error(`timed out waiting for ${description}`)
40
+ }
41
+
42
+ {
43
+ const events = controllableStream()
44
+ const commands = []
45
+ const ctx = {
46
+ event: { subscribe: () => events.stream },
47
+ session: {
48
+ prompt: async () => ({ accepted: true }),
49
+ command: async (request) => {
50
+ commands.push(structuredClone(request))
51
+ return { accepted: true }
52
+ },
53
+ },
54
+ }
55
+ const adapter = createOpenCode2RuntimeAdapter(ctx)
56
+ await adapter.start()
57
+ const result = await adapter.command({ sessionID: "ses_adapter", command: "/review", arguments: "--quick" })
58
+ assert.deepEqual(result, { accepted: true })
59
+ assert.deepEqual(commands, [{ sessionID: "ses_adapter", command: "review", arguments: "--quick" }])
60
+ assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false)
61
+ await adapter.dispose()
62
+ }
63
+
64
+ {
65
+ const events = controllableStream()
66
+ const adapter = createOpenCode2RuntimeAdapter({
67
+ event: { subscribe: () => events.stream },
68
+ session: { prompt: async () => ({ accepted: true }) },
69
+ })
70
+ await adapter.start()
71
+ await assert.rejects(adapter.command({ sessionID: "ses_adapter", command: "review" }), /session\.command capability is unavailable/)
72
+ await adapter.dispose()
73
+ }
74
+
75
+ {
76
+ const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-command-wiring-"))
77
+ const sessionID = "ses_v2_command_wiring"
78
+ const events = controllableStream()
79
+ const commands = []
80
+ const ctx = {
81
+ command: { transform: async () => ({ dispose: async () => {} }) },
82
+ event: { subscribe: () => events.stream },
83
+ session: {
84
+ prompt: async () => ({ accepted: true }),
85
+ command: async (request) => {
86
+ commands.push(structuredClone(request))
87
+ return { accepted: true }
88
+ },
89
+ },
90
+ }
91
+ const cleanup = await OpenCodeLoopV2ExperimentalPlugin.setup(ctx)
92
+ try {
93
+ events.push({
94
+ directory,
95
+ payload: {
96
+ type: "command.executed",
97
+ properties: {
98
+ sessionID,
99
+ name: "loop",
100
+ arguments: '0s --max-runs 1 --command "/review --quick"',
101
+ },
102
+ },
103
+ })
104
+ events.push({ directory, payload: { type: "session.idle", properties: { sessionID } } })
105
+ await waitFor(() => commands.length === 1, "V2 command dispatch")
106
+ assert.deepEqual(commands[0], { sessionID, command: "review", arguments: "--quick" })
107
+ } finally {
108
+ await cleanup?.()
109
+ await rm(directory, { recursive: true, force: true })
110
+ }
111
+ }
112
+
113
+ console.log("OpenCode 2 command adapter tests passed")
@@ -0,0 +1,98 @@
1
+ import assert from "node:assert/strict"
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import { createOpenCode2PromptRuntime } from "../src/source/opencode2/prompt-runtime.js"
6
+
7
+ const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-command-"))
8
+ const sessionID = "ses_v2_command_runtime"
9
+ const prompts = []
10
+ const commands = []
11
+ const runtime = createOpenCode2PromptRuntime({
12
+ prompt: async (request) => { prompts.push(request) },
13
+ command: async (request) => { commands.push(request); return { accepted: true } },
14
+ })
15
+
16
+ async function readState() {
17
+ const file = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
18
+ return JSON.parse(await readFile(file, "utf8"))
19
+ }
20
+
21
+ try {
22
+ const added = await runtime.onEvent({
23
+ kind: "command",
24
+ action: "executed",
25
+ name: "loop",
26
+ sessionID,
27
+ directory,
28
+ arguments: '0s --max-runs 2 --command "/review --quick"',
29
+ })
30
+ assert.equal(added.accepted, true)
31
+ assert.equal(added.job.kind, "command")
32
+ assert.equal(added.job.action, "/review --quick")
33
+
34
+ const first = await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })
35
+ assert.equal(first.dispatched, true)
36
+ assert.equal(first.kind, "command")
37
+ assert.deepEqual(first.request, { sessionID, command: "review", arguments: "--quick" })
38
+ assert.deepEqual(commands, [{ sessionID, command: "review", arguments: "--quick" }])
39
+ assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false, "OpenCode 2 id is a msg_ message ID, never the command name")
40
+ assert.equal(prompts.length, 0)
41
+ let state = await readState()
42
+ assert.equal(state.jobs[0].runCount, 1)
43
+ assert.equal(state.jobs[0].enabled, true)
44
+
45
+ const second = await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })
46
+ assert.equal(second.dispatched, true)
47
+ assert.deepEqual(commands[1], { sessionID, command: "review", arguments: "--quick" })
48
+ state = await readState()
49
+ assert.equal(state.jobs[0].runCount, 2)
50
+ assert.equal(state.jobs[0].enabled, false)
51
+
52
+ const third = await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })
53
+ assert.equal(third.dispatched, false)
54
+ assert.equal(commands.length, 2)
55
+
56
+ const compact = await runtime.onEvent({
57
+ kind: "command",
58
+ action: "executed",
59
+ name: "loop",
60
+ sessionID,
61
+ directory,
62
+ arguments: '0s --command "/compact"',
63
+ })
64
+ assert.equal(compact.accepted, false)
65
+ assert.ok(compact.blockers.includes("kind"), "shared actionKind keeps /compact on the dedicated compact path")
66
+
67
+ const shell = await runtime.onEvent({
68
+ kind: "command",
69
+ action: "executed",
70
+ name: "loop",
71
+ sessionID,
72
+ directory,
73
+ arguments: '0s --shell "echo hello"',
74
+ })
75
+ assert.equal(shell.accepted, false)
76
+ assert.ok(shell.blockers.includes("kind"), "shell actions remain unsupported because OpenCode 2 plugin ctx has no session.shell")
77
+
78
+ const noCommandRuntime = createOpenCode2PromptRuntime({ prompt: async () => {} })
79
+ try {
80
+ const unavailable = await noCommandRuntime.onEvent({
81
+ kind: "command",
82
+ action: "executed",
83
+ name: "loop",
84
+ sessionID: "ses_no_command",
85
+ directory,
86
+ arguments: '0s --command "/review"',
87
+ })
88
+ assert.equal(unavailable.accepted, false)
89
+ assert.ok(unavailable.blockers.includes("command-capability"))
90
+ } finally {
91
+ await noCommandRuntime.dispose()
92
+ }
93
+
94
+ console.log("OpenCode 2 command runtime tests passed")
95
+ } finally {
96
+ await runtime.dispose()
97
+ await rm(directory, { recursive: true, force: true })
98
+ }
@@ -0,0 +1,74 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCode2DiagnosticsRuntime, OPENCODE_LOOP_V2_HELP_TEXT } from "../src/source/opencode2/diagnostics.js"
3
+
4
+ const prompts = []
5
+ const reads = []
6
+ const state = {
7
+ version: 1,
8
+ jobs: [{ id: "job_export", name: "exported", runCount: 2, paused: false }],
9
+ }
10
+ const runtime = createOpenCode2DiagnosticsRuntime({
11
+ prompt: async (request) => { prompts.push(request) },
12
+ readState: async (directory, sessionID) => {
13
+ reads.push([directory, sessionID])
14
+ return structuredClone(state)
15
+ },
16
+ runtimeVersion: "v-test",
17
+ runtimePlatform: "test-platform",
18
+ })
19
+
20
+ const unrelated = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-status", sessionID: "ses", directory: "/work" })
21
+ assert.equal(unrelated.handled, false)
22
+ assert.equal(prompts.length, 0)
23
+
24
+ const missing = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-export", sessionID: "ses" })
25
+ assert.equal(missing.handled, false)
26
+ assert.equal(missing.reason, "missing-scope")
27
+ assert.equal(prompts.length, 0)
28
+
29
+ const exported = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-export", sessionID: "ses", directory: "/work" })
30
+ assert.equal(exported.handled, true)
31
+ assert.equal(exported.accepted, true)
32
+ assert.deepEqual(reads, [["/work", "ses"]])
33
+ assert.equal(prompts.length, 1)
34
+ assert.equal(prompts[0].sessionID, "ses")
35
+ assert.equal(prompts[0].noReply, true)
36
+ assert.match(prompts[0].text, /^OpenCode loop state export:\n```json\n/)
37
+ assert.match(prompts[0].text, /"name": "exported"/)
38
+ assert.match(prompts[0].text, /"runCount": 2/)
39
+ assert.match(prompts[0].text, /\n```$/)
40
+
41
+ const help = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-help", sessionID: "ses", directory: "/work" })
42
+ assert.equal(help.handled, true)
43
+ assert.equal(help.accepted, true)
44
+ assert.equal(reads.length, 1, "loop-help must not read or mutate Loop state")
45
+ assert.equal(prompts.length, 2)
46
+ assert.equal(prompts[1].noReply, true)
47
+ assert.equal(prompts[1].text, OPENCODE_LOOP_V2_HELP_TEXT)
48
+ assert.match(prompts[1].text, /^OpenCode Loop V2 experimental help:/)
49
+ assert.match(prompts[1].text, /\/loop-status/)
50
+ assert.match(prompts[1].text, /\/loop-export/)
51
+ assert.match(prompts[1].text, /\/loop-doctor/)
52
+ assert.match(prompts[1].text, /does not yet claim full stable-plugin parity/)
53
+ assert.doesNotMatch(prompts[1].text, /\/loop-goal/)
54
+ assert.doesNotMatch(prompts[1].text, /--verify/)
55
+
56
+ const doctor = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-doctor", sessionID: "ses", directory: "/work" })
57
+ assert.equal(doctor.handled, true)
58
+ assert.equal(doctor.accepted, true)
59
+ assert.equal(reads.length, 2, "loop-doctor may read state but must not mutate it")
60
+ assert.equal(prompts.length, 3)
61
+ assert.equal(prompts[2].noReply, true)
62
+ assert.match(prompts[2].text, /^OpenCode Loop V2 doctor:/)
63
+ assert.match(prompts[2].text, /- plugin: bybrawe\.opencode-loop\.v2\.experimental/)
64
+ assert.match(prompts[2].text, /- project directory: \/work/)
65
+ assert.match(prompts[2].text, /- state directory:/)
66
+ assert.match(prompts[2].text, /- active jobs: 1/)
67
+ assert.match(prompts[2].text, /- node: v-test/)
68
+ assert.match(prompts[2].text, /- platform: test-platform/)
69
+ assert.match(prompts[2].text, /- full stable parity: not claimed/)
70
+ assert.match(prompts[2].text, /- smoke test: \/loop 0s --max-runs 1/)
71
+
72
+ assert.throws(() => createOpenCode2DiagnosticsRuntime({}), /requires prompt\(\)/)
73
+
74
+ console.log("OpenCode 2 diagnostics contract passed")
@@ -4,6 +4,7 @@ import { createOpenCode2HostContract } from "../src/source/opencode2/host-contra
4
4
  let listener
5
5
  let unsubscribeCalls = 0
6
6
  const prompts = []
7
+ const commands = []
7
8
  const events = []
8
9
  const host = createOpenCode2HostContract({
9
10
  directory: "/project",
@@ -15,10 +16,15 @@ const host = createOpenCode2HostContract({
15
16
  prompts.push(request)
16
17
  return { accepted: true }
17
18
  },
19
+ sendCommand: async (request) => {
20
+ commands.push(request)
21
+ return { accepted: true, kind: "command" }
22
+ },
18
23
  onEvent: async (event, runtime) => events.push({ event, runtime }),
19
24
  })
20
25
 
21
26
  await assert.rejects(host.prompt({ sessionID: "ses_a", text: "early" }), /not started/)
27
+ await assert.rejects(host.command({ sessionID: "ses_a", command: "review" }), /not started/)
22
28
  assert.equal(await host.start(), true)
23
29
  assert.equal(await host.start(), false)
24
30
  assert.equal(typeof listener, "function")
@@ -37,8 +43,28 @@ assert.equal(prompts[0].sessionID, "ses_a")
37
43
  assert.equal(prompts[0].text, "continue")
38
44
  assert.equal(prompts[0].runtime, runtime)
39
45
 
46
+ const commandResult = await host.command({ sessionID: "ses_a", command: "/review", arguments: "--quick" })
47
+ assert.deepEqual(commandResult, { accepted: true, kind: "command" })
48
+ assert.equal(commands.length, 1)
49
+ assert.equal(commands[0].sessionID, "ses_a")
50
+ assert.equal(commands[0].command, "review")
51
+ assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false)
52
+ assert.equal(commands[0].arguments, "--quick")
53
+ assert.equal(commands[0].runtime, runtime)
54
+
40
55
  await assert.rejects(host.prompt({ sessionID: "", text: "missing" }), /session ID/)
41
56
  await assert.rejects(host.prompt({ sessionID: "ses_a", text: " " }), /requires text/)
57
+ await assert.rejects(host.command({ sessionID: "", command: "review" }), /session ID/)
58
+ await assert.rejects(host.command({ sessionID: "ses_a", command: "" }), /requires a command name/)
59
+ await assert.rejects(host.command({ sessionID: "ses_a", id: "review" }), /requires a command name/)
60
+
61
+ const promptOnly = createOpenCode2HostContract({
62
+ subscribe: async (_callback) => ({ unsubscribe: async () => {} }),
63
+ sendPrompt: async () => ({ accepted: true }),
64
+ })
65
+ await promptOnly.start()
66
+ await assert.rejects(promptOnly.command({ sessionID: "ses_a", command: "review" }), /session\.command capability is unavailable/)
67
+ await promptOnly.dispose()
42
68
 
43
69
  await listener({
44
70
  directory: "/project",
@@ -48,6 +74,7 @@ assert.equal(host.isHostDisposed(), true)
48
74
  assert.deepEqual(host.runtimeManager.entries(), [])
49
75
  assert.ok(events.some(({ event }) => event.kind === "server" && event.action === "disposed"))
50
76
  await assert.rejects(host.prompt({ sessionID: "ses_a", text: "late" }), /unavailable/)
77
+ await assert.rejects(host.command({ sessionID: "ses_a", command: "review" }), /unavailable/)
51
78
 
52
79
  assert.equal(await host.dispose(), true)
53
80
  assert.equal(await host.dispose(), false)