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,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
|
+
})
|