@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,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
+ }
@@ -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)
@@ -1,5 +1,9 @@
1
1
  import assert from "node:assert/strict"
2
- import plugin, { OPENCODE_LOOP_V2_PLUGIN_ID } from "../src/source/opencode2/experimental.js"
2
+ import plugin, {
3
+ OPENCODE_LOOP_V2_COMMANDS,
4
+ OPENCODE_LOOP_V2_PLUGIN_ID,
5
+ } from "../src/source/opencode2/experimental.js"
6
+ import { parseOpenCode2LoopCommandText } from "../src/source/opencode2/commands.js"
3
7
  import {
4
8
  OPENCODE_LOOP_V2_COMMAND_SOURCE,
5
9
  OPENCODE_LOOP_V2_HOST_REQUIREMENTS,
@@ -13,10 +17,24 @@ import {
13
17
  assert.equal(plugin.id, OPENCODE_LOOP_V2_PLUGIN_ID)
14
18
  assert.equal(plugin.id, "bybrawe.opencode-loop.v2.experimental")
15
19
  assert.equal(typeof plugin.setup, "function")
16
- assert.equal(OPENCODE_LOOP_V2_COMMAND_SOURCE, "file-definitions")
20
+ assert.equal(OPENCODE_LOOP_V2_COMMAND_SOURCE, "plugin-transform")
17
21
  assert.equal(OPENCODE_LOOP_V2_RUNTIME_IMPLEMENTED, false)
18
22
  assert.deepEqual(OPENCODE_LOOP_V2_HOST_REQUIREMENTS, ["event.subscribe", "session.prompt"])
19
23
  assert.deepEqual(OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS, ["event.subscribe", "session.prompt", "runtime.adapter"])
24
+ assert.deepEqual(Object.keys(OPENCODE_LOOP_V2_COMMANDS), [
25
+ "loop",
26
+ "loop-pause",
27
+ "loop-resume",
28
+ "loop-stop",
29
+ "loop-remove",
30
+ "loop-clear",
31
+ "loop-status",
32
+ "loop-now",
33
+ ])
34
+ assert.deepEqual(
35
+ parseOpenCode2LoopCommandText(`${OPENCODE_LOOP_V2_COMMANDS["loop-now"].template}\n\nsecond`),
36
+ { name: "loop-now", arguments: "second" },
37
+ )
20
38
 
21
39
  let transforms = 0
22
40
  let registered
@@ -33,19 +51,43 @@ assert.equal(setupResult, undefined)
33
51
  assert.equal(transforms, 1)
34
52
  assert.equal(typeof registered, "function")
35
53
 
54
+ const commands = new Map()
36
55
  const currentDraft = {
37
- list: () => [],
38
- get: () => undefined,
39
- update: () => {},
40
- remove: () => {},
56
+ list: () => [...commands.values()],
57
+ get: (name) => commands.get(name),
58
+ update: (name, update) => {
59
+ const command = commands.get(name) ?? { name, template: "" }
60
+ update(command)
61
+ command.name = name
62
+ commands.set(name, command)
63
+ },
64
+ remove: (name) => commands.delete(name),
41
65
  }
42
66
  await registered(currentDraft)
43
67
 
68
+ assert.deepEqual([...commands.keys()], Object.keys(OPENCODE_LOOP_V2_COMMANDS))
69
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
70
+ const command = commands.get(name)
71
+ assert.equal(command?.name, name)
72
+ assert.equal(command?.template, definition.template)
73
+ assert.equal(command?.description, definition.description)
74
+ }
75
+
76
+ let missingDraftUpdateFailed = false
77
+ try {
78
+ await registered({})
79
+ } catch (error) {
80
+ missingDraftUpdateFailed = /command draft\.update capability is unavailable/.test(String(error))
81
+ }
82
+ assert.equal(missingDraftUpdateFailed, true)
83
+
44
84
  assert.deepEqual(inspectOpenCode2Context(currentContext), {
45
85
  commandTransform: true,
46
86
  eventSubscribe: false,
47
87
  sessionHook: false,
48
88
  sessionPrompt: false,
89
+ sessionCommand: false,
90
+ sessionShell: false,
49
91
  toolTransform: false,
50
92
  toolHook: false,
51
93
  })
@@ -62,19 +104,26 @@ assert.deepEqual(currentStatus.blockers, ["event.subscribe", "session.prompt", "
62
104
  assert.equal(currentStatus.hostReady, false)
63
105
  assert.equal(currentStatus.implementationReady, false)
64
106
  assert.equal(currentStatus.ready, false)
65
- assert.equal(currentStatus.commandSource, "file-definitions")
107
+ assert.equal(currentStatus.commandSource, "plugin-transform")
66
108
 
67
109
  const futureContext = {
68
110
  command: { transform: async () => {} },
69
111
  event: { subscribe: async () => {} },
70
- session: { prompt: async () => ({}) },
112
+ session: {
113
+ prompt: async () => ({}),
114
+ command: async () => ({}),
115
+ },
71
116
  }
117
+ const futureCapabilities = inspectOpenCode2Context(futureContext)
118
+ assert.equal(futureCapabilities.sessionCommand, true)
119
+ assert.equal(futureCapabilities.sessionShell, false)
72
120
  const futureStatus = openCode2LoopRuntimeStatus(futureContext, currentDraft)
73
121
  assert.deepEqual(futureStatus.hostBlockers, [])
74
122
  assert.deepEqual(futureStatus.blockers, ["runtime.adapter"])
75
123
  assert.equal(futureStatus.hostReady, true)
76
124
  assert.equal(futureStatus.implementationReady, false)
77
125
  assert.equal(futureStatus.ready, false)
126
+ assert.equal(futureStatus.commandSource, "plugin-transform")
78
127
 
79
128
  let missingCapabilityFailed = false
80
129
  try {