@tanstack/ai-opencode 0.1.0

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 (46) hide show
  1. package/README.md +18 -0
  2. package/dist/esm/adapters/projection.d.ts +14 -0
  3. package/dist/esm/adapters/projection.js +83 -0
  4. package/dist/esm/adapters/projection.js.map +1 -0
  5. package/dist/esm/adapters/text.d.ts +47 -0
  6. package/dist/esm/adapters/text.js +231 -0
  7. package/dist/esm/adapters/text.js.map +1 -0
  8. package/dist/esm/index.d.ts +16 -0
  9. package/dist/esm/index.js +23 -0
  10. package/dist/esm/index.js.map +1 -0
  11. package/dist/esm/messages/prompt.d.ts +16 -0
  12. package/dist/esm/messages/prompt.js +37 -0
  13. package/dist/esm/messages/prompt.js.map +1 -0
  14. package/dist/esm/model-meta.d.ts +14 -0
  15. package/dist/esm/model-meta.js +13 -0
  16. package/dist/esm/model-meta.js.map +1 -0
  17. package/dist/esm/process/permissions.d.ts +50 -0
  18. package/dist/esm/process/permissions.js +51 -0
  19. package/dist/esm/process/permissions.js.map +1 -0
  20. package/dist/esm/process/sandbox-server.d.ts +23 -0
  21. package/dist/esm/process/sandbox-server.js +72 -0
  22. package/dist/esm/process/sandbox-server.js.map +1 -0
  23. package/dist/esm/process/server.d.ts +77 -0
  24. package/dist/esm/process/server.js +160 -0
  25. package/dist/esm/process/server.js.map +1 -0
  26. package/dist/esm/provider-options.d.ts +18 -0
  27. package/dist/esm/stream/queue.d.ts +18 -0
  28. package/dist/esm/stream/queue.js +56 -0
  29. package/dist/esm/stream/queue.js.map +1 -0
  30. package/dist/esm/stream/sdk-types.d.ts +134 -0
  31. package/dist/esm/stream/translate.d.ts +51 -0
  32. package/dist/esm/stream/translate.js +305 -0
  33. package/dist/esm/stream/translate.js.map +1 -0
  34. package/package.json +62 -0
  35. package/src/adapters/projection.ts +220 -0
  36. package/src/adapters/text.ts +368 -0
  37. package/src/index.ts +40 -0
  38. package/src/messages/prompt.ts +67 -0
  39. package/src/model-meta.ts +24 -0
  40. package/src/process/permissions.ts +119 -0
  41. package/src/process/sandbox-server.ts +130 -0
  42. package/src/process/server.ts +270 -0
  43. package/src/provider-options.ts +19 -0
  44. package/src/stream/queue.ts +64 -0
  45. package/src/stream/sdk-types.ts +104 -0
  46. package/src/stream/translate.ts +419 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Structural subset of the `@opencode-ai/sdk` event types that the stream
3
+ * translator consumes.
4
+ *
5
+ * These are intentionally defined structurally (rather than imported from the
6
+ * OpenCode SDK) so the translator stays a pure, fixture-testable state machine
7
+ * and the package's public types don't depend on the SDK's generated schema
8
+ * types. Unknown part or event types fall through every branch at runtime.
9
+ */
10
+
11
+ export interface OpencodeTokens {
12
+ input?: number
13
+ output?: number
14
+ reasoning?: number
15
+ cache?: { read?: number; write?: number }
16
+ }
17
+
18
+ /** Error payload attached to a failed assistant message. */
19
+ export interface OpencodeMessageError {
20
+ name: string
21
+ data?: { message?: string }
22
+ }
23
+
24
+ /**
25
+ * The final assistant message of a turn, returned by the blocking prompt
26
+ * call. Carries the finish reason, token usage, and any fatal error.
27
+ */
28
+ export interface OpencodeAssistantMessage {
29
+ id: string
30
+ role: 'assistant'
31
+ finish?: string
32
+ error?: OpencodeMessageError
33
+ tokens?: OpencodeTokens
34
+ cost?: number
35
+ }
36
+
37
+ export type OpencodeToolState =
38
+ | { status: 'pending'; input?: Record<string, unknown> }
39
+ | {
40
+ status: 'running'
41
+ input?: Record<string, unknown>
42
+ title?: string
43
+ }
44
+ | {
45
+ status: 'completed'
46
+ input?: Record<string, unknown>
47
+ output: string
48
+ title?: string
49
+ }
50
+ | { status: 'error'; input?: Record<string, unknown>; error: string }
51
+
52
+ /**
53
+ * The OpenCode message-part kinds the translator understands. The trailing
54
+ * catch-all member keeps the union open to other kinds (file, step-start,
55
+ * step-finish, snapshot, patch, agent, ...); the translator dispatches via
56
+ * the `is*Part` type guards, so those kinds simply match no guard.
57
+ */
58
+ export type OpencodePart =
59
+ | { id: string; sessionID?: string; type: 'text'; text: string }
60
+ | { id: string; sessionID?: string; type: 'reasoning'; text: string }
61
+ | {
62
+ id: string
63
+ sessionID?: string
64
+ type: 'tool'
65
+ callID: string
66
+ tool: string
67
+ state: OpencodeToolState
68
+ }
69
+ | { id: string; sessionID?: string; type: string }
70
+
71
+ /**
72
+ * The OpenCode events the translator understands. This is a closed
73
+ * discriminated union (so `event.type` narrows cleanly); the server forwards
74
+ * raw SDK events cast to this type, and any event whose `type` isn't listed
75
+ * here simply matches no branch and is ignored at runtime.
76
+ */
77
+ export type OpencodeEvent =
78
+ | {
79
+ type: 'message.part.updated'
80
+ properties: { part: OpencodePart; delta?: string }
81
+ }
82
+ | {
83
+ type: 'message.updated'
84
+ properties: { info: { sessionID?: string } }
85
+ }
86
+ | { type: 'session.idle'; properties: { sessionID: string } }
87
+ | {
88
+ type: 'session.error'
89
+ properties: { sessionID?: string; error?: OpencodeMessageError }
90
+ }
91
+ | {
92
+ type: 'todo.updated'
93
+ properties: { sessionID: string; todos: Array<unknown> }
94
+ }
95
+
96
+ /**
97
+ * Events fed to the translator: the session id once established, every
98
+ * session-scoped OpenCode event, and a terminal `done` carrying the final
99
+ * assistant message (the adapter's async queue produces these).
100
+ */
101
+ export type OpencodeStreamEvent =
102
+ | { kind: 'session'; sessionId: string }
103
+ | { kind: 'event'; event: OpencodeEvent }
104
+ | { kind: 'done'; message: OpencodeAssistantMessage }
@@ -0,0 +1,419 @@
1
+ import { EventType, buildBaseUsage } from '@tanstack/ai'
2
+ import type { StreamChunk, TokenUsage } from '@tanstack/ai'
3
+ import type {
4
+ OpencodeAssistantMessage,
5
+ OpencodeEvent,
6
+ OpencodePart,
7
+ OpencodeStreamEvent,
8
+ OpencodeTokens,
9
+ } from './sdk-types'
10
+
11
+ /** Name of the CUSTOM event carrying the OpenCode session id. */
12
+ export const SESSION_ID_EVENT = 'opencode.session-id'
13
+
14
+ /** Name of the CUSTOM event carrying the harness's todo list updates. */
15
+ export const TODO_EVENT = 'opencode.todo'
16
+
17
+ /** Server name used for bridged TanStack tools. */
18
+ export const BRIDGED_MCP_SERVER_NAME = 'tanstack'
19
+
20
+ export interface TranslateContext {
21
+ model: string
22
+ runId: string
23
+ threadId: string
24
+ parentRunId?: string
25
+ genId: () => string
26
+ /**
27
+ * Names of bridged TanStack tools, used to surface the harness's MCP tool
28
+ * calls under the names the application registered.
29
+ */
30
+ bridgedToolNames?: ReadonlySet<string>
31
+ /** Called for each raw stream event, for logging. */
32
+ onStreamEvent?: (event: OpencodeStreamEvent) => void
33
+ }
34
+
35
+ /**
36
+ * Resolve the AG-UI tool-call name for an OpenCode tool part. OpenCode names
37
+ * MCP tools `<server>_<tool>`, so bridged TanStack tools arrive as
38
+ * `tanstack_<tool>` and are surfaced under the names the application
39
+ * registered; everything else (built-in `read`, `edit`, `bash`, ... and
40
+ * foreign MCP tools) uses the harness tool name verbatim.
41
+ */
42
+ export function resolveToolName(
43
+ tool: string,
44
+ bridgedToolNames: ReadonlySet<string> | undefined,
45
+ ): string {
46
+ if (!bridgedToolNames || bridgedToolNames.size === 0) return tool
47
+ if (bridgedToolNames.has(tool)) return tool
48
+ if (tool.startsWith('tanstack_') && bridgedToolNames.has(tool.slice(9))) {
49
+ return tool.slice(9)
50
+ }
51
+ return tool
52
+ }
53
+
54
+ function buildUsage(
55
+ tokens: OpencodeTokens | undefined,
56
+ ): TokenUsage | undefined {
57
+ if (!tokens) return undefined
58
+ const promptTokens = tokens.input ?? 0
59
+ const completionTokens = tokens.output ?? 0
60
+ const result = buildBaseUsage({
61
+ promptTokens,
62
+ completionTokens,
63
+ totalTokens: promptTokens + completionTokens,
64
+ })
65
+ if (tokens.cache?.read) {
66
+ result.promptTokensDetails = { cachedTokens: tokens.cache.read }
67
+ }
68
+ if (tokens.reasoning) {
69
+ result.completionTokensDetails = { reasoningTokens: tokens.reasoning }
70
+ }
71
+ return result
72
+ }
73
+
74
+ type TextPart = Extract<OpencodePart, { type: 'text' }>
75
+ type ReasoningPart = Extract<OpencodePart, { type: 'reasoning' }>
76
+ type ToolPart = Extract<OpencodePart, { type: 'tool' }>
77
+
78
+ const isTextPart = (part: OpencodePart): part is TextPart =>
79
+ part.type === 'text'
80
+ const isReasoningPart = (part: OpencodePart): part is ReasoningPart =>
81
+ part.type === 'reasoning'
82
+ const isToolPart = (part: OpencodePart): part is ToolPart =>
83
+ part.type === 'tool'
84
+
85
+ function messageError(
86
+ message: OpencodeAssistantMessage,
87
+ ): { message: string } | undefined {
88
+ if (!message.error) return undefined
89
+ return { message: message.error.data?.message ?? message.error.name }
90
+ }
91
+
92
+ /**
93
+ * Translate an OpenCode event stream into AG-UI StreamChunk events.
94
+ *
95
+ * The harness runs its own agent loop and executes its own tools, so the
96
+ * translation always ends with `finishReason: 'stop'` (or `'length'` /
97
+ * RUN_ERROR) — never `'tool_calls'`. Harness tool activity is emitted as
98
+ * already-resolved TOOL_CALL_START/ARGS/END + TOOL_CALL_RESULT sequences so
99
+ * UIs can render it, while the TanStack engine never tries to execute them.
100
+ *
101
+ * OpenCode delivers true token-level deltas for both assistant text and
102
+ * reasoning via `message.part.updated` events (a `delta` string when
103
+ * incremental, otherwise the full part text, from which the delta is
104
+ * derived). The final assistant message — finish reason, token usage, and any
105
+ * fatal error — arrives as the terminal `done` event.
106
+ *
107
+ * Invariant: every TOOL_CALL_START is eventually paired with a
108
+ * TOOL_CALL_RESULT (synthesized as `{"status":"interrupted"}` when the run
109
+ * ends or aborts before the harness reported one) so the engine's
110
+ * pending-tool-call scan on the next request never force-executes them.
111
+ */
112
+ export async function* translateOpencodeStream(
113
+ events: AsyncIterable<OpencodeStreamEvent>,
114
+ ctx: TranslateContext,
115
+ ): AsyncIterable<StreamChunk> {
116
+ const { model, runId, threadId, genId } = ctx
117
+ const now = () => Date.now()
118
+
119
+ let runStarted = false
120
+ /** Tool calls started but with no result yet, keyed by callID. */
121
+ const unresolvedToolCalls = new Set<string>()
122
+ /** Tool call ids that already emitted TOOL_CALL_START/ARGS/END. */
123
+ const openedToolCalls = new Set<string>()
124
+ /** Tool call ids that already emitted a TOOL_CALL_RESULT. */
125
+ const resolvedToolCalls = new Set<string>()
126
+
127
+ /** Accumulated text per text-part id, for delta derivation. */
128
+ const textAccumulators = new Map<string, string>()
129
+ let openTextId: string | null = null
130
+ let openReasoningId: string | null = null
131
+
132
+ function* startRun(): Generator<StreamChunk> {
133
+ if (runStarted) return
134
+ runStarted = true
135
+ yield {
136
+ type: EventType.RUN_STARTED,
137
+ runId,
138
+ threadId,
139
+ model,
140
+ timestamp: now(),
141
+ ...(ctx.parentRunId !== undefined && { parentRunId: ctx.parentRunId }),
142
+ }
143
+ }
144
+
145
+ function* closeText(): Generator<StreamChunk> {
146
+ if (openTextId !== null) {
147
+ yield {
148
+ type: EventType.TEXT_MESSAGE_END,
149
+ messageId: openTextId,
150
+ model,
151
+ timestamp: now(),
152
+ }
153
+ openTextId = null
154
+ }
155
+ }
156
+
157
+ function* closeReasoning(): Generator<StreamChunk> {
158
+ if (openReasoningId !== null) {
159
+ yield {
160
+ type: EventType.REASONING_MESSAGE_END,
161
+ messageId: openReasoningId,
162
+ model,
163
+ timestamp: now(),
164
+ }
165
+ yield {
166
+ type: EventType.REASONING_END,
167
+ messageId: openReasoningId,
168
+ model,
169
+ timestamp: now(),
170
+ }
171
+ openReasoningId = null
172
+ }
173
+ }
174
+
175
+ function* synthesizeUnresolvedResults(): Generator<StreamChunk> {
176
+ for (const toolCallId of unresolvedToolCalls) {
177
+ yield {
178
+ type: EventType.TOOL_CALL_RESULT,
179
+ toolCallId,
180
+ messageId: genId(),
181
+ model,
182
+ timestamp: now(),
183
+ content: JSON.stringify({ status: 'interrupted' }),
184
+ }
185
+ }
186
+ unresolvedToolCalls.clear()
187
+ }
188
+
189
+ function* handleTextPart(
190
+ part: Extract<OpencodePart, { type: 'text' }>,
191
+ delta: string | undefined,
192
+ ): Generator<StreamChunk> {
193
+ yield* closeReasoning()
194
+
195
+ const prev = textAccumulators.get(part.id) ?? ''
196
+ let deltaText: string
197
+ if (typeof delta === 'string' && delta !== '') {
198
+ deltaText = delta
199
+ textAccumulators.set(part.id, prev + delta)
200
+ } else {
201
+ const full = part.text
202
+ deltaText = full.startsWith(prev) ? full.slice(prev.length) : full
203
+ textAccumulators.set(part.id, full)
204
+ }
205
+ if (deltaText === '') return
206
+
207
+ if (openTextId !== part.id) {
208
+ yield* closeText()
209
+ openTextId = part.id
210
+ yield {
211
+ type: EventType.TEXT_MESSAGE_START,
212
+ messageId: part.id,
213
+ model,
214
+ timestamp: now(),
215
+ role: 'assistant',
216
+ }
217
+ }
218
+ yield {
219
+ type: EventType.TEXT_MESSAGE_CONTENT,
220
+ messageId: part.id,
221
+ model,
222
+ timestamp: now(),
223
+ delta: deltaText,
224
+ content: textAccumulators.get(part.id) ?? deltaText,
225
+ }
226
+ }
227
+
228
+ function* handleReasoningPart(
229
+ part: Extract<OpencodePart, { type: 'reasoning' }>,
230
+ delta: string | undefined,
231
+ ): Generator<StreamChunk> {
232
+ yield* closeText()
233
+
234
+ const prev = textAccumulators.get(part.id) ?? ''
235
+ let deltaText: string
236
+ if (typeof delta === 'string' && delta !== '') {
237
+ deltaText = delta
238
+ textAccumulators.set(part.id, prev + delta)
239
+ } else {
240
+ const full = part.text
241
+ deltaText = full.startsWith(prev) ? full.slice(prev.length) : full
242
+ textAccumulators.set(part.id, full)
243
+ }
244
+ if (deltaText === '') return
245
+
246
+ if (openReasoningId !== part.id) {
247
+ yield* closeReasoning()
248
+ openReasoningId = part.id
249
+ yield {
250
+ type: EventType.REASONING_START,
251
+ messageId: part.id,
252
+ model,
253
+ timestamp: now(),
254
+ }
255
+ yield {
256
+ type: EventType.REASONING_MESSAGE_START,
257
+ messageId: part.id,
258
+ role: 'reasoning' as const,
259
+ model,
260
+ timestamp: now(),
261
+ }
262
+ }
263
+ yield {
264
+ type: EventType.REASONING_MESSAGE_CONTENT,
265
+ messageId: part.id,
266
+ delta: deltaText,
267
+ model,
268
+ timestamp: now(),
269
+ }
270
+ }
271
+
272
+ function* openToolCall(
273
+ part: Extract<OpencodePart, { type: 'tool' }>,
274
+ ): Generator<StreamChunk> {
275
+ if (openedToolCalls.has(part.callID)) return
276
+ openedToolCalls.add(part.callID)
277
+ const toolCallName = resolveToolName(part.tool, ctx.bridgedToolNames)
278
+ const input = part.state.input ?? {}
279
+ const args = JSON.stringify(input)
280
+ yield {
281
+ type: EventType.TOOL_CALL_START,
282
+ toolCallId: part.callID,
283
+ toolCallName,
284
+ toolName: toolCallName,
285
+ model,
286
+ timestamp: now(),
287
+ }
288
+ yield {
289
+ type: EventType.TOOL_CALL_ARGS,
290
+ toolCallId: part.callID,
291
+ model,
292
+ timestamp: now(),
293
+ delta: args,
294
+ args,
295
+ }
296
+ yield {
297
+ type: EventType.TOOL_CALL_END,
298
+ toolCallId: part.callID,
299
+ toolCallName,
300
+ toolName: toolCallName,
301
+ model,
302
+ timestamp: now(),
303
+ input,
304
+ }
305
+ unresolvedToolCalls.add(part.callID)
306
+ }
307
+
308
+ function* handleToolPart(
309
+ part: Extract<OpencodePart, { type: 'tool' }>,
310
+ ): Generator<StreamChunk> {
311
+ yield* closeText()
312
+ yield* closeReasoning()
313
+ yield* openToolCall(part)
314
+
315
+ const state = part.state
316
+ if (state.status !== 'completed' && state.status !== 'error') return
317
+ if (resolvedToolCalls.has(part.callID)) return
318
+ resolvedToolCalls.add(part.callID)
319
+ unresolvedToolCalls.delete(part.callID)
320
+
321
+ const isError = state.status === 'error'
322
+ yield {
323
+ type: EventType.TOOL_CALL_RESULT,
324
+ toolCallId: part.callID,
325
+ messageId: genId(),
326
+ model,
327
+ timestamp: now(),
328
+ content: isError ? state.error : state.output,
329
+ ...(isError && { state: 'output-error' as const }),
330
+ }
331
+ }
332
+
333
+ function* handleEvent(event: OpencodeEvent): Generator<StreamChunk> {
334
+ if (event.type === 'message.part.updated') {
335
+ const { part, delta } = event.properties
336
+ if (isTextPart(part)) {
337
+ yield* handleTextPart(part, delta)
338
+ } else if (isReasoningPart(part)) {
339
+ yield* handleReasoningPart(part, delta)
340
+ } else if (isToolPart(part)) {
341
+ yield* handleToolPart(part)
342
+ }
343
+ // Other part kinds (file, step-start/finish, snapshot, ...) carry no
344
+ // state the chunk stream needs.
345
+ } else if (event.type === 'todo.updated') {
346
+ yield {
347
+ type: EventType.CUSTOM,
348
+ model,
349
+ timestamp: now(),
350
+ name: TODO_EVENT,
351
+ value: { todos: event.properties.todos },
352
+ }
353
+ }
354
+ // session.idle / session.status / message.updated are redundant with the
355
+ // terminal `done` event and are ignored.
356
+ }
357
+
358
+ function* finish(message: OpencodeAssistantMessage): Generator<StreamChunk> {
359
+ yield* startRun()
360
+ yield* closeText()
361
+ yield* closeReasoning()
362
+ yield* synthesizeUnresolvedResults()
363
+
364
+ const error = messageError(message)
365
+ if (error) {
366
+ yield {
367
+ type: EventType.RUN_ERROR,
368
+ model,
369
+ timestamp: now(),
370
+ message: error.message,
371
+ error,
372
+ }
373
+ return
374
+ }
375
+
376
+ const usage = buildUsage(message.tokens)
377
+ const finishReason = message.finish === 'length' ? 'length' : 'stop'
378
+ yield {
379
+ type: EventType.RUN_FINISHED,
380
+ runId,
381
+ threadId,
382
+ model,
383
+ timestamp: now(),
384
+ finishReason,
385
+ ...(usage !== undefined && { usage }),
386
+ }
387
+ }
388
+
389
+ try {
390
+ for await (const streamEvent of events) {
391
+ ctx.onStreamEvent?.(streamEvent)
392
+
393
+ if (streamEvent.kind === 'session') {
394
+ yield* startRun()
395
+ yield {
396
+ type: EventType.CUSTOM,
397
+ model,
398
+ timestamp: now(),
399
+ name: SESSION_ID_EVENT,
400
+ value: { sessionId: streamEvent.sessionId },
401
+ }
402
+ } else if (streamEvent.kind === 'event') {
403
+ yield* startRun()
404
+ yield* handleEvent(streamEvent.event)
405
+ } else {
406
+ yield* finish(streamEvent.message)
407
+ }
408
+ }
409
+ } catch (error) {
410
+ // The run is dying (abort, server exit, or connection failure). Close any
411
+ // open message and pair started tool calls with a synthetic result first
412
+ // so the next request's pending-tool-call scan doesn't try to execute
413
+ // them, then let the adapter surface the error as RUN_ERROR.
414
+ yield* closeText()
415
+ yield* closeReasoning()
416
+ yield* synthesizeUnresolvedResults()
417
+ throw error
418
+ }
419
+ }