@bybrawe/opencode-loop 0.5.27 → 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 (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -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 +29 -7
  8. package/scripts/comprehensive-watchdog.mjs +32 -0
  9. package/scripts/executor-runtime-test.mjs +310 -0
  10. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  11. package/scripts/goal-command-handlers-test.mjs +236 -0
  12. package/scripts/goal-policy-test.mjs +143 -0
  13. package/scripts/goal-runtime-test.mjs +203 -0
  14. package/scripts/goal-steering-test.mjs +153 -0
  15. package/scripts/host-adapter-contract-test.mjs +147 -0
  16. package/scripts/host-goal-steering-canary.mjs +443 -0
  17. package/scripts/host-loop-canary.mjs +68 -12
  18. package/scripts/job-workspace-test.mjs +164 -0
  19. package/scripts/loop-command-handlers-test.mjs +279 -0
  20. package/scripts/loop-registration-test.mjs +220 -0
  21. package/scripts/opencode2-host-canary.mjs +194 -0
  22. package/scripts/opencode2-loop-canary.mjs +433 -0
  23. package/scripts/runtime-contract-test.mjs +59 -0
  24. package/scripts/runtime-event-test.mjs +78 -0
  25. package/scripts/runtime-scope-test.mjs +46 -0
  26. package/scripts/scheduler-runtime-test.mjs +147 -0
  27. package/scripts/session-activity-test.mjs +95 -0
  28. package/scripts/session-manager-test.mjs +48 -0
  29. package/scripts/session-status-test.mjs +220 -0
  30. package/scripts/v2-command-adapter-test.mjs +113 -0
  31. package/scripts/v2-command-runtime-test.mjs +98 -0
  32. package/scripts/v2-event-bridge-test.mjs +72 -0
  33. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  34. package/scripts/v2-host-contract-test.mjs +82 -0
  35. package/scripts/v2-plugin-sentinel-test.mjs +136 -0
  36. package/scripts/v2-prompt-interval-test.mjs +171 -0
  37. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  38. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  39. package/src/index.js +3737 -2220
  40. package/src/opencode/v1.js +1 -0
  41. package/src/source/core/args.js +281 -0
  42. package/src/source/core/jobs.js +80 -0
  43. package/src/source/core/process.js +51 -0
  44. package/src/source/core/state.js +211 -0
  45. package/src/source/legacy-v1.js +252 -0
  46. package/src/source/opencode/adapter.js +21 -0
  47. package/src/source/opencode/command-router.js +103 -0
  48. package/src/source/opencode/commands.js +83 -0
  49. package/src/source/opencode/goal-commands.js +107 -0
  50. package/src/source/opencode/host.js +134 -0
  51. package/src/source/opencode/loop-commands.js +195 -0
  52. package/src/source/opencode/loop-registration.js +100 -0
  53. package/src/source/opencode/messages.js +55 -0
  54. package/src/source/opencode/sdk.js +38 -0
  55. package/src/source/opencode/session-context.js +50 -0
  56. package/src/source/opencode2/capabilities.js +71 -0
  57. package/src/source/opencode2/commands.js +60 -0
  58. package/src/source/opencode2/event-bridge.js +174 -0
  59. package/src/source/opencode2/events.js +64 -0
  60. package/src/source/opencode2/experimental.js +47 -0
  61. package/src/source/opencode2/host-contract.js +94 -0
  62. package/src/source/opencode2/prompt-runtime.js +381 -0
  63. package/src/source/opencode2/runtime-adapter.js +56 -0
  64. package/src/source/opencode2/status.js +40 -0
  65. package/src/source/runtime/compaction.js +122 -0
  66. package/src/source/runtime/events.js +113 -0
  67. package/src/source/runtime/executor.js +558 -0
  68. package/src/source/runtime/goal-policy.js +77 -0
  69. package/src/source/runtime/goal-runtime.js +231 -0
  70. package/src/source/runtime/goal-steering.js +198 -0
  71. package/src/source/runtime/job-workspace.js +148 -0
  72. package/src/source/runtime/observer.js +23 -0
  73. package/src/source/runtime/scheduler.js +212 -0
  74. package/src/source/runtime/scope.js +56 -0
  75. package/src/source/runtime/session-activity.js +112 -0
  76. package/src/source/runtime/session-manager.js +100 -0
  77. package/src/source/runtime/session-registry.js +58 -0
  78. package/src/source/runtime/session-status.js +197 -0
  79. package/src/source/runtime/timers.js +45 -0
  80. package/src/source/v1.js +69 -0
@@ -0,0 +1,78 @@
1
+ import assert from "node:assert/strict"
2
+ import { normalizeOpenCodeEvent } from "../src/source/runtime/events.js"
3
+
4
+ assert.deepEqual(
5
+ normalizeOpenCodeEvent({ type: "session.idle", properties: { sessionID: "ses_idle" } }),
6
+ { kind: "session", action: "idle", sessionID: "ses_idle", directory: undefined },
7
+ )
8
+
9
+ assert.deepEqual(
10
+ normalizeOpenCodeEvent({
11
+ directory: "/workspace",
12
+ payload: { type: "session.status", properties: { sessionID: "ses_busy", status: { type: "busy" } } },
13
+ }),
14
+ { kind: "session", action: "status", sessionID: "ses_busy", directory: "/workspace", status: "busy" },
15
+ )
16
+
17
+ assert.deepEqual(
18
+ normalizeOpenCodeEvent({
19
+ type: "session.created",
20
+ properties: { info: { id: "ses_child", directory: "/project", parentID: "ses_parent" } },
21
+ }),
22
+ { kind: "session", action: "created", sessionID: "ses_child", directory: "/project", parentID: "ses_parent" },
23
+ )
24
+
25
+ assert.deepEqual(
26
+ normalizeOpenCodeEvent({
27
+ directory: "/workspace",
28
+ payload: {
29
+ type: "message.updated",
30
+ properties: {
31
+ info: {
32
+ id: "msg_done",
33
+ sessionID: "ses_message",
34
+ role: "assistant",
35
+ time: { created: 10, completed: 20 },
36
+ finish: "stop",
37
+ },
38
+ },
39
+ },
40
+ }),
41
+ {
42
+ kind: "message",
43
+ action: "updated",
44
+ sessionID: "ses_message",
45
+ directory: "/workspace",
46
+ messageID: "msg_done",
47
+ role: "assistant",
48
+ completedAt: 20,
49
+ finish: "stop",
50
+ },
51
+ )
52
+
53
+ assert.deepEqual(
54
+ normalizeOpenCodeEvent({
55
+ type: "command.executed",
56
+ properties: { name: "loop-status", sessionID: "ses_command", arguments: "--json", messageID: "msg_command" },
57
+ }),
58
+ {
59
+ kind: "command",
60
+ action: "executed",
61
+ sessionID: "ses_command",
62
+ directory: undefined,
63
+ name: "loop-status",
64
+ arguments: "--json",
65
+ messageID: "msg_command",
66
+ },
67
+ )
68
+
69
+ assert.deepEqual(
70
+ normalizeOpenCodeEvent({ type: "server.instance.disposed", properties: { directory: "/old" } }),
71
+ { kind: "server", action: "disposed", directory: "/old" },
72
+ )
73
+
74
+ assert.equal(normalizeOpenCodeEvent({ type: "session.idle", properties: {} }), undefined)
75
+ assert.equal(normalizeOpenCodeEvent({ payload: { properties: {} } }), undefined)
76
+ assert.equal(normalizeOpenCodeEvent({ type: "file.edited", properties: { file: "x.txt" } }), undefined)
77
+
78
+ console.log("OpenCode runtime event normalization test passed")
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict"
2
+ import { createRuntimeScope } from "../src/source/runtime/scope.js"
3
+ import { createRuntimeTimers } from "../src/source/runtime/timers.js"
4
+
5
+ const scope = createRuntimeScope()
6
+ const events = []
7
+ scope.track(() => events.push("first"))
8
+ const release = scope.track(() => events.push("released"))
9
+ scope.track(() => events.push("last"))
10
+ assert.equal(release(), true)
11
+
12
+ let calls = 0
13
+ const guarded = scope.guard(() => ++calls)
14
+ assert.equal(guarded(), 1)
15
+
16
+ const cleared = []
17
+ const api = {
18
+ setTimeout(callback) { return { callback } },
19
+ clearTimeout(handle) { cleared.push(handle) },
20
+ setInterval(callback) { return { callback } },
21
+ clearInterval(handle) { cleared.push(handle) },
22
+ }
23
+ const timers = createRuntimeTimers(scope, api)
24
+ const repeating = timers.interval(() => ++calls, 1)
25
+ repeating.handle.callback()
26
+ assert.equal(calls, 2)
27
+ assert.equal(repeating.cancel(), true)
28
+ repeating.handle.callback()
29
+ assert.equal(calls, 2)
30
+ const pending = timers.interval(() => ++calls, 1)
31
+
32
+ assert.equal(scope.dispose("done"), true)
33
+ assert.deepEqual(events, ["last", "first"])
34
+ assert.equal(scope.signal.aborted, true)
35
+ assert.equal(scope.isActive(), false)
36
+ pending.handle.callback()
37
+ assert.equal(calls, 2)
38
+ assert.ok(cleared.includes(pending.handle))
39
+ assert.equal(timers.timeout(() => {}, 1), undefined)
40
+ assert.equal(scope.dispose(), false)
41
+ assert.equal(guarded(), undefined)
42
+
43
+ scope.track(() => events.push("late"))
44
+ assert.deepEqual(events, ["last", "first", "late"])
45
+
46
+ console.log("OpenCode runtime scope test passed")
@@ -0,0 +1,147 @@
1
+ import assert from "node:assert/strict"
2
+ import { createSchedulerRuntime, jobDueAt, nextDueDelay } from "../src/source/runtime/scheduler.js"
3
+
4
+ const baseJob = (overrides = {}) => ({
5
+ id: "job-1",
6
+ enabled: true,
7
+ paused: false,
8
+ maxRuns: 0,
9
+ runCount: 0,
10
+ watchPaths: [],
11
+ createdAt: new Date(0).toISOString(),
12
+ intervalMs: 100,
13
+ lastRunAt: 950,
14
+ ...overrides,
15
+ })
16
+
17
+ assert.equal(jobDueAt(baseJob(), 1_000), 1_050)
18
+ assert.equal(jobDueAt(baseJob({ intervalMs: 0 }), 1_000), 1_000)
19
+ assert.equal(jobDueAt(baseJob({ lastRunAt: 0 }), 1_000), 1_000)
20
+ assert.equal(jobDueAt(baseJob({ lastRunAt: 0, immediate: true, createdAt: new Date(900).toISOString(), intervalMs: 200 }), 1_000), 1_000)
21
+ assert.equal(jobDueAt(baseJob({ lastRunAt: 0, immediate: false, createdAt: new Date(900).toISOString(), intervalMs: 200 }), 1_000), 1_100)
22
+ assert.equal(nextDueDelay({ jobs: [baseJob({ lastRunAt: 0, immediate: false, createdAt: new Date(900).toISOString(), intervalMs: 200 })] }, 1_000), 100)
23
+ assert.equal(jobDueAt(baseJob({ lastRunAt: 0, immediate: false, createdAt: new Date(900).toISOString(), intervalMs: 200, watchPaths: ["x"], runNowRequestedAt: 999 }), 1_000), 1_000)
24
+ assert.equal(nextDueDelay({ jobs: [baseJob({ lastRunAt: 0, immediate: false, intervalMs: 200, watchPaths: ["x"], runNowRequestedAt: 999 })] }, 1_000), 0)
25
+ assert.equal(jobDueAt(baseJob({ paused: true, runNowRequestedAt: 999 }), 1_000), Infinity)
26
+ assert.equal(jobDueAt(baseJob({ enabled: false, runNowRequestedAt: 999 }), 1_000), Infinity)
27
+ assert.equal(jobDueAt(baseJob({ maxRuns: 2, runCount: 2, runNowRequestedAt: 999 }), 1_000), Infinity)
28
+ assert.equal(jobDueAt(baseJob({ kind: "goal", goalStatus: "completed", runNowRequestedAt: 999 }), 1_000), Infinity)
29
+ assert.equal(nextDueDelay({ jobs: [baseJob(), baseJob({ paused: true })] }, 1_000), 50)
30
+ assert.equal(nextDueDelay({ jobs: [baseJob({ paused: true })] }, 1_000), Infinity)
31
+
32
+ function fakeTimers() {
33
+ let id = 0
34
+ const timeouts = new Map()
35
+ const intervals = new Map()
36
+ const setTimeout = (fn, ms) => {
37
+ const timer = { id: ++id, fn, ms }
38
+ timeouts.set(timer.id, timer)
39
+ return timer
40
+ }
41
+ const clearTimeout = (timer) => { if (timer) timeouts.delete(timer.id) }
42
+ const setInterval = (fn, ms) => {
43
+ const timer = { id: ++id, fn, ms }
44
+ intervals.set(timer.id, timer)
45
+ return timer
46
+ }
47
+ const clearInterval = (timer) => { if (timer) intervals.delete(timer.id) }
48
+ const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve() }
49
+ const fireTimeout = async (timer) => {
50
+ timeouts.delete(timer.id)
51
+ timer.fn()
52
+ await flush()
53
+ }
54
+ const fireInterval = async (timer) => {
55
+ timer.fn()
56
+ await flush()
57
+ }
58
+ return { timeouts, intervals, setTimeout, clearTimeout, setInterval, clearInterval, fireTimeout, fireInterval }
59
+ }
60
+
61
+ const timers = fakeTimers()
62
+ let clock = 1_000
63
+ const events = []
64
+ const state = { jobs: [baseJob()] }
65
+ const client = { name: "fake-client" }
66
+ const runtime = createSchedulerRuntime({
67
+ now: () => clock,
68
+ readState: async () => state,
69
+ sessionIsIdle: async () => true,
70
+ finalizeActiveRun: async (_directory, _client, _sessionID, options) => events.push(options?.forceStale ? "heartbeat-finalize" : "finalize"),
71
+ maybeRunDueJobs: async (_directory, _client, _sessionID, options) => events.push(options?.heartbeat ? "heartbeat-run" : "run"),
72
+ appendLoopLog: async () => {},
73
+ toast: async () => {},
74
+ errorMessage: (error) => error?.message || String(error),
75
+ setTimeout: timers.setTimeout,
76
+ clearTimeout: timers.clearTimeout,
77
+ setInterval: timers.setInterval,
78
+ clearInterval: timers.clearInterval,
79
+ idleDebounceMs: 5,
80
+ busyRetryMs: 7,
81
+ minDueTimerMs: 1,
82
+ maxDueTimerMs: 1_000,
83
+ heartbeatMs: 10,
84
+ sessionTtlMs: 1_000,
85
+ })
86
+
87
+ runtime.rememberSession("/workspace", client, "ses-1")
88
+ runtime.rememberSession("/workspace", client, "ses-1")
89
+ assert.equal(runtime.knownSessionCount(), 1)
90
+ assert.equal(timers.intervals.size, 1, "heartbeat must be deduplicated")
91
+
92
+ runtime.scheduleIdleWork("/workspace", client, "ses-1")
93
+ runtime.scheduleIdleWork("/workspace", client, "ses-1")
94
+ assert.equal(timers.timeouts.size, 1, "idle timer must replace the previous timer")
95
+ const idleTimer = [...timers.timeouts.values()][0]
96
+ assert.equal(idleTimer.ms, 5)
97
+ await timers.fireTimeout(idleTimer)
98
+ assert.deepEqual(events.splice(0), ["finalize", "run"])
99
+
100
+ await runtime.scheduleDueWork("/workspace", client, "ses-1")
101
+ assert.equal(timers.intervals.size, 2, "watchdog should run alongside heartbeat")
102
+ const dueTimer = [...timers.timeouts.values()][0]
103
+ assert.equal(dueTimer.ms, 50)
104
+ await timers.fireTimeout(dueTimer)
105
+ assert.deepEqual(events.splice(0), ["finalize", "run"])
106
+
107
+ const heartbeat = [...timers.intervals.values()].find((timer) => timer.ms === 10)
108
+ assert.ok(heartbeat)
109
+ await timers.fireInterval(heartbeat)
110
+ assert.deepEqual(events.splice(0), ["heartbeat-finalize", "heartbeat-run"])
111
+
112
+ await runtime.scheduleDueWork("/workspace", client, "ses-1")
113
+ assert.ok(timers.timeouts.size > 0)
114
+ runtime.clearSessionScheduling("ses-1")
115
+ assert.equal(runtime.knownSessionCount(), 0)
116
+ assert.equal(timers.timeouts.size, 0)
117
+ assert.equal(timers.intervals.size, 0, "clearing the last session should stop watchdog and heartbeat")
118
+
119
+ const busyTimers = fakeTimers()
120
+ let idleChecks = 0
121
+ const busyRuntime = createSchedulerRuntime({
122
+ now: () => 2_000,
123
+ readState: async () => ({ jobs: [baseJob({ intervalMs: 0, lastRunAt: 0 })] }),
124
+ sessionIsIdle: async () => (++idleChecks > 1),
125
+ finalizeActiveRun: async () => { throw new Error("finalize should not run while first idle check is busy") },
126
+ maybeRunDueJobs: async () => {},
127
+ appendLoopLog: async () => {},
128
+ toast: async () => {},
129
+ setTimeout: busyTimers.setTimeout,
130
+ clearTimeout: busyTimers.clearTimeout,
131
+ setInterval: busyTimers.setInterval,
132
+ clearInterval: busyTimers.clearInterval,
133
+ idleDebounceMs: 3,
134
+ busyRetryMs: 7,
135
+ minDueTimerMs: 1,
136
+ heartbeatMs: 10,
137
+ })
138
+
139
+ busyRuntime.scheduleIdleWork("/workspace", client, "ses-busy")
140
+ const busyIdleTimer = [...busyTimers.timeouts.values()][0]
141
+ await busyTimers.fireTimeout(busyIdleTimer)
142
+ const retryTimer = [...busyTimers.timeouts.values()][0]
143
+ assert.ok(retryTimer)
144
+ assert.equal(retryTimer.ms, 7, "busy idle handler should reschedule with busy retry delay")
145
+ busyRuntime.clearSessionScheduling("ses-busy")
146
+
147
+ console.log("scheduler runtime tests passed")
@@ -0,0 +1,95 @@
1
+ import assert from "node:assert/strict"
2
+ import {
3
+ activeToolCalls,
4
+ sessionParents,
5
+ sessionStatuses,
6
+ sessionStatusSeenAt,
7
+ hasActiveToolCalls,
8
+ markToolCallActive,
9
+ markToolCallFinished,
10
+ updateSessionRelationship,
11
+ updateSessionRelationshipFromEvent,
12
+ isDescendantSession,
13
+ hasBusyDescendant,
14
+ refreshSessionRelationships,
15
+ updateToolActivityFromEvent,
16
+ clearSessionActivity,
17
+ } from "../src/source/runtime/session-activity.js"
18
+
19
+ const parentID = "ses_parent"
20
+ const childID = "ses_child"
21
+ const listedChildID = "ses_listed_child"
22
+
23
+ for (const sessionID of [parentID, childID, listedChildID]) clearSessionActivity(sessionID)
24
+
25
+ updateSessionRelationship({ id: childID, parentID })
26
+ assert.equal(sessionParents.get(childID), parentID)
27
+ assert.equal(isDescendantSession(childID, parentID), true)
28
+ assert.equal(isDescendantSession(parentID, childID), false)
29
+
30
+ markToolCallActive({ sessionID: childID, callID: "call_1" })
31
+ assert.equal(hasActiveToolCalls(childID), true)
32
+ assert.equal(sessionStatuses.get(childID), "busy")
33
+ assert.ok((sessionStatusSeenAt.get(childID) || 0) > 0)
34
+ assert.equal(hasBusyDescendant(parentID), true)
35
+
36
+ markToolCallFinished({ sessionID: childID, callID: "call_1" })
37
+ assert.equal(hasActiveToolCalls(childID), false)
38
+ sessionStatuses.set(childID, "idle")
39
+ assert.equal(hasBusyDescendant(parentID), false)
40
+
41
+ updateToolActivityFromEvent({
42
+ type: "message.part.updated",
43
+ properties: {
44
+ part: {
45
+ type: "tool",
46
+ sessionID: childID,
47
+ callID: "call_2",
48
+ id: "part_2",
49
+ state: { status: "running" },
50
+ },
51
+ },
52
+ })
53
+ assert.equal(activeToolCalls.get(childID)?.has("call_2"), true)
54
+
55
+ updateToolActivityFromEvent({
56
+ type: "message.part.updated",
57
+ properties: {
58
+ part: {
59
+ type: "tool",
60
+ sessionID: childID,
61
+ callID: "call_2",
62
+ id: "part_2",
63
+ state: { status: "completed" },
64
+ },
65
+ },
66
+ })
67
+ assert.equal(hasActiveToolCalls(childID), false)
68
+
69
+ await refreshSessionRelationships({
70
+ session: {
71
+ list: async () => [{ id: listedChildID, parentID }],
72
+ },
73
+ }, "/tmp/opencode-loop-session-activity")
74
+ assert.equal(sessionParents.get(listedChildID), parentID)
75
+
76
+ updateSessionRelationshipFromEvent({
77
+ type: "session.deleted",
78
+ properties: { info: { id: listedChildID, parentID } },
79
+ })
80
+ assert.equal(sessionParents.has(listedChildID), false)
81
+
82
+ sessionParents.set(parentID, childID)
83
+ sessionParents.set(childID, parentID)
84
+ assert.equal(isDescendantSession(parentID, "ses_missing"), false)
85
+ sessionParents.delete(parentID)
86
+
87
+ clearSessionActivity(childID)
88
+ assert.equal(activeToolCalls.has(childID), false)
89
+ assert.equal(sessionParents.has(childID), false)
90
+ assert.equal(sessionStatuses.has(childID), false)
91
+ assert.equal(sessionStatusSeenAt.has(childID), false)
92
+ clearSessionActivity(parentID)
93
+ clearSessionActivity(listedChildID)
94
+
95
+ console.log("session activity runtime tests passed")
@@ -0,0 +1,48 @@
1
+ import assert from "node:assert/strict"
2
+ import { createSessionRuntimeManager } from "../src/source/runtime/session-manager.js"
3
+
4
+ let clock = 1000
5
+ const cleared = []
6
+ const timerAPI = {
7
+ setTimeout(callback) { return { callback } },
8
+ clearTimeout(handle) { cleared.push(handle) },
9
+ setInterval(callback) { return { callback } },
10
+ clearInterval(handle) { cleared.push(handle) },
11
+ }
12
+ const manager = createSessionRuntimeManager({ now: () => clock, staleAfterMs: 100, timerAPI })
13
+
14
+ const first = manager.observeExternal("ses_a")
15
+ assert.equal(manager.entries()[0].seenAt, 1000)
16
+ clock = 1050
17
+ assert.equal(manager.peek("ses_a"), first)
18
+ assert.equal(manager.entries()[0].seenAt, 1000, "reads must not refresh external activity")
19
+ assert.equal(manager.observeExternal("ses_a"), first)
20
+ assert.equal(manager.entries()[0].seenAt, 1050)
21
+
22
+ first.dispose("replace")
23
+ clock = 1060
24
+ const second = manager.observeExternal("ses_a")
25
+ assert.notEqual(second, first)
26
+ assert.equal(manager.remove("ses_a", { expectedRuntime: first }), false)
27
+ assert.equal(manager.peek("ses_a"), second)
28
+
29
+ let calls = 0
30
+ const pending = second.timers.interval(() => ++calls, 10)
31
+ clock = 1159
32
+ assert.deepEqual(manager.pruneStale(), [])
33
+ clock = 1160
34
+ assert.deepEqual(manager.pruneStale(), ["ses_a"])
35
+ assert.equal(manager.peek("ses_a"), undefined)
36
+ assert.ok(cleared.includes(pending.handle))
37
+ pending.handle.callback()
38
+ assert.equal(calls, 0)
39
+
40
+ const third = manager.observeExternal("ses_a")
41
+ assert.notEqual(third, second)
42
+ assert.equal(manager.remove("ses_a", { expectedRuntime: third, reason: "done" }), true)
43
+ assert.equal(third.scope.isActive(), false)
44
+ assert.equal(manager.dispose(), true)
45
+ assert.equal(manager.dispose(), false)
46
+ assert.throws(() => manager.observeExternal("ses_b"), /disposed/)
47
+
48
+ console.log("OpenCode session runtime manager test passed")
@@ -0,0 +1,220 @@
1
+ import assert from "node:assert/strict"
2
+ import {
3
+ clearSessionActivity,
4
+ markToolCallActive,
5
+ markToolCallFinished,
6
+ sessionParents,
7
+ sessionStatuses,
8
+ sessionStatusSeenAt,
9
+ updateSessionRelationship,
10
+ } from "../src/source/runtime/session-activity.js"
11
+ import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
12
+
13
+ assert.throws(() => createSessionStatusRuntime({}), /activeRuns Map/)
14
+
15
+ const activeRuns = new Map()
16
+ const logs = []
17
+ let clock = 100_000
18
+ let completion = "unknown"
19
+ const runtime = createSessionStatusRuntime({
20
+ activeRuns,
21
+ now: () => clock,
22
+ sessionStatusCacheMs: 0,
23
+ appendLoopLog: async (...args) => { logs.push(args) },
24
+ activeRunCompletionFromMessages: async () => completion,
25
+ })
26
+
27
+ const ids = new Set()
28
+ const track = (...values) => {
29
+ for (const value of values) if (typeof value === "string") ids.add(value)
30
+ }
31
+
32
+ try {
33
+ {
34
+ const sessionID = "event-idle"
35
+ track(sessionID)
36
+ const result = runtime.updateSessionStatusFromEvent({ type: "session.idle", properties: { sessionID } })
37
+ assert.deepEqual(result, { sessionID, idle: true })
38
+ assert.equal(sessionStatuses.get(sessionID), "idle")
39
+ assert.equal(sessionStatusSeenAt.get(sessionID), clock)
40
+
41
+ clock++
42
+ assert.deepEqual(
43
+ runtime.updateSessionStatusFromEvent({ type: "session.status", properties: { sessionID, status: { type: "retry" } } }),
44
+ { sessionID, idle: false },
45
+ )
46
+ assert.equal(sessionStatuses.get(sessionID), "retry")
47
+ assert.equal(sessionStatusSeenAt.get(sessionID), clock)
48
+ assert.equal(runtime.updateSessionStatusFromEvent({ type: "message.updated", properties: { sessionID } }), undefined)
49
+ assert.equal(runtime.updateSessionStatusFromEvent({ type: "session.idle", properties: {} }), undefined)
50
+ }
51
+
52
+ {
53
+ const sessionID = "mark-clear"
54
+ track(sessionID)
55
+ assert.equal(runtime.markSessionStatus(sessionID, "busy", 1234), true)
56
+ assert.equal(sessionStatuses.get(sessionID), "busy")
57
+ assert.equal(sessionStatusSeenAt.get(sessionID), 1234)
58
+ assert.equal(runtime.markSessionStatus(undefined, "busy"), false)
59
+ runtime.clearSessionStatus(sessionID)
60
+ assert.equal(sessionStatuses.has(sessionID), false)
61
+ assert.equal(sessionStatusSeenAt.has(sessionID), false)
62
+ }
63
+
64
+ {
65
+ const sessionID = "sdk-data-wrapper"
66
+ track(sessionID)
67
+ const calls = []
68
+ const client = {
69
+ session: {
70
+ status: async (args) => {
71
+ calls.push(args)
72
+ return { data: { [sessionID]: { type: "busy" } } }
73
+ },
74
+ },
75
+ }
76
+ const live = await runtime.readLiveSessionStatus(client, sessionID, "/repo")
77
+ assert.deepEqual(live, { type: "busy", source: "sdk" })
78
+ assert.deepEqual(calls, [{ query: { directory: "/repo" } }])
79
+ assert.equal(sessionStatuses.get(sessionID), "busy")
80
+ assert.equal(sessionStatusSeenAt.get(sessionID), clock)
81
+ }
82
+
83
+ {
84
+ const sessionID = "sdk-error-fallback"
85
+ track(sessionID)
86
+ const calls = []
87
+ const client = {
88
+ session: {
89
+ status: async (args) => {
90
+ calls.push(args)
91
+ if (calls.length === 1) return { error: { message: "unsupported query shape" } }
92
+ return { data: { [sessionID]: { type: "idle" } } }
93
+ },
94
+ },
95
+ }
96
+ const live = await runtime.readLiveSessionStatus(client, sessionID, "/repo")
97
+ assert.deepEqual(live, { type: "idle", source: "sdk" })
98
+ assert.equal(calls.length, 2)
99
+ assert.deepEqual(calls[0], { query: { directory: "/repo" } })
100
+ assert.deepEqual(calls[1], { directory: "/repo" })
101
+ }
102
+
103
+ {
104
+ const parentID = "parent-busy-child"
105
+ const childID = "child-busy"
106
+ track(parentID, childID)
107
+ updateSessionRelationship({ id: childID, parentID })
108
+ const client = {
109
+ session: {
110
+ status: async () => ({ data: { [childID]: { type: "busy" } } }),
111
+ },
112
+ }
113
+ const live = await runtime.readLiveSessionStatus(client, parentID, "/repo")
114
+ assert.deepEqual(live, { type: "busy", source: "descendant" })
115
+ assert.equal(sessionParents.get(childID), parentID)
116
+ assert.equal(sessionStatuses.get(childID), "busy")
117
+ }
118
+
119
+ {
120
+ const parentID = "parent-finished-child"
121
+ const childID = "child-finished"
122
+ track(parentID, childID)
123
+ updateSessionRelationship({ id: childID, parentID })
124
+ runtime.markSessionStatus(childID, "busy")
125
+ const client = {
126
+ session: {
127
+ status: async () => ({ data: { [parentID]: { type: "idle" } } }),
128
+ },
129
+ }
130
+ const live = await runtime.readLiveSessionStatus(client, parentID, "/repo")
131
+ assert.deepEqual(live, { type: "idle", source: "sdk" })
132
+ assert.equal(sessionStatuses.get(childID), "idle", "omitted descendant must be cleared to idle")
133
+ }
134
+
135
+ {
136
+ const sessionID = "active-tool"
137
+ track(sessionID)
138
+ runtime.clearSessionStatus(sessionID)
139
+ markToolCallActive({ sessionID, callID: "tool-1" })
140
+ const client = { session: { status: async () => ({ data: { [sessionID]: { type: "idle" } } }) } }
141
+ assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "busy")
142
+ assert.equal(sessionStatuses.get(sessionID), "busy")
143
+ markToolCallFinished({ sessionID, callID: "tool-1" })
144
+ }
145
+
146
+ {
147
+ const sessionID = "message-complete-recovery"
148
+ track(sessionID)
149
+ runtime.clearSessionStatus(sessionID)
150
+ activeRuns.set(sessionID, { jobId: "job-1", job: { id: "job-1", name: "dev" }, startedAt: 1_000 })
151
+ completion = "completed"
152
+ const client = { session: { status: async () => ({ data: { [sessionID]: { type: "busy" } } }) } }
153
+ assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "idle")
154
+ assert.equal(sessionStatuses.get(sessionID), "idle")
155
+ assert.deepEqual(logs.at(-1), ["/repo", "status-message-complete-recovery", { sessionID, job: "dev", startedAt: 1_000 }])
156
+ activeRuns.delete(sessionID)
157
+ completion = "unknown"
158
+ }
159
+
160
+ {
161
+ const sessionID = "stale-recovery"
162
+ track(sessionID)
163
+ runtime.clearSessionStatus(sessionID)
164
+ activeRuns.set(sessionID, { jobId: "job-2", job: { id: "job-2" }, startedAt: 1_000 })
165
+ completion = "unknown"
166
+ const client = { session: { status: async () => ({ data: { [sessionID]: { type: "retry" } } }) } }
167
+ assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "idle")
168
+ assert.deepEqual(logs.at(-1), ["/repo", "status-stale-recovery", { sessionID, job: "job-2", startedAt: 1_000 }])
169
+ activeRuns.delete(sessionID)
170
+ }
171
+
172
+ {
173
+ const sessionID = "fallback-fresh"
174
+ track(sessionID)
175
+ runtime.clearSessionStatus(sessionID)
176
+ activeRuns.set(sessionID, { jobId: "fresh", job: {}, startedAt: clock - 1_000 })
177
+ const client = { session: { status: async () => { throw new Error("offline") } } }
178
+ assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "busy")
179
+ activeRuns.set(sessionID, { jobId: "stale", job: {}, startedAt: clock - 60_000 })
180
+ runtime.clearSessionStatus(sessionID)
181
+ assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "idle")
182
+ activeRuns.delete(sessionID)
183
+ }
184
+
185
+ {
186
+ const sessionID = "finalize-basic"
187
+ track(sessionID)
188
+ runtime.clearSessionStatus(sessionID)
189
+ const active = { jobId: "job", job: {}, startedAt: clock - 100 }
190
+ assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), true)
191
+
192
+ markToolCallActive({ sessionID, callID: "tool-finalize" })
193
+ assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), false)
194
+ markToolCallFinished({ sessionID, callID: "tool-finalize" })
195
+
196
+ runtime.markSessionStatus(sessionID, "idle", clock + 1)
197
+ const client = { session: { status: async () => { throw new Error("status unavailable") } } }
198
+ assert.equal(await runtime.canFinalizeActiveRun("/repo", client, sessionID, active, { requireIdle: true }), true)
199
+ }
200
+
201
+ {
202
+ const sessionID = "force-stale"
203
+ track(sessionID)
204
+ runtime.clearSessionStatus(sessionID)
205
+ activeRuns.set(sessionID, { jobId: "force", job: {}, startedAt: clock - 60_000 })
206
+ completion = "unknown"
207
+ const active = activeRuns.get(sessionID)
208
+ const client = { session: { status: async () => ({ data: { [sessionID]: { type: "busy" } } }) } }
209
+ assert.equal(await runtime.canFinalizeActiveRun("/repo", client, sessionID, active, { requireIdle: true, forceStale: true }), true)
210
+ activeRuns.delete(sessionID)
211
+ }
212
+
213
+ assert.equal(await runtime.readLiveSessionStatus({}, "missing-client", "/repo"), undefined)
214
+ assert.equal(await runtime.sessionIsIdle({ session: { status: async () => ({ data: {} }) } }, "unlisted", "/repo"), true)
215
+
216
+ console.log("session status tests passed")
217
+ } finally {
218
+ activeRuns.clear()
219
+ for (const sessionID of ids) clearSessionActivity(sessionID)
220
+ }