opencode-discord-bot 0.0.1

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +78 -0
  4. package/src/Bridge/LoopbackServer.test.ts +94 -0
  5. package/src/Bridge/LoopbackServer.ts +77 -0
  6. package/src/Bridge/ToolControl.test.ts +245 -0
  7. package/src/Bridge/ToolControl.ts +260 -0
  8. package/src/Bridge/ToolControlEdges.test.ts +49 -0
  9. package/src/Bridge/ToolControlHighRisk.test.ts +153 -0
  10. package/src/Config.test.ts +142 -0
  11. package/src/Config.ts +295 -0
  12. package/src/ConfigSchema.ts +46 -0
  13. package/src/ConfigTypes.ts +11 -0
  14. package/src/Discord/ChatSdkDiscord.test.ts +257 -0
  15. package/src/Discord/ChatSdkDiscord.ts +206 -0
  16. package/src/Discord/ChatSdkGatewayIntake.test.ts +121 -0
  17. package/src/Discord/ChatSdkGatewayIntake.ts +235 -0
  18. package/src/Discord/DiscordGateway.test.ts +215 -0
  19. package/src/Discord/DiscordGateway.ts +140 -0
  20. package/src/Discord/DiscordGatewayFailures.test.ts +148 -0
  21. package/src/Discord/DiscordJsDiscord.test.ts +208 -0
  22. package/src/Discord/DiscordJsDiscord.ts +267 -0
  23. package/src/Discord/DiscordPort.ts +30 -0
  24. package/src/Discord/MemoryDiscord.test.ts +44 -0
  25. package/src/Discord/MemoryDiscord.ts +85 -0
  26. package/src/Discord/Safety.ts +11 -0
  27. package/src/Main.test.ts +273 -0
  28. package/src/Main.ts +192 -0
  29. package/src/MainQueue.test.ts +124 -0
  30. package/src/Opencode/EventMapping.test.ts +188 -0
  31. package/src/Opencode/EventMapping.ts +232 -0
  32. package/src/Opencode/EventMappingState.ts +97 -0
  33. package/src/Opencode/MemoryOpencode.test.ts +18 -0
  34. package/src/Opencode/MemoryOpencode.ts +29 -0
  35. package/src/Opencode/OpencodePort.ts +30 -0
  36. package/src/Opencode/PromptParts.ts +47 -0
  37. package/src/Opencode/SdkOpencode.test.ts +280 -0
  38. package/src/Opencode/SdkOpencode.ts +270 -0
  39. package/src/Opencode/SdkOpencodeAttachments.test.ts +79 -0
  40. package/src/Opencode/SdkOpencodeFailures.test.ts +113 -0
  41. package/src/Orchestrator/ContextAssembly.test.ts +115 -0
  42. package/src/Orchestrator/ContextAssembly.ts +120 -0
  43. package/src/Orchestrator/Orchestrator.ts +67 -0
  44. package/src/Orchestrator/StopCommand.test.ts +20 -0
  45. package/src/Orchestrator/StopCommand.ts +14 -0
  46. package/src/Orchestrator/Triggering.test.ts +56 -0
  47. package/src/Orchestrator/Triggering.ts +26 -0
  48. package/src/Orchestrator/TurnManager.test.ts +180 -0
  49. package/src/Orchestrator/TurnManager.ts +179 -0
  50. package/src/Orchestrator/TurnManagerStrategy.test.ts +136 -0
  51. package/src/PublicContracts.test.ts +43 -0
  52. package/src/Render/Renderer.test.ts +249 -0
  53. package/src/Render/Renderer.ts +159 -0
  54. package/src/Render/Splitting.test.ts +30 -0
  55. package/src/Render/Splitting.ts +68 -0
  56. package/src/Schema.ts +93 -0
  57. package/src/Tools/Scaffolding.test.ts +56 -0
  58. package/src/Tools/Scaffolding.ts +60 -0
@@ -0,0 +1,180 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Deferred, Duration, Effect, Fiber, Ref, Stream } from "effect"
3
+ import { makeMemoryDiscord } from "../Discord/MemoryDiscord.ts"
4
+ import { makeMemoryOpencode } from "../Opencode/MemoryOpencode.ts"
5
+ import { OpencodeError, type OpencodeService } from "../Opencode/OpencodePort.ts"
6
+ import type { DiscordScope } from "../Schema.ts"
7
+ import { createTurnManager } from "./TurnManager.ts"
8
+
9
+ const channelScope: DiscordScope = { guildId: "g1", channelId: "c1" }
10
+ const threadScope: DiscordScope = { guildId: "g1", channelId: "c1", threadId: "t1" }
11
+
12
+ describe("TurnManager", () => {
13
+ test("serializes work per Discord scope and allows different scopes in parallel", async () => {
14
+ const order = await Effect.runPromise(
15
+ Effect.gen(function* () {
16
+ const manager = createTurnManager(makeMemoryOpencode([]), makeMemoryDiscord())
17
+ const firstStarted = yield* Deferred.make<void>()
18
+ const releaseFirst = yield* Deferred.make<void>()
19
+ const seen = yield* Ref.make<ReadonlyArray<string>>([])
20
+
21
+ const first = yield* manager
22
+ .runExclusive(
23
+ channelScope,
24
+ Effect.gen(function* () {
25
+ yield* Ref.update(seen, (values) => [...values, "same-1-start"])
26
+ yield* Deferred.succeed(firstStarted, void 0)
27
+ yield* Deferred.await(releaseFirst)
28
+ yield* Ref.update(seen, (values) => [...values, "same-1-end"])
29
+ })
30
+ )
31
+ .pipe(Effect.forkChild)
32
+
33
+ yield* Deferred.await(firstStarted)
34
+ const second = yield* manager
35
+ .runExclusive(
36
+ channelScope,
37
+ Ref.update(seen, (values) => [...values, "same-2"])
38
+ )
39
+ .pipe(Effect.forkChild)
40
+ const parallel = yield* manager
41
+ .runExclusive(
42
+ threadScope,
43
+ Ref.update(seen, (values) => [...values, "other-scope"])
44
+ )
45
+ .pipe(Effect.forkChild)
46
+
47
+ yield* Fiber.join(parallel)
48
+ expect(yield* Ref.get(seen)).toEqual(["same-1-start", "other-scope"])
49
+
50
+ yield* Deferred.succeed(releaseFirst, void 0)
51
+ yield* Fiber.join(first)
52
+ yield* Fiber.join(second)
53
+ return yield* Ref.get(seen)
54
+ })
55
+ )
56
+
57
+ expect(order).toEqual(["same-1-start", "other-scope", "same-1-end", "same-2"])
58
+ })
59
+
60
+ test("/stop interrupts the active turn fiber, calls opencode abort, and clears transient state", async () => {
61
+ const opencode = makeMemoryOpencode([])
62
+ const manager = createTurnManager(opencode, makeMemoryDiscord())
63
+
64
+ const result = await Effect.runPromise(
65
+ Effect.gen(function* () {
66
+ const started = yield* Deferred.make<void>()
67
+ const finalizerRan = yield* Ref.make(false)
68
+ const fiber = yield* manager.startTurn(
69
+ channelScope,
70
+ "session-1",
71
+ Effect.gen(function* () {
72
+ yield* Deferred.succeed(started, void 0)
73
+ yield* Effect.never
74
+ }).pipe(Effect.ensuring(Ref.set(finalizerRan, true)))
75
+ )
76
+
77
+ yield* Deferred.await(started)
78
+ expect(yield* manager.isActive(channelScope)).toBe(true)
79
+ const stop = yield* manager.stop(channelScope)
80
+ yield* Fiber.await(fiber)
81
+ return { stop, finalizerRan: yield* Ref.get(finalizerRan), active: yield* manager.isActive(channelScope) }
82
+ })
83
+ )
84
+
85
+ expect(result).toEqual({ stop: { stopped: true }, finalizerRan: true, active: false })
86
+ expect(opencode.aborted).toEqual(["c1"])
87
+ })
88
+
89
+ test("clears active turns that finish before callers can stop them", async () => {
90
+ const opencode = makeMemoryOpencode([])
91
+ const manager = createTurnManager(opencode, makeMemoryDiscord())
92
+
93
+ await Effect.runPromise(
94
+ Effect.gen(function* () {
95
+ const fiber = yield* manager.startTurn(channelScope, "session-1", Effect.void)
96
+ yield* Fiber.join(fiber)
97
+ expect(yield* manager.isActive(channelScope)).toBe(false)
98
+ })
99
+ )
100
+
101
+ expect(opencode.aborted).toEqual([])
102
+ })
103
+
104
+ test("honors the optional global active-turn cap across scopes", async () => {
105
+ const order = await Effect.runPromise(
106
+ Effect.gen(function* () {
107
+ const manager = createTurnManager(makeMemoryOpencode([]), makeMemoryDiscord(), { globalMaxActiveTurns: 1 })
108
+ const firstStarted = yield* Deferred.make<void>()
109
+ const releaseFirst = yield* Deferred.make<void>()
110
+ const seen = yield* Ref.make<ReadonlyArray<string>>([])
111
+
112
+ const first = yield* manager
113
+ .runExclusive(
114
+ channelScope,
115
+ Effect.gen(function* () {
116
+ yield* Ref.update(seen, (values) => [...values, "first-start"])
117
+ yield* Deferred.succeed(firstStarted, void 0)
118
+ yield* Deferred.await(releaseFirst)
119
+ yield* Ref.update(seen, (values) => [...values, "first-end"])
120
+ })
121
+ )
122
+ .pipe(Effect.forkChild)
123
+
124
+ yield* Deferred.await(firstStarted)
125
+ const second = yield* manager
126
+ .runExclusive(
127
+ threadScope,
128
+ Ref.update(seen, (values) => [...values, "second"])
129
+ )
130
+ .pipe(Effect.forkChild)
131
+ expect(yield* Ref.get(seen)).toEqual(["first-start"])
132
+
133
+ yield* Deferred.succeed(releaseFirst, void 0)
134
+ yield* Fiber.join(first)
135
+ yield* Fiber.join(second)
136
+ return yield* Ref.get(seen)
137
+ })
138
+ )
139
+
140
+ expect(order).toEqual(["first-start", "first-end", "second"])
141
+ })
142
+
143
+ test("aborts and clears turns that exceed the configured max duration", async () => {
144
+ const opencode = makeMemoryOpencode([])
145
+ const manager = createTurnManager(opencode, makeMemoryDiscord(), { maxTurn: Duration.millis(1) })
146
+
147
+ await Effect.runPromise(
148
+ Effect.gen(function* () {
149
+ const fiber = yield* manager.startTurn(channelScope, "session-1", Effect.never)
150
+ yield* Fiber.await(fiber)
151
+ expect(yield* manager.isActive(channelScope)).toBe(false)
152
+ })
153
+ )
154
+
155
+ expect(opencode.aborted).toEqual(["c1"])
156
+ })
157
+
158
+ test("still clears timed-out turns when opencode abort fails", async () => {
159
+ const opencode: OpencodeService = {
160
+ checkHealth: Effect.void,
161
+ runPrompt: () => Stream.empty,
162
+ abort: () => Stream.fail(new OpencodeError({ message: "abort failed" }))
163
+ }
164
+ const manager = createTurnManager(opencode, makeMemoryDiscord(), { maxTurn: Duration.millis(1) })
165
+
166
+ await Effect.runPromise(
167
+ Effect.gen(function* () {
168
+ const fiber = yield* manager.startTurn(channelScope, "session-1", Effect.never)
169
+ yield* Fiber.await(fiber)
170
+ expect(yield* manager.isActive(channelScope)).toBe(false)
171
+ })
172
+ )
173
+ })
174
+
175
+ test("/stop reports when this process has no active turn", async () => {
176
+ const manager = createTurnManager(makeMemoryOpencode([]), makeMemoryDiscord())
177
+
178
+ await expect(Effect.runPromise(manager.stop(channelScope))).resolves.toEqual({ stopped: false, reason: "no-active-turn" })
179
+ })
180
+ })
@@ -0,0 +1,179 @@
1
+ import { Deferred, type Duration, Effect, Fiber, Semaphore, Stream } from "effect"
2
+ import type { DiscordService } from "../Discord/DiscordPort.ts"
3
+ import type { OpencodeService } from "../Opencode/OpencodePort.ts"
4
+ import type { DiscordScope } from "../Schema.ts"
5
+
6
+ type ActiveTurn = {
7
+ readonly scope: DiscordScope
8
+ readonly sessionId?: string | undefined
9
+ readonly fiber: Fiber.Fiber<void, never>
10
+ }
11
+
12
+ type PendingTurn = {
13
+ readonly scope: DiscordScope
14
+ readonly sessionId?: string | undefined
15
+ readonly effect: Effect.Effect<void, never>
16
+ }
17
+
18
+ type StopResult = { readonly stopped: true } | { readonly stopped: false; readonly reason: "no-active-turn" }
19
+
20
+ export type TurnManager = {
21
+ readonly runExclusive: <A, E, R>(scope: DiscordScope, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
22
+ readonly startTurn: (
23
+ scope: DiscordScope,
24
+ sessionId: string | undefined,
25
+ effect: Effect.Effect<void, never>
26
+ ) => Effect.Effect<Fiber.Fiber<void, never>>
27
+ readonly stop: (scope: DiscordScope) => Effect.Effect<StopResult>
28
+ readonly isActive: (scope: DiscordScope) => Effect.Effect<boolean>
29
+ }
30
+
31
+ export type TurnManagerOptions = {
32
+ readonly strategy?: "queue" | "burst" | undefined
33
+ readonly globalMaxActiveTurns?: number | null | undefined
34
+ readonly maxTurn?: Duration.Duration | null | undefined
35
+ }
36
+
37
+ const scopeKey = (scope: DiscordScope): string => scope.threadId ?? scope.channelId
38
+
39
+ export const createTurnManager = (opencode: OpencodeService, _discord: DiscordService, options: TurnManagerOptions = {}): TurnManager => {
40
+ const locks = new Map<string, Semaphore.Semaphore>()
41
+ const activeTurns = new Map<string, ActiveTurn>()
42
+ const queuedTurns = new Map<string, PendingTurn>()
43
+ const currentFibers = new Map<string, Fiber.Fiber<void, never>>()
44
+ const waitingTurns = new Map<string, Set<Fiber.Fiber<void, never>>>()
45
+ const globalLock =
46
+ options.globalMaxActiveTurns === undefined || options.globalMaxActiveTurns === null
47
+ ? undefined
48
+ : Semaphore.makeUnsafe(Math.max(1, Math.floor(options.globalMaxActiveTurns)))
49
+
50
+ const lockFor = (scope: DiscordScope): Semaphore.Semaphore => {
51
+ const key = scopeKey(scope)
52
+ const existing = locks.get(key)
53
+ if (existing !== undefined) return existing
54
+ const next = Semaphore.makeUnsafe(1)
55
+ locks.set(key, next)
56
+ return next
57
+ }
58
+
59
+ const withGlobalLimit = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => globalLock?.withPermit(effect) ?? effect
60
+
61
+ const withMaxTurn = (scope: DiscordScope, effect: Effect.Effect<void, never>): Effect.Effect<void, never> => {
62
+ if (options.maxTurn === undefined || options.maxTurn === null) return effect
63
+ return effect.pipe(
64
+ Effect.timeoutOrElse({
65
+ duration: options.maxTurn,
66
+ orElse: () =>
67
+ opencode.abort(scope).pipe(
68
+ Stream.runDrain,
69
+ Effect.catch(() => Effect.void)
70
+ )
71
+ })
72
+ )
73
+ }
74
+
75
+ const runExclusive: TurnManager["runExclusive"] = (scope, effect) => withGlobalLimit(lockFor(scope).withPermit(effect))
76
+
77
+ const hasWaitingTurn = (key: string): boolean => (waitingTurns.get(key)?.size ?? 0) > 0
78
+
79
+ const addWaitingTurn = (key: string, fiber: Fiber.Fiber<void, never>): void => {
80
+ const waiting = waitingTurns.get(key) ?? new Set<Fiber.Fiber<void, never>>()
81
+ waiting.add(fiber)
82
+ waitingTurns.set(key, waiting)
83
+ }
84
+
85
+ const removeWaitingTurn = (key: string, fiber: Fiber.Fiber<void, never>): void => {
86
+ const waiting = waitingTurns.get(key)
87
+ if (waiting === undefined) return
88
+ waiting.delete(fiber)
89
+ if (waiting.size === 0) waitingTurns.delete(key)
90
+ }
91
+
92
+ const completeTurn = (key: string, fiber: Fiber.Fiber<void, never>): Effect.Effect<void> =>
93
+ Effect.gen(function* () {
94
+ yield* Effect.sync(() => removeWaitingTurn(key, fiber))
95
+ if (activeTurns.get(key)?.fiber === fiber) activeTurns.delete(key)
96
+ const queued = queuedTurns.get(key)
97
+ if (queued === undefined) {
98
+ if (currentFibers.get(key) === fiber) currentFibers.delete(key)
99
+ return
100
+ }
101
+ queuedTurns.delete(key)
102
+ yield* launchTurn(queued).pipe(Effect.asVoid)
103
+ })
104
+
105
+ const launchTurn = Effect.fn("launchTurn")(function* (turn: PendingTurn) {
106
+ const key = scopeKey(turn.scope)
107
+ const startGate = yield* Deferred.make<void>()
108
+ const self = yield* Deferred.make<Fiber.Fiber<void, never>>()
109
+ const fiber = yield* Effect.gen(function* () {
110
+ yield* Deferred.await(startGate)
111
+ const current = yield* Deferred.await(self)
112
+ yield* runExclusive(
113
+ turn.scope,
114
+ Effect.gen(function* () {
115
+ yield* Effect.sync(() => {
116
+ removeWaitingTurn(key, current)
117
+ activeTurns.set(key, { scope: turn.scope, sessionId: turn.sessionId, fiber: current })
118
+ })
119
+ yield* withMaxTurn(turn.scope, turn.effect)
120
+ })
121
+ )
122
+ })
123
+ .pipe(Effect.ensuring(Deferred.await(self).pipe(Effect.flatMap((current) => completeTurn(key, current)))))
124
+ .pipe(Effect.forkDetach)
125
+
126
+ yield* Effect.sync(() => {
127
+ addWaitingTurn(key, fiber)
128
+ currentFibers.set(key, fiber)
129
+ })
130
+ yield* Deferred.succeed(self, fiber)
131
+ yield* Deferred.succeed(startGate, void 0)
132
+ return fiber
133
+ })
134
+
135
+ const startTurn: TurnManager["startTurn"] = (scope, sessionId, effect) =>
136
+ Effect.gen(function* () {
137
+ const key = scopeKey(scope)
138
+ const turn = { scope, sessionId, effect } satisfies PendingTurn
139
+ const current = currentFibers.get(key)
140
+ if (current !== undefined && options.strategy !== "burst") {
141
+ queuedTurns.set(key, turn)
142
+ return current
143
+ }
144
+ return yield* launchTurn(turn)
145
+ })
146
+
147
+ const stop: TurnManager["stop"] = (scope) =>
148
+ Effect.gen(function* () {
149
+ const key = scopeKey(scope)
150
+ const active = activeTurns.get(key)
151
+ const waiting = [...(waitingTurns.get(key) ?? [])]
152
+ const queued = queuedTurns.get(key)
153
+ if (active === undefined && waiting.length === 0 && queued === undefined) return { stopped: false, reason: "no-active-turn" } as const
154
+
155
+ activeTurns.delete(key)
156
+ queuedTurns.delete(key)
157
+ currentFibers.delete(key)
158
+ waitingTurns.delete(key)
159
+ if (active !== undefined) {
160
+ yield* opencode.abort(active.scope).pipe(
161
+ Stream.runDrain,
162
+ Effect.catch(() => Effect.void)
163
+ )
164
+ yield* Fiber.interrupt(active.fiber)
165
+ }
166
+ for (const fiber of waiting) {
167
+ yield* Fiber.interrupt(fiber).pipe(Effect.catch(() => Effect.void))
168
+ }
169
+ return { stopped: true } as const
170
+ })
171
+
172
+ const isActive: TurnManager["isActive"] = (scope) =>
173
+ Effect.sync(() => {
174
+ const key = scopeKey(scope)
175
+ return activeTurns.has(key) || currentFibers.has(key) || hasWaitingTurn(key) || queuedTurns.has(key)
176
+ })
177
+
178
+ return { runExclusive, startTurn, stop, isActive }
179
+ }
@@ -0,0 +1,136 @@
1
+ import { expect, test } from "bun:test"
2
+ import { Deferred, Effect, Fiber, Ref } from "effect"
3
+ import { makeMemoryDiscord } from "../Discord/MemoryDiscord.ts"
4
+ import { makeMemoryOpencode } from "../Opencode/MemoryOpencode.ts"
5
+ import type { DiscordScope } from "../Schema.ts"
6
+ import { createTurnManager } from "./TurnManager.ts"
7
+
8
+ const channelScope: DiscordScope = { guildId: "g1", channelId: "c1" }
9
+
10
+ test("queue strategy keeps only the latest pending turn per Discord scope", async () => {
11
+ const order = await Effect.runPromise(
12
+ Effect.gen(function* () {
13
+ const manager = createTurnManager(makeMemoryOpencode([]), makeMemoryDiscord(), { strategy: "queue" })
14
+ const firstStarted = yield* Deferred.make<void>()
15
+ const releaseFirst = yield* Deferred.make<void>()
16
+ const thirdRan = yield* Deferred.make<void>()
17
+ const seen = yield* Ref.make<ReadonlyArray<string>>([])
18
+
19
+ const first = yield* manager.startTurn(
20
+ channelScope,
21
+ "session-1",
22
+ Effect.gen(function* () {
23
+ yield* Ref.update(seen, (values) => [...values, "first"])
24
+ yield* Deferred.succeed(firstStarted, void 0)
25
+ yield* Deferred.await(releaseFirst)
26
+ })
27
+ )
28
+ yield* Deferred.await(firstStarted)
29
+
30
+ const second = yield* manager.startTurn(
31
+ channelScope,
32
+ "session-2",
33
+ Ref.update(seen, (values) => [...values, "second"])
34
+ )
35
+ const third = yield* manager.startTurn(
36
+ channelScope,
37
+ "session-3",
38
+ Effect.gen(function* () {
39
+ yield* Ref.update(seen, (values) => [...values, "third"])
40
+ yield* Deferred.succeed(thirdRan, void 0)
41
+ })
42
+ )
43
+
44
+ expect(second).toBe(first)
45
+ expect(third).toBe(first)
46
+
47
+ yield* Deferred.succeed(releaseFirst, void 0)
48
+ yield* Fiber.join(first)
49
+ yield* Deferred.await(thirdRan)
50
+ return yield* Ref.get(seen)
51
+ })
52
+ )
53
+
54
+ expect(order).toEqual(["first", "third"])
55
+ })
56
+
57
+ test("burst strategy serializes every started turn for a busy scope", async () => {
58
+ const order = await Effect.runPromise(
59
+ Effect.gen(function* () {
60
+ const manager = createTurnManager(makeMemoryOpencode([]), makeMemoryDiscord(), { strategy: "burst" })
61
+ const firstStarted = yield* Deferred.make<void>()
62
+ const releaseFirst = yield* Deferred.make<void>()
63
+ const seen = yield* Ref.make<ReadonlyArray<string>>([])
64
+
65
+ const first = yield* manager.startTurn(
66
+ channelScope,
67
+ "session-1",
68
+ Effect.gen(function* () {
69
+ yield* Ref.update(seen, (values) => [...values, "first"])
70
+ yield* Deferred.succeed(firstStarted, void 0)
71
+ yield* Deferred.await(releaseFirst)
72
+ })
73
+ )
74
+ yield* Deferred.await(firstStarted)
75
+
76
+ const second = yield* manager.startTurn(
77
+ channelScope,
78
+ "session-2",
79
+ Ref.update(seen, (values) => [...values, "second"])
80
+ )
81
+ const third = yield* manager.startTurn(
82
+ channelScope,
83
+ "session-3",
84
+ Ref.update(seen, (values) => [...values, "third"])
85
+ )
86
+
87
+ expect(second).not.toBe(first)
88
+ expect(third).not.toBe(first)
89
+
90
+ yield* Deferred.succeed(releaseFirst, void 0)
91
+ yield* Fiber.join(first)
92
+ yield* Fiber.join(second)
93
+ yield* Fiber.join(third)
94
+ return yield* Ref.get(seen)
95
+ })
96
+ )
97
+
98
+ expect(order).toEqual(["first", "second", "third"])
99
+ })
100
+
101
+ test("stop cancels active and waiting burst turns for the same scope", async () => {
102
+ const opencode = makeMemoryOpencode([])
103
+ const result = await Effect.runPromise(
104
+ Effect.gen(function* () {
105
+ const manager = createTurnManager(opencode, makeMemoryDiscord(), { strategy: "burst" })
106
+ const firstStarted = yield* Deferred.make<void>()
107
+ const firstFinalized = yield* Ref.make(false)
108
+ const secondStarted = yield* Ref.make(false)
109
+
110
+ const first = yield* manager.startTurn(
111
+ channelScope,
112
+ "session-1",
113
+ Effect.gen(function* () {
114
+ yield* Deferred.succeed(firstStarted, void 0)
115
+ yield* Effect.never
116
+ }).pipe(Effect.ensuring(Ref.set(firstFinalized, true)))
117
+ )
118
+ yield* Deferred.await(firstStarted)
119
+ const second = yield* manager.startTurn(channelScope, "session-2", Ref.set(secondStarted, true))
120
+
121
+ const stop = yield* manager.stop(channelScope)
122
+ yield* Fiber.await(first)
123
+ yield* Fiber.await(second)
124
+
125
+ return {
126
+ active: yield* manager.isActive(channelScope),
127
+ firstFinalized: yield* Ref.get(firstFinalized),
128
+ secondStarted: yield* Ref.get(secondStarted),
129
+ stop
130
+ }
131
+ })
132
+ )
133
+
134
+ expect(result).toEqual({ active: false, firstFinalized: true, secondStarted: false, stop: { stopped: true } })
135
+ expect(opencode.aborted).toEqual(["c1"])
136
+ })
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { makeLiveChatSdkDiscord } from "./Discord/ChatSdkDiscord.ts"
4
+ import { Discord } from "./Discord/DiscordPort.ts"
5
+ import { makeMemoryDiscord } from "./Discord/MemoryDiscord.ts"
6
+ import { makeMemoryOpencode } from "./Opencode/MemoryOpencode.ts"
7
+ import { Opencode } from "./Opencode/OpencodePort.ts"
8
+ import { makeLiveSdkOpencode } from "./Opencode/SdkOpencode.ts"
9
+ import type { DiscordAuthor, DiscordReaction, Snowflake, ToolTarget } from "./Schema.ts"
10
+ import { ToolTargetSchema } from "./Schema.ts"
11
+
12
+ describe("public contracts", () => {
13
+ test("keeps Effect service tags and live adapter factories exported", () => {
14
+ expect(Discord).toBeDefined()
15
+ expect(Opencode).toBeDefined()
16
+ expect(makeLiveChatSdkDiscord({ botToken: "token", applicationId: "123", publicKey: "0".repeat(64) })).toBeDefined()
17
+ expect(makeLiveSdkOpencode({ baseUrl: "http://127.0.0.1:4096", projectDir: "/repo" })).toBeDefined()
18
+ expect(ToolTargetSchema).toBeDefined()
19
+ })
20
+
21
+ test("provides and retrieves Effect service tags", async () => {
22
+ const discord = makeMemoryDiscord()
23
+ const opencode = makeMemoryOpencode([])
24
+
25
+ const services = await Effect.gen(function* () {
26
+ return { discord: yield* Discord, opencode: yield* Opencode }
27
+ }).pipe(Effect.provideService(Discord, discord), Effect.provideService(Opencode, opencode), Effect.runPromise)
28
+
29
+ expect(services.discord).toBe(discord)
30
+ expect(services.opencode).toBe(opencode)
31
+ })
32
+
33
+ test("keeps exported schema-adjacent types usable", () => {
34
+ const snowflake: Snowflake = "123"
35
+ const author: DiscordAuthor = { id: snowflake, displayName: "Alice", isBot: false }
36
+ const reaction: DiscordReaction = { emoji: "rocket", count: 1 }
37
+ const target: ToolTarget = { guildId: "g1", channelId: "c1" }
38
+
39
+ expect(author.id).toBe("123")
40
+ expect(reaction.count).toBe(1)
41
+ expect(target.channelId).toBe("c1")
42
+ })
43
+ })