@bybrawe/opencode-loop 0.5.36 → 0.5.38
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 +26 -0
- package/README.md +1 -1
- package/package.json +7 -6
- package/scripts/comprehensive-test.mjs +7 -4
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- package/scripts/install-node.mjs +4 -2
- package/scripts/install-test.mjs +5 -0
- package/scripts/network-recovery-test.mjs +38 -1
- package/scripts/opencode2-loop-canary.mjs +48 -7
- package/scripts/publish-workflow-test.mjs +5 -0
- package/scripts/run-finalization-test.mjs +33 -0
- package/scripts/server-entry-test.mjs +15 -0
- package/scripts/session-error-recovery-test.mjs +186 -0
- package/scripts/session-status-test.mjs +30 -0
- package/scripts/smoke-test.mjs +21 -0
- package/scripts/v2-command-adapter-test.mjs +14 -2
- package/scripts/v2-plugin-sentinel-test.mjs +35 -4
- package/scripts/v2-runtime-adapter-test.mjs +75 -0
- package/src/index.js +257 -96
- package/src/server.js +6451 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/legacy-v1.js +34 -6
- package/src/source/opencode2/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +44 -8
- package/src/source/opencode2/events.js +11 -0
- package/src/source/opencode2/experimental.js +51 -7
- package/src/source/opencode2/prompt-runtime.js +36 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +5 -1
- package/src/source/runtime/terminal-guard.js +32 -7
- package/src/source/server.js +18 -0
|
@@ -157,6 +157,36 @@ try {
|
|
|
157
157
|
completion = "unknown"
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
{
|
|
161
|
+
const sessionID = "concurrent-recovery-log-dedupe"
|
|
162
|
+
track(sessionID)
|
|
163
|
+
runtime.clearSessionStatus(sessionID)
|
|
164
|
+
activeRuns.set(sessionID, { jobId: "job-dedupe", job: { id: "job-dedupe", name: "dedupe" }, startedAt: 1_000 })
|
|
165
|
+
completion = "completed"
|
|
166
|
+
let releaseStatus
|
|
167
|
+
const gate = new Promise((resolve) => { releaseStatus = resolve })
|
|
168
|
+
let calls = 0
|
|
169
|
+
const client = {
|
|
170
|
+
session: {
|
|
171
|
+
status: async () => {
|
|
172
|
+
calls += 1
|
|
173
|
+
await gate
|
|
174
|
+
return { data: { [sessionID]: { type: "busy" } } }
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
const before = logs.filter((entry) => entry[1] === "status-message-complete-recovery" && entry[2]?.sessionID === sessionID).length
|
|
179
|
+
const pending = Array.from({ length: 20 }, () => runtime.sessionStatusType(client, sessionID, "/repo"))
|
|
180
|
+
await Promise.resolve()
|
|
181
|
+
assert.equal(calls, 20, "all concurrent status reads should be in flight before release")
|
|
182
|
+
releaseStatus()
|
|
183
|
+
assert.deepEqual(await Promise.all(pending), Array(20).fill("idle"))
|
|
184
|
+
const after = logs.filter((entry) => entry[1] === "status-message-complete-recovery" && entry[2]?.sessionID === sessionID).length
|
|
185
|
+
assert.equal(after - before, 1, "one active run must emit at most one copy of the same stale-recovery diagnostic")
|
|
186
|
+
activeRuns.delete(sessionID)
|
|
187
|
+
completion = "unknown"
|
|
188
|
+
}
|
|
189
|
+
|
|
160
190
|
{
|
|
161
191
|
const sessionID = "retry-remains-owned"
|
|
162
192
|
track(sessionID)
|
package/scripts/smoke-test.mjs
CHANGED
|
@@ -86,6 +86,27 @@ try {
|
|
|
86
86
|
"opencode_loop_goal_progress",
|
|
87
87
|
])
|
|
88
88
|
|
|
89
|
+
const dedicatedGoalRoot = path.join(directory, ".opencode", "goals")
|
|
90
|
+
await fs.mkdir(dedicatedGoalRoot, { recursive: true })
|
|
91
|
+
await fs.writeFile(path.join(dedicatedGoalRoot, "dedicated.json"), JSON.stringify({
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
id: "dedicated-goal",
|
|
94
|
+
sessionID,
|
|
95
|
+
status: "active",
|
|
96
|
+
}), "utf8")
|
|
97
|
+
for (const [name, args] of [
|
|
98
|
+
["opencode_loop_goal_blocked", { reason: "Need deploy", needed: "User deploy" }],
|
|
99
|
+
["opencode_loop_goal_progress", { summary: "Checked status", next: "Continue" }],
|
|
100
|
+
["opencode_loop_goal_complete", { summary: "Done", evidence: "tests passed" }],
|
|
101
|
+
]) {
|
|
102
|
+
const guarded = await hooks.tool[name].execute(args, { directory, sessionID })
|
|
103
|
+
assert.equal(guarded.title, "Dedicated Goal owns lifecycle")
|
|
104
|
+
assert.match(guarded.output, /opencode_loop_goal_\*/)
|
|
105
|
+
assert.match(guarded.output, /opencode_goal_wait_for_user/)
|
|
106
|
+
assert.match(guarded.output, /opencode_goal_blocked/)
|
|
107
|
+
}
|
|
108
|
+
await fs.rm(dedicatedGoalRoot, { recursive: true, force: true })
|
|
109
|
+
|
|
89
110
|
const output = { parts: [{ type: "text", text: "original command body" }] }
|
|
90
111
|
await hooks["command.execute.before"]({
|
|
91
112
|
command: "loop-goal",
|
|
@@ -56,7 +56,13 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
|
|
|
56
56
|
await adapter.start()
|
|
57
57
|
const result = await adapter.command({ sessionID: "ses_adapter", command: "/review", arguments: "--quick" })
|
|
58
58
|
assert.deepEqual(result, { accepted: true })
|
|
59
|
-
assert.deepEqual(commands, [{
|
|
59
|
+
assert.deepEqual(commands, [{
|
|
60
|
+
sessionID: "ses_adapter",
|
|
61
|
+
name: "review",
|
|
62
|
+
command: "review",
|
|
63
|
+
arguments: "--quick",
|
|
64
|
+
text: "--quick",
|
|
65
|
+
}])
|
|
60
66
|
assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false)
|
|
61
67
|
await adapter.dispose()
|
|
62
68
|
}
|
|
@@ -103,7 +109,13 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
|
|
|
103
109
|
})
|
|
104
110
|
events.push({ directory, payload: { type: "session.idle", properties: { sessionID } } })
|
|
105
111
|
await waitFor(() => commands.length === 1, "V2 command dispatch")
|
|
106
|
-
assert.deepEqual(commands[0], {
|
|
112
|
+
assert.deepEqual(commands[0], {
|
|
113
|
+
sessionID,
|
|
114
|
+
name: "review",
|
|
115
|
+
command: "review",
|
|
116
|
+
arguments: "--quick",
|
|
117
|
+
text: "--quick",
|
|
118
|
+
})
|
|
107
119
|
} finally {
|
|
108
120
|
await cleanup?.()
|
|
109
121
|
await rm(directory, { recursive: true, force: true })
|
|
@@ -3,7 +3,7 @@ import plugin, {
|
|
|
3
3
|
OPENCODE_LOOP_V2_COMMANDS,
|
|
4
4
|
OPENCODE_LOOP_V2_PLUGIN_ID,
|
|
5
5
|
} from "../src/source/opencode2/experimental.js"
|
|
6
|
-
import { parseOpenCode2LoopCommandText } from "../src/source/opencode2/commands.js"
|
|
6
|
+
import { parseOpenCode2LoopCommandText, registerOpenCode2LoopCommands } from "../src/source/opencode2/commands.js"
|
|
7
7
|
import {
|
|
8
8
|
OPENCODE_LOOP_V2_COMMAND_SOURCE,
|
|
9
9
|
OPENCODE_LOOP_V2_HOST_REQUIREMENTS,
|
|
@@ -77,13 +77,43 @@ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
|
|
|
77
77
|
assert.equal(command?.description, definition.description)
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
let
|
|
80
|
+
let missingDraftMutationFailed = false
|
|
81
81
|
try {
|
|
82
82
|
await registered({})
|
|
83
83
|
} catch (error) {
|
|
84
|
-
|
|
84
|
+
missingDraftMutationFailed = /neither add\(\) nor update\(\)/.test(String(error))
|
|
85
85
|
}
|
|
86
|
-
assert.equal(
|
|
86
|
+
assert.equal(missingDraftMutationFailed, true)
|
|
87
|
+
|
|
88
|
+
const addedCommands = new Map()
|
|
89
|
+
const executedCommands = []
|
|
90
|
+
registerOpenCode2LoopCommands({
|
|
91
|
+
add(definition) {
|
|
92
|
+
addedCommands.set(definition.name, definition)
|
|
93
|
+
},
|
|
94
|
+
}, {
|
|
95
|
+
execute: async (event) => {
|
|
96
|
+
executedCommands.push(structuredClone(event))
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
assert.deepEqual([...addedCommands.keys()], Object.keys(OPENCODE_LOOP_V2_COMMANDS))
|
|
100
|
+
for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
|
|
101
|
+
const command = addedCommands.get(name)
|
|
102
|
+
assert.equal(command?.name, name)
|
|
103
|
+
assert.equal(command?.description, definition.description)
|
|
104
|
+
assert.equal(typeof command?.execute, "function")
|
|
105
|
+
}
|
|
106
|
+
await addedCommands.get("loop").execute({
|
|
107
|
+
sessionID: "ses_2_0_11",
|
|
108
|
+
prompt: { text: "devam et" },
|
|
109
|
+
delivery: "steer",
|
|
110
|
+
})
|
|
111
|
+
assert.deepEqual(executedCommands, [{
|
|
112
|
+
name: "loop",
|
|
113
|
+
sessionID: "ses_2_0_11",
|
|
114
|
+
arguments: "devam et",
|
|
115
|
+
delivery: "steer",
|
|
116
|
+
}])
|
|
87
117
|
|
|
88
118
|
assert.deepEqual(inspectOpenCode2Context(currentContext), {
|
|
89
119
|
commandTransform: true,
|
|
@@ -98,6 +128,7 @@ assert.deepEqual(inspectOpenCode2Context(currentContext), {
|
|
|
98
128
|
assert.deepEqual(inspectOpenCode2CommandDraft(currentDraft), {
|
|
99
129
|
list: true,
|
|
100
130
|
get: true,
|
|
131
|
+
add: false,
|
|
101
132
|
update: true,
|
|
102
133
|
remove: true,
|
|
103
134
|
})
|
|
@@ -164,6 +164,8 @@ async function verifyTwoTurnLoop({ directory, sessionID, events, prompts, native
|
|
|
164
164
|
sessionID: "ses_v2",
|
|
165
165
|
noReply: true,
|
|
166
166
|
parts: [{ type: "text", text: "OpenCode loop status:\nNo active loop jobs." }],
|
|
167
|
+
text: "OpenCode loop status:\nNo active loop jobs.",
|
|
168
|
+
resume: false,
|
|
167
169
|
agent: "build",
|
|
168
170
|
model: { providerID: "canary", modelID: "canary" },
|
|
169
171
|
})
|
|
@@ -301,6 +303,79 @@ async function verifyTwoTurnLoop({ directory, sessionID, events, prompts, native
|
|
|
301
303
|
}
|
|
302
304
|
}
|
|
303
305
|
|
|
306
|
+
{
|
|
307
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-2-0-11-command-add-"))
|
|
308
|
+
const sessionID = "ses_v2_2_0_11_add"
|
|
309
|
+
const events = controllableStream()
|
|
310
|
+
const prompts = []
|
|
311
|
+
const commands = new Map()
|
|
312
|
+
const ctx = {
|
|
313
|
+
app: { name: "opencode", version: "2.0.11", channel: "latest" },
|
|
314
|
+
location: { directory },
|
|
315
|
+
command: {
|
|
316
|
+
transform: async (callback) => {
|
|
317
|
+
await callback({
|
|
318
|
+
add(definition) {
|
|
319
|
+
commands.set(definition.name, definition)
|
|
320
|
+
},
|
|
321
|
+
})
|
|
322
|
+
return { dispose: async () => {} }
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
event: {
|
|
326
|
+
// OpenCode 2.0.11 exposes subscribe(options), so Function.length === 1
|
|
327
|
+
// even though it returns an AsyncIterable rather than accepting a callback.
|
|
328
|
+
subscribe(options) {
|
|
329
|
+
assert.equal(options, undefined)
|
|
330
|
+
return events.stream
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
session: {
|
|
334
|
+
prompt: async (input) => {
|
|
335
|
+
prompts.push(structuredClone(input))
|
|
336
|
+
return { accepted: true }
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const cleanup = await OpenCodeLoopV2ExperimentalPlugin.setup(ctx)
|
|
342
|
+
try {
|
|
343
|
+
assert.ok(commands.has("loop"), "OpenCode 2.0.11 add() command editor must receive /loop")
|
|
344
|
+
await commands.get("loop").execute({
|
|
345
|
+
sessionID,
|
|
346
|
+
prompt: { text: "0s --max-runs 2 continue exact 2.0.11 command path" },
|
|
347
|
+
delivery: "steer",
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
const stateFile = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
|
|
351
|
+
await waitFor(async () => {
|
|
352
|
+
try { return JSON.parse(await readFile(stateFile, "utf8")).jobs?.length === 1 } catch { return false }
|
|
353
|
+
}, "OpenCode 2.0.11 direct command state creation")
|
|
354
|
+
|
|
355
|
+
await waitFor(() => prompts.length === 1, "OpenCode 2.0.11 direct command first local-kick dispatch")
|
|
356
|
+
assert.equal(prompts[0].sessionID, sessionID)
|
|
357
|
+
assert.match(prompts[0].text || "", /continue exact 2\.0\.11 command path/)
|
|
358
|
+
|
|
359
|
+
events.push({
|
|
360
|
+
type: "session.status",
|
|
361
|
+
location: { directory },
|
|
362
|
+
data: { sessionID, status: { type: "busy" } },
|
|
363
|
+
})
|
|
364
|
+
events.push({
|
|
365
|
+
type: "session.status",
|
|
366
|
+
location: { directory },
|
|
367
|
+
data: { sessionID, status: { type: "idle" } },
|
|
368
|
+
})
|
|
369
|
+
await waitFor(() => prompts.length === 2, "OpenCode 2.0.11 second dispatch from native idle status")
|
|
370
|
+
const state = JSON.parse(await readFile(stateFile, "utf8"))
|
|
371
|
+
assert.equal(state.jobs[0].runCount, 2)
|
|
372
|
+
assert.equal(state.jobs[0].enabled, false)
|
|
373
|
+
} finally {
|
|
374
|
+
await cleanup?.()
|
|
375
|
+
await rm(directory, { recursive: true, force: true })
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
304
379
|
{
|
|
305
380
|
let commandDisposals = 0
|
|
306
381
|
const result = await OpenCodeLoopV2ExperimentalPlugin.setup({
|