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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +78 -0
- package/src/Bridge/LoopbackServer.test.ts +94 -0
- package/src/Bridge/LoopbackServer.ts +77 -0
- package/src/Bridge/ToolControl.test.ts +245 -0
- package/src/Bridge/ToolControl.ts +260 -0
- package/src/Bridge/ToolControlEdges.test.ts +49 -0
- package/src/Bridge/ToolControlHighRisk.test.ts +153 -0
- package/src/Config.test.ts +142 -0
- package/src/Config.ts +295 -0
- package/src/ConfigSchema.ts +46 -0
- package/src/ConfigTypes.ts +11 -0
- package/src/Discord/ChatSdkDiscord.test.ts +257 -0
- package/src/Discord/ChatSdkDiscord.ts +206 -0
- package/src/Discord/ChatSdkGatewayIntake.test.ts +121 -0
- package/src/Discord/ChatSdkGatewayIntake.ts +235 -0
- package/src/Discord/DiscordGateway.test.ts +215 -0
- package/src/Discord/DiscordGateway.ts +140 -0
- package/src/Discord/DiscordGatewayFailures.test.ts +148 -0
- package/src/Discord/DiscordJsDiscord.test.ts +208 -0
- package/src/Discord/DiscordJsDiscord.ts +267 -0
- package/src/Discord/DiscordPort.ts +30 -0
- package/src/Discord/MemoryDiscord.test.ts +44 -0
- package/src/Discord/MemoryDiscord.ts +85 -0
- package/src/Discord/Safety.ts +11 -0
- package/src/Main.test.ts +273 -0
- package/src/Main.ts +192 -0
- package/src/MainQueue.test.ts +124 -0
- package/src/Opencode/EventMapping.test.ts +188 -0
- package/src/Opencode/EventMapping.ts +232 -0
- package/src/Opencode/EventMappingState.ts +97 -0
- package/src/Opencode/MemoryOpencode.test.ts +18 -0
- package/src/Opencode/MemoryOpencode.ts +29 -0
- package/src/Opencode/OpencodePort.ts +30 -0
- package/src/Opencode/PromptParts.ts +47 -0
- package/src/Opencode/SdkOpencode.test.ts +280 -0
- package/src/Opencode/SdkOpencode.ts +270 -0
- package/src/Opencode/SdkOpencodeAttachments.test.ts +79 -0
- package/src/Opencode/SdkOpencodeFailures.test.ts +113 -0
- package/src/Orchestrator/ContextAssembly.test.ts +115 -0
- package/src/Orchestrator/ContextAssembly.ts +120 -0
- package/src/Orchestrator/Orchestrator.ts +67 -0
- package/src/Orchestrator/StopCommand.test.ts +20 -0
- package/src/Orchestrator/StopCommand.ts +14 -0
- package/src/Orchestrator/Triggering.test.ts +56 -0
- package/src/Orchestrator/Triggering.ts +26 -0
- package/src/Orchestrator/TurnManager.test.ts +180 -0
- package/src/Orchestrator/TurnManager.ts +179 -0
- package/src/Orchestrator/TurnManagerStrategy.test.ts +136 -0
- package/src/PublicContracts.test.ts +43 -0
- package/src/Render/Renderer.test.ts +249 -0
- package/src/Render/Renderer.ts +159 -0
- package/src/Render/Splitting.test.ts +30 -0
- package/src/Render/Splitting.ts +68 -0
- package/src/Schema.ts +93 -0
- package/src/Tools/Scaffolding.test.ts +56 -0
- 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
|
+
})
|