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,280 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect, Stream } from "effect"
3
+ import type { DiscordScope } from "../Schema.ts"
4
+ import { makeLiveSdkOpencode, makeSdkOpencode } from "./SdkOpencode.ts"
5
+
6
+ const scope: DiscordScope = { guildId: "g1", channelId: "c1" }
7
+
8
+ describe("makeSdkOpencode", () => {
9
+ test("creates a session, sends a prompt, filters SSE events, and cleans up after idle", async () => {
10
+ const calls: Array<readonly [string, unknown]> = []
11
+ const client = {
12
+ session: {
13
+ create: (parameters: unknown) => {
14
+ calls.push(["session.create", parameters])
15
+ return Promise.resolve({ data: { id: "session-1" }, error: undefined })
16
+ },
17
+ promptAsync: (parameters: unknown) => {
18
+ calls.push(["session.promptAsync", parameters])
19
+ return Promise.resolve({ data: {}, error: undefined })
20
+ },
21
+ abort: (parameters: unknown) => {
22
+ calls.push(["session.abort", parameters])
23
+ return Promise.resolve({ data: {}, error: undefined })
24
+ }
25
+ },
26
+ event: {
27
+ subscribe: (parameters: unknown) => {
28
+ calls.push(["event.subscribe", parameters])
29
+ return Promise.resolve({
30
+ stream: (async function* () {
31
+ calls.push(["stream.start", {}])
32
+ yield { type: "session.next.text.delta", properties: { sessionID: "other-session", delta: "ignore" } }
33
+ yield {
34
+ directory: "/repo",
35
+ payload: { type: "session.next.text.delta", properties: { sessionID: "other-session", delta: "ignore-wrapped" } }
36
+ }
37
+ yield {
38
+ type: "message.part.delta",
39
+ properties: { part: { type: "text", sessionID: "other-session" }, delta: "ignore-nested" }
40
+ }
41
+ yield { type: "session.next.text.delta", properties: { sessionID: "session-1", delta: "ok" } }
42
+ yield {
43
+ directory: "/repo",
44
+ payload: { type: "session.next.text.delta", properties: { sessionID: "session-1", delta: "wrapped" } }
45
+ }
46
+ yield {
47
+ type: "message.updated",
48
+ properties: { sessionID: "session-1", info: { id: "assistant-message", role: "assistant" } }
49
+ }
50
+ yield {
51
+ type: "message.part.delta",
52
+ properties: { messageID: "assistant-message", part: { type: "text", sessionID: "session-1" }, delta: "nested" }
53
+ }
54
+ yield { type: "session.idle", properties: { sessionID: "session-1" } }
55
+ yield { type: "session.next.text.delta", properties: { sessionID: "session-1", delta: "after-idle" } }
56
+ })()
57
+ })
58
+ }
59
+ },
60
+ global: {
61
+ health: () => {
62
+ calls.push(["global.health", {}])
63
+ return Promise.resolve({ data: { ok: true }, error: undefined })
64
+ }
65
+ }
66
+ }
67
+
68
+ const opencode = makeSdkOpencode(client, { baseUrl: "http://127.0.0.1:4096", projectDir: "/repo" })
69
+ const events = await Effect.runPromise(
70
+ opencode
71
+ .runPrompt({
72
+ prompt: "hello",
73
+ parts: [{ type: "file", mime: "text/plain", filename: "notes.txt", url: "https://cdn/notes.txt" }],
74
+ projectDir: "/repo",
75
+ scope,
76
+ agent: "build",
77
+ model: "anthropic/claude"
78
+ })
79
+ .pipe(Stream.runCollect)
80
+ )
81
+ await Effect.runPromise(opencode.abort(scope).pipe(Stream.runCollect))
82
+ await Effect.runPromise(opencode.checkHealth)
83
+
84
+ expect(events).toEqual([
85
+ { type: "text-delta", text: "ok" },
86
+ { type: "text-delta", text: "wrapped" },
87
+ { type: "text-delta", text: "nested" },
88
+ { type: "idle" }
89
+ ])
90
+ expect(calls).toEqual([
91
+ ["event.subscribe", { directory: "/repo" }],
92
+ ["stream.start", {}],
93
+ ["session.create", { directory: "/repo", agent: "build", model: { id: "claude", providerID: "anthropic" } }],
94
+ [
95
+ "session.promptAsync",
96
+ {
97
+ sessionID: "session-1",
98
+ directory: "/repo",
99
+ agent: "build",
100
+ model: { providerID: "anthropic", modelID: "claude" },
101
+ parts: [
102
+ { type: "text", text: "hello" },
103
+ { type: "file", mime: "text/plain", filename: "notes.txt", url: "https://cdn/notes.txt" }
104
+ ]
105
+ }
106
+ ],
107
+ ["global.health", {}]
108
+ ])
109
+ })
110
+
111
+ test("aborts an active session before the event stream finishes", async () => {
112
+ const calls: Array<readonly [string, unknown]> = []
113
+ let releaseStream: (() => void) | undefined
114
+ const waiting = new Promise<void>((resolve) => {
115
+ releaseStream = resolve
116
+ })
117
+ const client = {
118
+ session: {
119
+ create: (parameters: unknown) => {
120
+ calls.push(["session.create", parameters])
121
+ return Promise.resolve({ data: { id: "session-1" }, error: undefined })
122
+ },
123
+ promptAsync: (parameters: unknown) => {
124
+ calls.push(["session.promptAsync", parameters])
125
+ return Promise.resolve({ data: {}, error: undefined })
126
+ },
127
+ abort: (parameters: unknown) => {
128
+ calls.push(["session.abort", parameters])
129
+ return Promise.resolve({ data: {}, error: undefined })
130
+ }
131
+ },
132
+ event: {
133
+ subscribe: () =>
134
+ Promise.resolve({
135
+ stream: (async function* () {
136
+ yield { type: "session.next.text.delta", properties: { sessionID: "session-1", delta: "ok" } }
137
+ await waiting
138
+ })()
139
+ })
140
+ },
141
+ global: {
142
+ health: () => Promise.resolve({ data: { ok: true }, error: undefined })
143
+ }
144
+ }
145
+
146
+ const opencode = makeSdkOpencode(client, { baseUrl: "http://127.0.0.1:4096", projectDir: "/repo" })
147
+ const running = Effect.runPromise(opencode.runPrompt({ prompt: "hello", projectDir: "/repo", scope }).pipe(Stream.runDrain))
148
+ await new Promise((resolve) => setTimeout(resolve, 0))
149
+
150
+ await Effect.runPromise(opencode.abort(scope).pipe(Stream.runDrain))
151
+ releaseStream?.()
152
+ await running
153
+
154
+ expect(calls).toContainEqual(["session.abort", { sessionID: "session-1", directory: "/repo" }])
155
+ })
156
+ })
157
+
158
+ describe("makeSdkOpencode errors", () => {
159
+ test("maps thrown SDK errors to opencode errors", async () => {
160
+ const client = {
161
+ session: {
162
+ create: () => Promise.resolve({ data: { id: "session-1" }, error: undefined }),
163
+ promptAsync: () => Promise.resolve({ data: {}, error: undefined }),
164
+ abort: () => Promise.resolve({ data: {}, error: undefined })
165
+ },
166
+ event: {
167
+ subscribe: () => Promise.reject(new Error("subscribe failed"))
168
+ },
169
+ global: {
170
+ health: () => Promise.resolve({ data: { ok: true }, error: undefined })
171
+ }
172
+ }
173
+
174
+ const opencode = makeSdkOpencode(client, { baseUrl: "http://127.0.0.1:4096", projectDir: "/repo" })
175
+
176
+ await expect(
177
+ opencode.runPrompt({ prompt: "hello", projectDir: "/repo", scope }).pipe(Stream.runCollect, Effect.runPromise)
178
+ ).rejects.toMatchObject({
179
+ _tag: "OpencodeError",
180
+ message: "subscribe failed"
181
+ })
182
+ })
183
+
184
+ test("maps SDK result errors and constructs the live client wrapper", async () => {
185
+ const client = {
186
+ session: {
187
+ create: () => Promise.resolve({ data: { id: "session-1" }, error: undefined }),
188
+ promptAsync: () => Promise.resolve({ data: {}, error: undefined }),
189
+ abort: () => Promise.resolve({ data: {}, error: undefined })
190
+ },
191
+ event: {
192
+ subscribe: () => Promise.resolve({ stream: (async function* () {})() })
193
+ },
194
+ global: {
195
+ health: () => Promise.resolve({ data: undefined, error: "health failed" })
196
+ }
197
+ }
198
+
199
+ const opencode = makeSdkOpencode(client, { baseUrl: "http://127.0.0.1:4096", projectDir: "/repo" })
200
+
201
+ await expect(Effect.runPromise(opencode.checkHealth)).rejects.toMatchObject({ _tag: "OpencodeError", message: "health failed" })
202
+ expect(makeLiveSdkOpencode({ baseUrl: "http://127.0.0.1:4096", projectDir: "/repo" })).toBeDefined()
203
+ })
204
+
205
+ test("extracts structured SDK result error messages", async () => {
206
+ const client = {
207
+ session: {
208
+ create: () => Promise.resolve({ data: { id: "session-1" }, error: undefined }),
209
+ promptAsync: () => Promise.resolve({ data: {}, error: undefined }),
210
+ abort: () => Promise.resolve({ data: {}, error: undefined })
211
+ },
212
+ event: {
213
+ subscribe: () => Promise.resolve({ stream: (async function* () {})() })
214
+ },
215
+ global: {
216
+ health: () =>
217
+ Promise.resolve({
218
+ data: undefined,
219
+ error: { name: "BadRequest", data: { message: "invalid project directory", kind: "Query" } }
220
+ })
221
+ }
222
+ }
223
+
224
+ const opencode = makeSdkOpencode(client, { baseUrl: "http://127.0.0.1:4096", projectDir: "/repo" })
225
+
226
+ await expect(Effect.runPromise(opencode.checkHealth)).rejects.toMatchObject({
227
+ _tag: "OpencodeError",
228
+ message: "BadRequest: invalid project directory"
229
+ })
230
+ })
231
+
232
+ test("uses HTTP response details when SDK errors have no message", async () => {
233
+ const client = {
234
+ session: {
235
+ create: () => Promise.resolve({ data: { id: "session-1" }, error: undefined }),
236
+ promptAsync: () => Promise.resolve({ data: {}, error: undefined }),
237
+ abort: () => Promise.resolve({ data: {}, error: undefined })
238
+ },
239
+ event: {
240
+ subscribe: () => Promise.resolve({ stream: (async function* () {})() })
241
+ },
242
+ global: {
243
+ health: () =>
244
+ Promise.resolve({
245
+ data: undefined,
246
+ error: {},
247
+ request: new Request("http://127.0.0.1:4096/global/health?directory=%2Fsecret"),
248
+ response: new Response("", { status: 500, statusText: "Internal Server Error" })
249
+ })
250
+ }
251
+ }
252
+
253
+ const opencode = makeSdkOpencode(client, { baseUrl: "http://127.0.0.1:4096", projectDir: "/repo" })
254
+
255
+ await expect(Effect.runPromise(opencode.checkHealth)).rejects.toMatchObject({
256
+ _tag: "OpencodeError",
257
+ message: "GET /global/health returned 500 Internal Server Error"
258
+ })
259
+ })
260
+
261
+ test("maps rejected SDK requests", async () => {
262
+ const client = {
263
+ session: {
264
+ create: () => Promise.resolve({ data: { id: "session-1" }, error: undefined }),
265
+ promptAsync: () => Promise.resolve({ data: {}, error: undefined }),
266
+ abort: () => Promise.resolve({ data: {}, error: undefined })
267
+ },
268
+ event: {
269
+ subscribe: () => Promise.resolve({ stream: (async function* () {})() })
270
+ },
271
+ global: {
272
+ health: () => Promise.reject(new Error("health rejected"))
273
+ }
274
+ }
275
+
276
+ const opencode = makeSdkOpencode(client, { baseUrl: "http://127.0.0.1:4096", projectDir: "/repo" })
277
+
278
+ await expect(Effect.runPromise(opencode.checkHealth)).rejects.toMatchObject({ _tag: "OpencodeError", message: "health rejected" })
279
+ })
280
+ })
@@ -0,0 +1,270 @@
1
+ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
2
+ import { type Cause, Data, Effect, Fiber, Queue, Stream } from "effect"
3
+ import type { DiscordScope, OpencodeEvent } from "../Schema.ts"
4
+ import { opencodeEventStream } from "./EventMapping.ts"
5
+ import { OpencodeError, type OpencodePrompt, type OpencodeService } from "./OpencodePort.ts"
6
+ import { preparePromptParts } from "./PromptParts.ts"
7
+
8
+ type RequestMetadata = {
9
+ readonly request?: Request
10
+ readonly response?: Response | undefined
11
+ }
12
+
13
+ type RequestResult = Promise<
14
+ | ({ readonly data: unknown; readonly error: undefined } & RequestMetadata)
15
+ | ({ readonly data: undefined; readonly error: unknown } & RequestMetadata)
16
+ >
17
+
18
+ type SseResult = Promise<{ readonly stream: AsyncIterable<unknown> }>
19
+
20
+ type CreateSessionParameters = {
21
+ readonly directory?: string
22
+ readonly agent?: string
23
+ readonly model?: {
24
+ readonly id: string
25
+ readonly providerID: string
26
+ readonly variant?: string
27
+ }
28
+ }
29
+
30
+ type PromptParameters = {
31
+ readonly sessionID: string
32
+ readonly directory?: string
33
+ readonly agent?: string
34
+ readonly model?: {
35
+ readonly providerID: string
36
+ readonly modelID: string
37
+ }
38
+ readonly parts: Array<
39
+ | { readonly type: "text"; readonly text: string }
40
+ | { readonly type: "file"; readonly mime: string; readonly filename?: string; readonly url: string }
41
+ >
42
+ }
43
+
44
+ type AbortParameters = {
45
+ readonly sessionID: string
46
+ readonly directory?: string
47
+ }
48
+
49
+ type SubscribeParameters = {
50
+ readonly directory?: string
51
+ }
52
+
53
+ type SdkClient = {
54
+ readonly session: {
55
+ readonly create: (parameters?: CreateSessionParameters) => RequestResult
56
+ readonly promptAsync: (parameters: PromptParameters) => RequestResult
57
+ readonly abort: (parameters: AbortParameters) => RequestResult
58
+ }
59
+ readonly event: {
60
+ readonly subscribe: (parameters?: SubscribeParameters) => SseResult
61
+ }
62
+ readonly global: {
63
+ readonly health: () => RequestResult
64
+ }
65
+ }
66
+
67
+ type SdkOptions = {
68
+ readonly baseUrl: string
69
+ readonly projectDir: string
70
+ }
71
+
72
+ class SdkFailure extends Data.TaggedError("SdkFailure")<{
73
+ readonly message: string
74
+ }> {}
75
+
76
+ const scopeKey = (scope: DiscordScope): string => scope.threadId ?? scope.channelId
77
+
78
+ const dataId = (value: unknown): string | undefined => {
79
+ if (typeof value !== "object" || value === null || !("id" in value)) return undefined
80
+ return typeof value.id === "string" ? value.id : undefined
81
+ }
82
+
83
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
84
+ typeof value === "object" && value !== null && !Array.isArray(value)
85
+
86
+ const stringField = (record: Readonly<Record<string, unknown>>, key: string): string | undefined => {
87
+ const value = record[key]
88
+ return typeof value === "string" ? value : undefined
89
+ }
90
+
91
+ const withErrorName = (name: string | undefined, message: string | undefined): string | undefined => {
92
+ if (message === undefined || message.length === 0) return name
93
+ return name === undefined || name.length === 0 ? message : `${name}: ${message}`
94
+ }
95
+
96
+ const recordSdkErrorText = (value: Readonly<Record<string, unknown>>): string | undefined => {
97
+ const name = stringField(value, "name") ?? stringField(value, "_tag")
98
+ const data = value.data
99
+ if (isRecord(data)) {
100
+ const message = stringField(data, "message")
101
+ const text = withErrorName(name, message)
102
+ if (text !== undefined) return text
103
+ }
104
+
105
+ const message = stringField(value, "message")
106
+ const text = withErrorName(name, message)
107
+ if (text !== undefined) return text
108
+
109
+ const nestedError = sdkErrorText(value.error)
110
+ if (nestedError !== undefined) return nestedError
111
+
112
+ if (isRecord(value.cause)) {
113
+ const bodyError = sdkErrorText(value.cause.body)
114
+ if (bodyError !== undefined) return bodyError
115
+ }
116
+
117
+ return undefined
118
+ }
119
+
120
+ const sdkErrorText = (value: unknown): string | undefined => {
121
+ if (value instanceof Error) return value.message.length > 0 ? value.message : sdkErrorText(value.cause)
122
+ if (typeof value === "string") return value.length > 0 ? value : undefined
123
+ return isRecord(value) ? recordSdkErrorText(value) : undefined
124
+ }
125
+
126
+ const requestPath = (request: Request): string => {
127
+ try {
128
+ return new URL(request.url).pathname
129
+ } catch {
130
+ return request.url
131
+ }
132
+ }
133
+
134
+ const requestFailureText = (metadata: RequestMetadata): string | undefined => {
135
+ const { request, response } = metadata
136
+ if (request === undefined && response === undefined) return undefined
137
+ const target = request === undefined ? "opencode server request" : `${request.method} ${requestPath(request)}`
138
+ if (response === undefined) return `${target} failed before receiving a response`
139
+ const statusText = response.statusText.length > 0 ? ` ${response.statusText}` : ""
140
+ return `${target} returned ${response.status}${statusText}`
141
+ }
142
+
143
+ const errorText = (value: unknown, metadata: RequestMetadata = {}): string =>
144
+ sdkErrorText(value) ?? requestFailureText(metadata) ?? "opencode SDK request failed"
145
+
146
+ const modelFromConfig = (value: string | undefined): { readonly providerID: string; readonly modelID: string } | undefined => {
147
+ if (value === undefined) return undefined
148
+ const slash = value.indexOf("/")
149
+ const colon = value.indexOf(":")
150
+ const separator = slash >= 0 ? slash : colon
151
+ if (separator <= 0 || separator === value.length - 1) return undefined
152
+ return { providerID: value.slice(0, separator), modelID: value.slice(separator + 1) }
153
+ }
154
+
155
+ const sessionIdFromRecord = (value: Readonly<Record<string, unknown>>, depth: number): string | undefined => {
156
+ const direct = stringField(value, "sessionID") ?? stringField(value, "sessionId")
157
+ if (direct !== undefined) return direct
158
+ if (depth <= 0) return undefined
159
+ const properties = ["payload", "properties", "session", "message", "part"] as const
160
+ for (const property of properties) {
161
+ const nested = value[property]
162
+ if (!isRecord(nested)) continue
163
+ const nestedId = sessionIdFromRecord(nested, depth - 1)
164
+ if (nestedId !== undefined) return nestedId
165
+ }
166
+ return undefined
167
+ }
168
+
169
+ const sessionIdFromEvent = (value: unknown): string | undefined => (isRecord(value) ? sessionIdFromRecord(value, 3) : undefined)
170
+
171
+ const belongsToSession =
172
+ (sessionID: string) =>
173
+ (value: unknown): boolean => {
174
+ const eventSessionID = sessionIdFromEvent(value)
175
+ return eventSessionID === undefined || eventSessionID === sessionID
176
+ }
177
+
178
+ const request = Effect.fn("opencodeSdkRequest")(function* (value: RequestResult) {
179
+ const result = yield* Effect.tryPromise({
180
+ try: () => value,
181
+ catch: (cause) => new SdkFailure({ message: errorText(cause) })
182
+ })
183
+ if (result.error !== undefined) return yield* Effect.fail(new SdkFailure({ message: errorText(result.error, result) }))
184
+ return result.data
185
+ })
186
+
187
+ const createSession = Effect.fn("createOpencodeSession")(function* (client: SdkClient, input: OpencodePrompt) {
188
+ const model = modelFromConfig(input.model)
189
+ const data = yield* request(
190
+ client.session.create({
191
+ directory: input.projectDir,
192
+ ...(input.agent === undefined ? {} : { agent: input.agent }),
193
+ ...(model === undefined ? {} : { model: { id: model.modelID, providerID: model.providerID } })
194
+ })
195
+ )
196
+ const id = dataId(data)
197
+ if (id === undefined) return yield* Effect.fail(new SdkFailure({ message: "opencode session create did not return an id" }))
198
+ return id
199
+ })
200
+
201
+ export const makeSdkOpencode = (client: SdkClient, options: SdkOptions): OpencodeService => {
202
+ const activeSessions = new Map<string, string>()
203
+
204
+ const runPrompt = (input: OpencodePrompt): Stream.Stream<OpencodeEvent, OpencodeError> =>
205
+ Stream.unwrap(
206
+ Effect.gen(function* () {
207
+ const parts = yield* preparePromptParts(input.parts).pipe(Effect.mapError((error) => new SdkFailure({ message: error.message })))
208
+ const key = scopeKey(input.scope)
209
+ const model = modelFromConfig(input.model)
210
+ const sse = yield* Effect.tryPromise({
211
+ try: () => client.event.subscribe({ directory: input.projectDir }),
212
+ catch: (cause) => new SdkFailure({ message: errorText(cause) })
213
+ })
214
+ const queue = yield* Queue.unbounded<unknown, OpencodeError | Cause.Done>()
215
+ const reader = yield* Stream.fromAsyncIterable(sse.stream, (cause) => new OpencodeError({ message: errorText(cause) }))
216
+ .pipe(
217
+ Stream.runForEach((event) => Queue.offer(queue, event).pipe(Effect.asVoid)),
218
+ Effect.catch((error) => Queue.fail(queue, error).pipe(Effect.asVoid)),
219
+ Effect.ensuring(Queue.end(queue).pipe(Effect.asVoid))
220
+ )
221
+ .pipe(Effect.forkChild({ startImmediately: true }))
222
+ yield* Effect.yieldNow
223
+ const sessionID = yield* createSession(client, input)
224
+ activeSessions.set(key, sessionID)
225
+ yield* request(
226
+ client.session.promptAsync({
227
+ sessionID,
228
+ directory: input.projectDir,
229
+ ...(input.agent === undefined ? {} : { agent: input.agent }),
230
+ ...(model === undefined ? {} : { model }),
231
+ parts: [{ type: "text", text: input.prompt }, ...parts]
232
+ })
233
+ )
234
+ return opencodeEventStream(Stream.fromQueue(queue).pipe(Stream.filter(belongsToSession(sessionID)))).pipe(
235
+ Stream.takeUntil((event) => event.type === "idle" || event.type === "error"),
236
+ Stream.ensuring(
237
+ Effect.gen(function* () {
238
+ yield* Fiber.interrupt(reader).pipe(Effect.catch(() => Effect.void))
239
+ yield* Queue.shutdown(queue).pipe(Effect.catch(() => Effect.void))
240
+ yield* Effect.sync(() => activeSessions.delete(key))
241
+ })
242
+ )
243
+ )
244
+ }).pipe(Effect.mapError((error) => new OpencodeError({ message: error.message })))
245
+ )
246
+
247
+ const abort = (scope: DiscordScope): Stream.Stream<never, OpencodeError> => {
248
+ const sessionID = activeSessions.get(scopeKey(scope))
249
+ if (sessionID === undefined) return Stream.empty
250
+ activeSessions.delete(scopeKey(scope))
251
+ return Stream.fromEffect(
252
+ request(client.session.abort({ sessionID, directory: options.projectDir })).pipe(
253
+ Effect.mapError((error) => new OpencodeError({ message: error.message })),
254
+ Effect.asVoid
255
+ )
256
+ ).pipe(Stream.drain)
257
+ }
258
+
259
+ const checkHealth = Effect.suspend(() =>
260
+ request(client.global.health()).pipe(
261
+ Effect.mapError((error) => new OpencodeError({ message: error.message })),
262
+ Effect.asVoid
263
+ )
264
+ )
265
+
266
+ return { runPrompt, abort, checkHealth }
267
+ }
268
+
269
+ export const makeLiveSdkOpencode = (options: SdkOptions): OpencodeService =>
270
+ makeSdkOpencode(createOpencodeClient({ baseUrl: options.baseUrl, directory: options.projectDir }), options)
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect, Stream } from "effect"
3
+ import type { DiscordScope } from "../Schema.ts"
4
+ import { makeSdkOpencode } from "./SdkOpencode.ts"
5
+
6
+ const scope: DiscordScope = { guildId: "g1", channelId: "c1" }
7
+
8
+ describe("makeSdkOpencode attachments", () => {
9
+ test("encodes image prompt parts as base64 data URLs before sending", async () => {
10
+ const calls: Array<readonly [string, unknown]> = []
11
+ const fetchRequests: Array<string> = []
12
+ const originalFetch = globalThis.fetch
13
+ const fakeFetch: typeof fetch = Object.assign(
14
+ (input: URL | RequestInfo) => {
15
+ fetchRequests.push(String(input))
16
+ return Promise.resolve(new Response(new Uint8Array([0, 1, 2, 255]), { status: 200 }))
17
+ },
18
+ { preconnect: originalFetch.preconnect }
19
+ )
20
+ globalThis.fetch = fakeFetch
21
+
22
+ try {
23
+ const client = {
24
+ session: {
25
+ create: () => Promise.resolve({ data: { id: "session-1" }, error: undefined }),
26
+ promptAsync: (parameters: unknown) => {
27
+ calls.push(["session.promptAsync", parameters])
28
+ return Promise.resolve({ data: {}, error: undefined })
29
+ },
30
+ abort: () => Promise.resolve({ data: {}, error: undefined })
31
+ },
32
+ event: {
33
+ subscribe: () =>
34
+ Promise.resolve({
35
+ stream: (async function* () {
36
+ yield { type: "session.idle", properties: { sessionID: "session-1" } }
37
+ })()
38
+ })
39
+ },
40
+ global: {
41
+ health: () => Promise.resolve({ data: { ok: true }, error: undefined })
42
+ }
43
+ }
44
+
45
+ const opencode = makeSdkOpencode(client, { baseUrl: "http://127.0.0.1:4096", projectDir: "/repo" })
46
+ await Effect.runPromise(
47
+ opencode
48
+ .runPrompt({
49
+ prompt: "see image",
50
+ parts: [
51
+ { type: "file", mime: "image/png", filename: "screen.png", url: "https://cdn/screen.png" },
52
+ { type: "file", mime: "application/pdf", filename: "doc.pdf", url: "https://cdn/doc.pdf" }
53
+ ],
54
+ projectDir: "/repo",
55
+ scope
56
+ })
57
+ .pipe(Stream.runDrain)
58
+ )
59
+ } finally {
60
+ globalThis.fetch = originalFetch
61
+ }
62
+
63
+ expect(fetchRequests).toEqual(["https://cdn/screen.png"])
64
+ expect(calls).toEqual([
65
+ [
66
+ "session.promptAsync",
67
+ {
68
+ sessionID: "session-1",
69
+ directory: "/repo",
70
+ parts: [
71
+ { type: "text", text: "see image" },
72
+ { type: "file", mime: "image/png", filename: "screen.png", url: "data:image/png;base64,AAEC/w==" },
73
+ { type: "file", mime: "application/pdf", filename: "doc.pdf", url: "https://cdn/doc.pdf" }
74
+ ]
75
+ }
76
+ ]
77
+ ])
78
+ })
79
+ })