@bybrawe/opencode-loop 0.5.26 → 0.5.28
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 +10 -0
- package/README.md +9 -3
- package/package.json +8 -4
- package/scripts/comprehensive-test.mjs +49 -4
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-bridge-parity-test.mjs +22 -0
- package/scripts/host-loop-canary.mjs +357 -0
- package/scripts/loopd-test.mjs +45 -18
- package/scripts/loopd.mjs +92 -22
- package/scripts/opencode2-host-canary.mjs +194 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/session-manager-test.mjs +48 -0
- package/scripts/v2-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +55 -0
- package/scripts/v2-plugin-sentinel-test.mjs +87 -0
- package/src/index.js +2304 -1864
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +1944 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +69 -0
- package/src/source/opencode2/event-bridge.js +156 -0
- package/src/source/opencode2/experimental.js +16 -0
- package/src/source/opencode2/host-contract.js +68 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/timers.js +45 -0
- package/src/source/v1.js +69 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.27
|
|
4
|
+
|
|
5
|
+
- Rebase stale Loop state snapshots against the latest persisted job state before writing, preventing scheduler bookkeeping from erasing unrelated user pause/resume or other state changes.
|
|
6
|
+
- Add a deterministic concurrency regression that forces overlapping state reads/writes and proves a concurrent user pause survives a stale scheduler update.
|
|
7
|
+
- Harden `opencode-loopd` by pinning one exact OpenCode session across iterations instead of resolving `--continue` on every run; a fresh daemon session is created and then pinned when no existing session is available.
|
|
8
|
+
- Add per-run daemon timeouts (30 minutes by default, configurable with `--timeout`; `0s` disables) and timeout exit code 124, including descendant-process termination.
|
|
9
|
+
- Add daemon regressions for session pinning, timeout termination, and scheduled-task timeout propagation.
|
|
10
|
+
- Add a real OpenCode host canary that proves a three-run `/loop` performs exactly three autonomous turns on Ubuntu and Windows.
|
|
11
|
+
- Narrow the OpenCode plugin peer range to `>=1.4.0 <2` so a future breaking 2.x release is not treated as automatically compatible.
|
|
12
|
+
|
|
3
13
|
## 0.5.26
|
|
4
14
|
|
|
5
15
|
- Added an explicit npm-based OpenCode installation path: `npm install -g @bybrawe/opencode-loop@latest` followed by `opencode-loop`.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, safe long-running continuation helpers, and the `opencode-loopd` background daemon.
|
|
6
6
|
|
|
7
|
-
> **Current release: `0.5.
|
|
7
|
+
> **Current release: `0.5.27`.** Loop also contains an older experimental `/loop-goal` mode, but for strong persistent Goal contracts and host-verified completion, use the separate **OpenCode Goals** plugin described below.
|
|
8
8
|
|
|
9
9
|
## Install or update
|
|
10
10
|
|
|
@@ -304,7 +304,7 @@ Do not use a normal prompt loop when you mean to run `/compact`. Prefer:
|
|
|
304
304
|
|
|
305
305
|
## Background daemon
|
|
306
306
|
|
|
307
|
-
The normal `/loop` plugin is session-bound. If OpenCode closes, that TUI/session loop cannot keep running in the background.
|
|
307
|
+
The normal `/loop` plugin is session-bound. If OpenCode closes, that TUI/session loop cannot keep running in the background. `opencode-loopd` resolves the session once at startup and pins that exact session for later iterations, so a newer unrelated session cannot steal the daemon. If no session exists, the daemon creates one and pins it before the second iteration. Each daemon run is bounded by `--timeout` (30 minutes by default; use `--timeout 0s` to disable).
|
|
308
308
|
|
|
309
309
|
For long-running background jobs use:
|
|
310
310
|
|
|
@@ -321,7 +321,13 @@ opencode-loopd --project . --every 0s --prompt "continue from progress.md and im
|
|
|
321
321
|
Select a model and agent:
|
|
322
322
|
|
|
323
323
|
```bash
|
|
324
|
-
opencode-loopd --project . --every 0s --max-runs 1 --model provider/model --agent build --prompt-file loop-prompt.md
|
|
324
|
+
opencode-loopd --project . --every 0s --max-runs 1 --timeout 30m --model provider/model --agent build --prompt-file loop-prompt.md
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Pin a specific existing session when needed:
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
opencode-loopd --project . --session ses_xxx --every 5m --prompt-file loop-prompt.md
|
|
325
331
|
```
|
|
326
332
|
|
|
327
333
|
Limit total runs:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.28",
|
|
4
4
|
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -18,8 +18,12 @@
|
|
|
18
18
|
"CHANGELOG.md"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"
|
|
22
|
-
"
|
|
21
|
+
"build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
|
|
22
|
+
"build:plugin:npm": "npm run build:plugin",
|
|
23
|
+
"prepack": "node --check src/index.js",
|
|
24
|
+
"check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs",
|
|
25
|
+
"test": "node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
|
|
26
|
+
"canary:host": "node scripts/host-loop-canary.mjs",
|
|
23
27
|
"install:global": "node scripts/install-node.mjs",
|
|
24
28
|
"pack:zip": "node scripts/make-zip.mjs"
|
|
25
29
|
},
|
|
@@ -54,7 +58,7 @@
|
|
|
54
58
|
"url": "https://github.com/ByBrawe/opencode-loop/issues"
|
|
55
59
|
},
|
|
56
60
|
"peerDependencies": {
|
|
57
|
-
"@opencode-ai/plugin": ">=1.4.0"
|
|
61
|
+
"@opencode-ai/plugin": ">=1.4.0 <2"
|
|
58
62
|
},
|
|
59
63
|
"devDependencies": {
|
|
60
64
|
"@opencode-ai/plugin": "^1.18.15"
|
|
@@ -6,6 +6,18 @@ import path from "node:path"
|
|
|
6
6
|
import OpenCodeLoopPlugin from "../src/index.js"
|
|
7
7
|
|
|
8
8
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
9
|
+
|
|
10
|
+
async function waitForValue(read, timeoutMs = 1_000, intervalMs = 10) {
|
|
11
|
+
const deadline = Date.now() + timeoutMs
|
|
12
|
+
let value
|
|
13
|
+
while (Date.now() < deadline) {
|
|
14
|
+
value = await read()
|
|
15
|
+
if (value) return value
|
|
16
|
+
await delay(intervalMs)
|
|
17
|
+
}
|
|
18
|
+
return await read()
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
let harnessNumber = 0
|
|
10
22
|
|
|
11
23
|
async function createHarness(options = {}) {
|
|
@@ -405,9 +417,11 @@ async function testNativeCompactionLifecycleAndFallback() {
|
|
|
405
417
|
await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, compactOutput)
|
|
406
418
|
assert.deepEqual(compactOutput, { context: [], prompt: undefined }, "loop lifecycle tracking must not rewrite OpenCode's compaction prompt")
|
|
407
419
|
await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } })
|
|
408
|
-
await
|
|
409
|
-
|
|
410
|
-
|
|
420
|
+
const state = await waitForValue(async () => {
|
|
421
|
+
const candidate = await h.readState()
|
|
422
|
+
return candidate.jobs[0]?.lastFinishedAt > 0 ? candidate : undefined
|
|
423
|
+
})
|
|
424
|
+
assert.ok(state?.jobs[0]?.lastFinishedAt > 0, "session.compacted must finalize an explicit compact job without waiting for stale status recovery")
|
|
411
425
|
} finally {
|
|
412
426
|
await h.cleanup()
|
|
413
427
|
}
|
|
@@ -705,6 +719,36 @@ async function testStateReadRetriesTransientPartialJson() {
|
|
|
705
719
|
}
|
|
706
720
|
}
|
|
707
721
|
|
|
722
|
+
async function testStateRebasePreservesConcurrentPause() {
|
|
723
|
+
const h = await createHarness()
|
|
724
|
+
const originalRename = fs.rename
|
|
725
|
+
try {
|
|
726
|
+
await h.command("loop", "10m --no-now --name race continue safely")
|
|
727
|
+
h.statuses.set(h.sessionID, "busy")
|
|
728
|
+
let delayed = false
|
|
729
|
+
fs.rename = async (source, target, ...rest) => {
|
|
730
|
+
if (!delayed && path.resolve(target) === path.resolve(h.stateFile)) {
|
|
731
|
+
delayed = true
|
|
732
|
+
await delay(120)
|
|
733
|
+
}
|
|
734
|
+
return await originalRename.call(fs, source, target, ...rest)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const pause = h.command("loop-pause", "race")
|
|
738
|
+
await delay(20)
|
|
739
|
+
const runNow = h.command("loop-now", "race")
|
|
740
|
+
await Promise.all([pause, runNow])
|
|
741
|
+
|
|
742
|
+
const state = await h.readState()
|
|
743
|
+
const job = state.jobs.find((item) => item.name === "race")
|
|
744
|
+
assert.ok(job, "the concurrent state update must keep the loop job")
|
|
745
|
+
assert.equal(job.paused, true, "a stale scheduler snapshot must not erase a concurrent user pause")
|
|
746
|
+
} finally {
|
|
747
|
+
fs.rename = originalRename
|
|
748
|
+
await h.cleanup()
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
708
752
|
await testParserAndPresets()
|
|
709
753
|
await testLifecycleAndCommandDedupe()
|
|
710
754
|
await testWatchScheduling()
|
|
@@ -718,5 +762,6 @@ await testInitializationDoesNotWaitForLocalApi()
|
|
|
718
762
|
await testWindowsSafeStatePersistence()
|
|
719
763
|
await testWindowsStateRenameRetriesBeforeFallback()
|
|
720
764
|
await testStateReadRetriesTransientPartialJson()
|
|
765
|
+
await testStateRebasePreservesConcurrentPause()
|
|
721
766
|
|
|
722
|
-
console.log("OpenCode Loop comprehensive test passed")
|
|
767
|
+
console.log("OpenCode Loop comprehensive test passed")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
5
|
+
|
|
6
|
+
const scriptsDir = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const rootDir = path.resolve(scriptsDir, "..")
|
|
8
|
+
const sourcePath = path.join(scriptsDir, "comprehensive-test.mjs")
|
|
9
|
+
const timeoutMs = Math.max(5_000, Number(process.env.OPENCODE_LOOP_COMPREHENSIVE_TIMEOUT_MS) || 60_000)
|
|
10
|
+
const tempPath = path.join(os.tmpdir(), `opencode-loop-comprehensive-watchdog-${process.pid}-${Date.now()}.mjs`)
|
|
11
|
+
|
|
12
|
+
const source = await fs.readFile(sourcePath, "utf8")
|
|
13
|
+
const pluginURL = pathToFileURL(path.join(rootDir, "src", "index.js")).href
|
|
14
|
+
let instrumented = source.replace('import OpenCodeLoopPlugin from "../src/index.js"', `import OpenCodeLoopPlugin from ${JSON.stringify(pluginURL)}`)
|
|
15
|
+
instrumented = instrumented.replace(
|
|
16
|
+
/^await (test[A-Za-z0-9_]+)\(\)$/gm,
|
|
17
|
+
'globalThis.__opencodeLoopActiveCase = "$1"; await $1(); globalThis.__opencodeLoopActiveCase = undefined',
|
|
18
|
+
)
|
|
19
|
+
if (instrumented === source) throw new Error("failed to instrument comprehensive test")
|
|
20
|
+
|
|
21
|
+
await fs.writeFile(tempPath, instrumented, "utf8")
|
|
22
|
+
const watchdog = setTimeout(() => {
|
|
23
|
+
const active = globalThis.__opencodeLoopActiveCase
|
|
24
|
+
throw new Error(`OpenCode Loop comprehensive test timed out after ${timeoutMs}ms${active ? ` while running ${active}` : ""}`)
|
|
25
|
+
}, timeoutMs)
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
await import(`${pathToFileURL(tempPath).href}?run=${Date.now()}`)
|
|
29
|
+
} finally {
|
|
30
|
+
clearTimeout(watchdog)
|
|
31
|
+
try { await fs.rm(tempPath, { force: true }) } catch {}
|
|
32
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createOpenCodeHostAdapter } from "../src/source/opencode/adapter.js"
|
|
3
|
+
import {
|
|
4
|
+
activeRunCompletionFromMessages,
|
|
5
|
+
compactSession,
|
|
6
|
+
compactTuiCommandName,
|
|
7
|
+
executeTuiCommand,
|
|
8
|
+
orderedSessionMessages,
|
|
9
|
+
readRecentSessionMessages,
|
|
10
|
+
resolveCompactionModel,
|
|
11
|
+
} from "../src/source/opencode/host.js"
|
|
12
|
+
import {
|
|
13
|
+
deleteSessionExecutionContext,
|
|
14
|
+
getSessionExecutionContext,
|
|
15
|
+
} from "../src/source/opencode/session-context.js"
|
|
16
|
+
|
|
17
|
+
function messagesClient(messages) {
|
|
18
|
+
return { session: { messages: async () => ({ data: structuredClone(messages) }) } }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
assert.equal(compactTuiCommandName("/compact"), "session_compact")
|
|
22
|
+
assert.equal(compactTuiCommandName("summarize"), "session_compact")
|
|
23
|
+
assert.equal(compactTuiCommandName("other"), undefined)
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
const calls = []
|
|
27
|
+
const client = { tui: { executeCommand: async (args) => {
|
|
28
|
+
calls.push(args)
|
|
29
|
+
if (args?.body) return { error: { message: "body shape rejected" } }
|
|
30
|
+
return { data: args.command }
|
|
31
|
+
} } }
|
|
32
|
+
assert.equal(await executeTuiCommand(client, "session_compact"), "session_compact")
|
|
33
|
+
assert.deepEqual(calls, [
|
|
34
|
+
{ body: { command: "session_compact" } },
|
|
35
|
+
{ command: "session_compact" },
|
|
36
|
+
])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
const calls = []
|
|
41
|
+
const client = { session: { messages: async (args) => {
|
|
42
|
+
calls.push(args)
|
|
43
|
+
if (args?.path?.id) throw new Error("id shape rejected")
|
|
44
|
+
return { data: [{ info: { id: "m1", role: "assistant" } }] }
|
|
45
|
+
} } }
|
|
46
|
+
const result = await readRecentSessionMessages(client, "ses_messages", "/tmp/project", 7)
|
|
47
|
+
assert.equal(result[0].info.id, "m1")
|
|
48
|
+
assert.equal(calls.length, 2)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
const ordered = orderedSessionMessages([
|
|
53
|
+
{ info: { id: "b", time: { created: 20 } } },
|
|
54
|
+
{ info: { id: "a", time: { created: 10 } } },
|
|
55
|
+
{ info: { id: "c", time: { created: 20 } } },
|
|
56
|
+
])
|
|
57
|
+
assert.deepEqual(ordered.map((item) => item.info.id), ["a", "b", "c"])
|
|
58
|
+
|
|
59
|
+
assert.equal(await activeRunCompletionFromMessages("/tmp", {}, "missing", { startedAt: 100 }), "unknown")
|
|
60
|
+
assert.equal(await activeRunCompletionFromMessages(
|
|
61
|
+
"/tmp", messagesClient([{ info: { role: "assistant", time: { created: 50, completed: 90 } } }]), "old", { startedAt: 100 },
|
|
62
|
+
), "incomplete")
|
|
63
|
+
assert.equal(await activeRunCompletionFromMessages(
|
|
64
|
+
"/tmp", messagesClient([{ info: { role: "assistant", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },
|
|
65
|
+
), "completed")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
const sessionID = "ses_message_model_contract"
|
|
70
|
+
deleteSessionExecutionContext(sessionID)
|
|
71
|
+
const client = messagesClient([
|
|
72
|
+
{ info: { role: "assistant", time: { created: 10 }, model: { providerID: "old", modelID: "old" } } },
|
|
73
|
+
{ info: { role: "assistant", time: { created: 20 }, model: { providerID: "provider", modelID: "model" } } },
|
|
74
|
+
])
|
|
75
|
+
assert.deepEqual(await resolveCompactionModel("/tmp", client, sessionID), { providerID: "provider", modelID: "model" })
|
|
76
|
+
assert.deepEqual(getSessionExecutionContext(sessionID)?.model, { providerID: "provider", modelID: "model" })
|
|
77
|
+
deleteSessionExecutionContext(sessionID)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
const tuiCalls = []
|
|
82
|
+
const client = {
|
|
83
|
+
app: { log: async () => ({ data: true }) },
|
|
84
|
+
tui: { executeCommand: async (args) => {
|
|
85
|
+
const command = args?.body?.command || args?.command
|
|
86
|
+
tuiCalls.push(command)
|
|
87
|
+
if (command === "session.compact") return { error: { message: "unsupported" } }
|
|
88
|
+
return { data: true }
|
|
89
|
+
} },
|
|
90
|
+
}
|
|
91
|
+
assert.equal(await compactSession("/tmp", client, "ses_tui"), true)
|
|
92
|
+
assert.ok(tuiCalls.includes("session.compact"))
|
|
93
|
+
assert.ok(tuiCalls.includes("session_compact"))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
const summaries = []
|
|
98
|
+
const client = {
|
|
99
|
+
app: { log: async () => ({ data: true }) },
|
|
100
|
+
tui: { showToast: async () => ({ data: true }) },
|
|
101
|
+
session: { summarize: async (args) => { summaries.push(args); return { data: true } } },
|
|
102
|
+
}
|
|
103
|
+
assert.equal(await compactSession("/tmp", client, "ses_summary", "provider/model"), true)
|
|
104
|
+
assert.deepEqual(summaries[0], {
|
|
105
|
+
path: { id: "ses_summary" },
|
|
106
|
+
body: { providerID: "provider", modelID: "model", auto: false },
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
const calls = []
|
|
112
|
+
const client = {
|
|
113
|
+
app: { log: async (args) => { calls.push(["log", args]); return { data: true } } },
|
|
114
|
+
tui: {
|
|
115
|
+
executeCommand: async (args) => { calls.push(["command", args]); return { data: true } },
|
|
116
|
+
showToast: async (args) => { calls.push(["toast", args]); return { data: true } },
|
|
117
|
+
},
|
|
118
|
+
session: {
|
|
119
|
+
messages: async (args) => {
|
|
120
|
+
calls.push(["messages", args])
|
|
121
|
+
return { data: [{ info: { role: "assistant", time: { created: 10, completed: 20 } } }] }
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
const adapter = createOpenCodeHostAdapter(client, "/bound/project")
|
|
126
|
+
assert.equal(Object.isFrozen(adapter), true)
|
|
127
|
+
assert.deepEqual(Object.keys(adapter).sort(), [
|
|
128
|
+
"activeRunCompletion",
|
|
129
|
+
"compactSession",
|
|
130
|
+
"executeTuiCommand",
|
|
131
|
+
"log",
|
|
132
|
+
"readRecentMessages",
|
|
133
|
+
"resolveCompactionModel",
|
|
134
|
+
"toast",
|
|
135
|
+
])
|
|
136
|
+
await adapter.executeTuiCommand("session_compact")
|
|
137
|
+
await adapter.readRecentMessages("ses_adapter", 7)
|
|
138
|
+
assert.equal(await adapter.activeRunCompletion("ses_adapter", { startedAt: 5 }), "completed")
|
|
139
|
+
assert.deepEqual(await adapter.resolveCompactionModel("ses_adapter", "provider/model"), { providerID: "provider", modelID: "model" })
|
|
140
|
+
await adapter.log("info", "adapter log")
|
|
141
|
+
await adapter.toast("adapter toast", "warning")
|
|
142
|
+
const messageCall = calls.find(([kind]) => kind === "messages")?.[1]
|
|
143
|
+
assert.equal(messageCall?.query?.directory, "/bound/project")
|
|
144
|
+
assert.equal(messageCall?.query?.limit, 7)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log("OpenCode host adapter contract test passed")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { promises as fs } from "node:fs"
|
|
3
|
+
|
|
4
|
+
const legacy = await fs.readFile("src/source/legacy-v1.js", "utf8")
|
|
5
|
+
const host = await fs.readFile("src/source/opencode/host.js", "utf8")
|
|
6
|
+
|
|
7
|
+
const legacyStart = legacy.indexOf("function fireSdk(")
|
|
8
|
+
const legacyEnd = legacy.indexOf("function guardLoopOwnedUserMessage(")
|
|
9
|
+
const hostStart = host.indexOf("export function fireSdk(")
|
|
10
|
+
assert.ok(legacyStart >= 0 && legacyEnd > legacyStart, "legacy host bridge block not found")
|
|
11
|
+
assert.ok(hostStart >= 0, "host bridge module block not found")
|
|
12
|
+
|
|
13
|
+
const normalize = (text) => text
|
|
14
|
+
.replace(/\bexport\s+(?=(?:async\s+)?function\b)/g, "")
|
|
15
|
+
.replace(/\r\n/g, "\n")
|
|
16
|
+
.trim()
|
|
17
|
+
|
|
18
|
+
const legacyBlock = normalize(legacy.slice(legacyStart, legacyEnd))
|
|
19
|
+
const hostBlock = normalize(host.slice(hostStart))
|
|
20
|
+
assert.equal(hostBlock, legacyBlock, "OpenCode host bridge drifted from the stable V1 implementation")
|
|
21
|
+
|
|
22
|
+
console.log("host bridge parity test passed")
|