@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.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/package.json +4 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +11 -3
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +54 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-host-contract-test.mjs +27 -0
- package/scripts/v2-plugin-sentinel-test.mjs +57 -8
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +2751 -1594
- package/src/source/legacy-v1.js +168 -1860
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode2/capabilities.js +3 -1
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +19 -1
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +32 -1
- package/src/source/opencode2/host-contract.js +26 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-status.js +197 -0
- 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, {
|
|
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, "
|
|
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: () =>
|
|
39
|
-
update: () => {
|
|
40
|
-
|
|
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, "
|
|
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: {
|
|
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 {
|