@tanstack/ai-claude-code 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.
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Structural subset of the `@anthropic-ai/claude-agent-sdk` message types that
3
+ * the stream translator consumes.
4
+ *
5
+ * These are intentionally defined structurally (rather than imported from the
6
+ * agent SDK) so the translator stays a pure, fixture-testable state machine
7
+ * and the package's public types don't depend on the agent SDK's bundled
8
+ * `@anthropic-ai/sdk` type imports.
9
+ */
10
+
11
+ export interface SdkInitMessage {
12
+ type: 'system'
13
+ subtype: 'init'
14
+ session_id: string
15
+ model: string
16
+ tools: Array<string>
17
+ cwd?: string
18
+ }
19
+
20
+ export type SdkAssistantContentBlock =
21
+ | { type: 'text'; text: string }
22
+ | { type: 'thinking'; thinking: string }
23
+ | { type: 'tool_use'; id: string; name: string; input: unknown }
24
+ | { type: string; [key: string]: unknown }
25
+
26
+ export interface SdkAssistantMessage {
27
+ type: 'assistant'
28
+ message: {
29
+ id?: string
30
+ content: Array<SdkAssistantContentBlock>
31
+ }
32
+ parent_tool_use_id: string | null
33
+ }
34
+
35
+ export type SdkToolResultContent =
36
+ | string
37
+ | Array<{ type: string; text?: string; [key: string]: unknown }>
38
+
39
+ export type SdkUserContentBlock =
40
+ | {
41
+ type: 'tool_result'
42
+ tool_use_id: string
43
+ content?: SdkToolResultContent
44
+ is_error?: boolean
45
+ }
46
+ | { type: string; [key: string]: unknown }
47
+
48
+ export interface SdkUserMessage {
49
+ type: 'user'
50
+ message: {
51
+ role: 'user'
52
+ content: string | Array<SdkUserContentBlock>
53
+ }
54
+ parent_tool_use_id: string | null
55
+ }
56
+
57
+ /** Raw Anthropic streaming events forwarded when `includePartialMessages` is set. */
58
+ export type SdkRawStreamEvent =
59
+ | { type: 'message_start'; message: { id?: string } }
60
+ | {
61
+ type: 'content_block_start'
62
+ index: number
63
+ content_block: { type: string }
64
+ }
65
+ | {
66
+ type: 'content_block_delta'
67
+ index: number
68
+ delta: { type: string; text?: string; thinking?: string }
69
+ }
70
+ | { type: 'content_block_stop'; index: number }
71
+ | { type: 'message_delta' }
72
+ | { type: 'message_stop' }
73
+
74
+ export interface SdkPartialAssistantMessage {
75
+ type: 'stream_event'
76
+ event: SdkRawStreamEvent
77
+ parent_tool_use_id: string | null
78
+ }
79
+
80
+ export interface SdkUsage {
81
+ input_tokens?: number
82
+ output_tokens?: number
83
+ cache_read_input_tokens?: number
84
+ cache_creation_input_tokens?: number
85
+ }
86
+
87
+ export interface SdkResultMessage {
88
+ type: 'result'
89
+ subtype:
90
+ | 'success'
91
+ | 'error_max_turns'
92
+ | 'error_during_execution'
93
+ | 'error_max_budget_usd'
94
+ | 'error_max_structured_output_retries'
95
+ result?: string
96
+ errors?: Array<string>
97
+ usage?: SdkUsage
98
+ total_cost_usd?: number
99
+ structured_output?: unknown
100
+ }
101
+
102
+ /**
103
+ * Harness-internal system messages the translator deliberately ignores.
104
+ * (The real SDK union has many more members; unknown runtime types simply
105
+ * fall through every branch.)
106
+ */
107
+ export interface SdkNoiseSystemMessage {
108
+ type: 'system'
109
+ subtype:
110
+ | 'status'
111
+ | 'permission_denied'
112
+ | 'plugin_install'
113
+ | 'session_state_changed'
114
+ | 'task_notification'
115
+ | 'task_progress'
116
+ }
117
+
118
+ /** Other harness-internal top-level message types the translator ignores. */
119
+ export interface SdkNoiseMessage {
120
+ type:
121
+ | 'tool_progress'
122
+ | 'auth_status'
123
+ | 'rate_limit_event'
124
+ | 'prompt_suggestion'
125
+ | 'compact_boundary'
126
+ }
127
+
128
+ export type AgentSdkMessage =
129
+ | SdkInitMessage
130
+ | SdkAssistantMessage
131
+ | SdkUserMessage
132
+ | SdkPartialAssistantMessage
133
+ | SdkResultMessage
134
+ | SdkNoiseSystemMessage
135
+ | SdkNoiseMessage
@@ -0,0 +1,483 @@
1
+ import { EventType, buildBaseUsage } from '@tanstack/ai'
2
+ import type { StreamChunk, TokenUsage } from '@tanstack/ai'
3
+ import type {
4
+ AgentSdkMessage,
5
+ SdkAssistantMessage,
6
+ SdkPartialAssistantMessage,
7
+ SdkResultMessage,
8
+ SdkToolResultContent,
9
+ SdkUsage,
10
+ SdkUserMessage,
11
+ } from './sdk-types'
12
+
13
+ /** Name of the CUSTOM event carrying the Claude Code session id. */
14
+ export const SESSION_ID_EVENT = 'claude-code.session-id'
15
+
16
+ /** Server name used for bridged TanStack tools (model sees `mcp__tanstack__<name>`). */
17
+ export const BRIDGED_MCP_SERVER_NAME = 'tanstack'
18
+
19
+ const BRIDGED_MCP_PREFIX = `mcp__${BRIDGED_MCP_SERVER_NAME}__`
20
+
21
+ /** Claude Code-specific usage details attached to RUN_FINISHED usage. */
22
+ export type ClaudeCodeProviderUsageDetails = {
23
+ /** Total cost of the harness run in USD, as reported by Claude Code. */
24
+ totalCostUsd?: number
25
+ }
26
+
27
+ export interface TranslateContext {
28
+ model: string
29
+ runId: string
30
+ threadId: string
31
+ parentRunId?: string
32
+ genId: () => string
33
+ /** Called as soon as the harness reports its session id. */
34
+ onSessionId?: (sessionId: string) => void
35
+ /** Called for each raw SDK message, for logging. */
36
+ onSdkMessage?: (message: AgentSdkMessage) => void
37
+ }
38
+
39
+ /**
40
+ * Strip the bridged MCP server prefix so tool-call events match the TanStack
41
+ * tool names the application registered. Built-in harness tools (Bash, Read,
42
+ * Edit, ...) and foreign MCP tools pass through verbatim.
43
+ */
44
+ export function stripMcpPrefix(name: string): string {
45
+ return name.startsWith(BRIDGED_MCP_PREFIX)
46
+ ? name.slice(BRIDGED_MCP_PREFIX.length)
47
+ : name
48
+ }
49
+
50
+ function stringifyToolResultContent(
51
+ content: SdkToolResultContent | undefined,
52
+ ): string {
53
+ if (content === undefined) return ''
54
+ if (typeof content === 'string') return content
55
+ return content
56
+ .map((block) => (typeof block.text === 'string' ? block.text : ''))
57
+ .join('')
58
+ }
59
+
60
+ function buildUsage(
61
+ usage: SdkUsage | undefined,
62
+ totalCostUsd: number | undefined,
63
+ ): TokenUsage<ClaudeCodeProviderUsageDetails> | undefined {
64
+ if (!usage) return undefined
65
+ const promptTokens = usage.input_tokens ?? 0
66
+ const completionTokens = usage.output_tokens ?? 0
67
+ const result = buildBaseUsage<ClaudeCodeProviderUsageDetails>({
68
+ promptTokens,
69
+ completionTokens,
70
+ totalTokens: promptTokens + completionTokens,
71
+ })
72
+ const cacheWrite = usage.cache_creation_input_tokens
73
+ const cacheRead = usage.cache_read_input_tokens
74
+ const promptTokensDetails = {
75
+ ...(cacheWrite ? { cacheWriteTokens: cacheWrite } : {}),
76
+ ...(cacheRead ? { cachedTokens: cacheRead } : {}),
77
+ }
78
+ if (Object.keys(promptTokensDetails).length > 0) {
79
+ result.promptTokensDetails = promptTokensDetails
80
+ }
81
+ if (totalCostUsd !== undefined) {
82
+ result.providerUsageDetails = { totalCostUsd }
83
+ }
84
+ return result
85
+ }
86
+
87
+ /**
88
+ * Translate a Claude Code Agent SDK message stream into AG-UI StreamChunk
89
+ * events.
90
+ *
91
+ * The harness runs its own agent loop and executes its own tools, so the
92
+ * translation always ends with `finishReason: 'stop'` (or `'length'` /
93
+ * RUN_ERROR) — never `'tool_calls'`. Harness tool activity is emitted as
94
+ * already-resolved TOOL_CALL_START/ARGS/END + TOOL_CALL_RESULT sequences so
95
+ * UIs can render it, while the TanStack engine never tries to execute them.
96
+ *
97
+ * Invariant: every TOOL_CALL_START is eventually paired with a
98
+ * TOOL_CALL_RESULT (synthesized as `{"status":"interrupted"}` when the run
99
+ * ends or aborts before the harness reported one) so the engine's
100
+ * pending-tool-call scan on the next request never force-executes them.
101
+ */
102
+ export async function* translateSdkStream(
103
+ sdkMessages: AsyncIterable<AgentSdkMessage>,
104
+ ctx: TranslateContext,
105
+ ): AsyncIterable<StreamChunk> {
106
+ const { model, runId, threadId, genId } = ctx
107
+ const now = () => Date.now()
108
+
109
+ let runStarted = false
110
+ /** Tool calls started but with no result yet. */
111
+ const unresolvedToolCalls = new Set<string>()
112
+ /** Anthropic message ids whose text/thinking already streamed via partials. */
113
+ const streamedMessageIds = new Set<string>()
114
+
115
+ // Partial-stream state
116
+ let partialMessageId: string | null = null
117
+ let partialBlockType: string | null = null
118
+ let partialTextMessageId: string | null = null
119
+ let partialTextContent = ''
120
+ let partialTextStarted = false
121
+ let partialReasoningId: string | null = null
122
+
123
+ function* startRun(): Generator<StreamChunk> {
124
+ if (runStarted) return
125
+ runStarted = true
126
+ yield {
127
+ type: EventType.RUN_STARTED,
128
+ runId,
129
+ threadId,
130
+ model,
131
+ timestamp: now(),
132
+ ...(ctx.parentRunId !== undefined && { parentRunId: ctx.parentRunId }),
133
+ }
134
+ }
135
+
136
+ function* synthesizeUnresolvedResults(): Generator<StreamChunk> {
137
+ for (const toolCallId of unresolvedToolCalls) {
138
+ yield {
139
+ type: EventType.TOOL_CALL_RESULT,
140
+ toolCallId,
141
+ messageId: genId(),
142
+ model,
143
+ timestamp: now(),
144
+ content: JSON.stringify({ status: 'interrupted' }),
145
+ }
146
+ }
147
+ unresolvedToolCalls.clear()
148
+ }
149
+
150
+ function* closePartialText(): Generator<StreamChunk> {
151
+ if (partialTextStarted && partialTextMessageId) {
152
+ yield {
153
+ type: EventType.TEXT_MESSAGE_END,
154
+ messageId: partialTextMessageId,
155
+ model,
156
+ timestamp: now(),
157
+ }
158
+ }
159
+ partialTextStarted = false
160
+ partialTextMessageId = null
161
+ partialTextContent = ''
162
+ }
163
+
164
+ function* closePartialReasoning(): Generator<StreamChunk> {
165
+ if (partialReasoningId) {
166
+ yield {
167
+ type: EventType.REASONING_MESSAGE_END,
168
+ messageId: partialReasoningId,
169
+ model,
170
+ timestamp: now(),
171
+ }
172
+ yield {
173
+ type: EventType.REASONING_END,
174
+ messageId: partialReasoningId,
175
+ model,
176
+ timestamp: now(),
177
+ }
178
+ }
179
+ partialReasoningId = null
180
+ }
181
+
182
+ function* emitToolUse(block: {
183
+ id: string
184
+ name: string
185
+ input: unknown
186
+ }): Generator<StreamChunk> {
187
+ const toolCallName = stripMcpPrefix(block.name)
188
+ const args = JSON.stringify(block.input ?? {})
189
+ yield {
190
+ type: EventType.TOOL_CALL_START,
191
+ toolCallId: block.id,
192
+ toolCallName,
193
+ toolName: toolCallName,
194
+ model,
195
+ timestamp: now(),
196
+ }
197
+ yield {
198
+ type: EventType.TOOL_CALL_ARGS,
199
+ toolCallId: block.id,
200
+ model,
201
+ timestamp: now(),
202
+ delta: args,
203
+ args,
204
+ }
205
+ yield {
206
+ type: EventType.TOOL_CALL_END,
207
+ toolCallId: block.id,
208
+ toolCallName,
209
+ toolName: toolCallName,
210
+ model,
211
+ timestamp: now(),
212
+ input: block.input ?? {},
213
+ }
214
+ unresolvedToolCalls.add(block.id)
215
+ }
216
+
217
+ function* handleAssistant(
218
+ message: SdkAssistantMessage,
219
+ ): Generator<StreamChunk> {
220
+ const alreadyStreamed =
221
+ message.message.id !== undefined &&
222
+ streamedMessageIds.has(message.message.id)
223
+
224
+ for (const block of message.message.content) {
225
+ if (block.type === 'text') {
226
+ if (alreadyStreamed) continue
227
+ const messageId = message.message.id ?? genId()
228
+ const text = (block as { text: string }).text
229
+ yield {
230
+ type: EventType.TEXT_MESSAGE_START,
231
+ messageId,
232
+ model,
233
+ timestamp: now(),
234
+ role: 'assistant',
235
+ }
236
+ yield {
237
+ type: EventType.TEXT_MESSAGE_CONTENT,
238
+ messageId,
239
+ model,
240
+ timestamp: now(),
241
+ delta: text,
242
+ content: text,
243
+ }
244
+ yield {
245
+ type: EventType.TEXT_MESSAGE_END,
246
+ messageId,
247
+ model,
248
+ timestamp: now(),
249
+ }
250
+ } else if (block.type === 'thinking') {
251
+ if (alreadyStreamed) continue
252
+ const reasoningId = genId()
253
+ const thinking = (block as { thinking: string }).thinking
254
+ yield {
255
+ type: EventType.REASONING_START,
256
+ messageId: reasoningId,
257
+ model,
258
+ timestamp: now(),
259
+ }
260
+ yield {
261
+ type: EventType.REASONING_MESSAGE_START,
262
+ messageId: reasoningId,
263
+ role: 'reasoning' as const,
264
+ model,
265
+ timestamp: now(),
266
+ }
267
+ yield {
268
+ type: EventType.REASONING_MESSAGE_CONTENT,
269
+ messageId: reasoningId,
270
+ delta: thinking,
271
+ model,
272
+ timestamp: now(),
273
+ }
274
+ yield {
275
+ type: EventType.REASONING_MESSAGE_END,
276
+ messageId: reasoningId,
277
+ model,
278
+ timestamp: now(),
279
+ }
280
+ yield {
281
+ type: EventType.REASONING_END,
282
+ messageId: reasoningId,
283
+ model,
284
+ timestamp: now(),
285
+ }
286
+ } else if (block.type === 'tool_use') {
287
+ yield* emitToolUse(
288
+ block as { id: string; name: string; input: unknown },
289
+ )
290
+ }
291
+ }
292
+ }
293
+
294
+ function* handleUser(message: SdkUserMessage): Generator<StreamChunk> {
295
+ const content = message.message.content
296
+ if (typeof content === 'string') return
297
+ for (const block of content) {
298
+ if (block.type !== 'tool_result') continue
299
+ const toolResult = block as {
300
+ tool_use_id: string
301
+ content?: SdkToolResultContent
302
+ is_error?: boolean
303
+ }
304
+ unresolvedToolCalls.delete(toolResult.tool_use_id)
305
+ yield {
306
+ type: EventType.TOOL_CALL_RESULT,
307
+ toolCallId: toolResult.tool_use_id,
308
+ messageId: genId(),
309
+ model,
310
+ timestamp: now(),
311
+ content: stringifyToolResultContent(toolResult.content),
312
+ ...(toolResult.is_error === true && { state: 'output-error' as const }),
313
+ }
314
+ }
315
+ }
316
+
317
+ function* handleResult(message: SdkResultMessage): Generator<StreamChunk> {
318
+ yield* closePartialText()
319
+ yield* closePartialReasoning()
320
+ yield* synthesizeUnresolvedResults()
321
+
322
+ const usage = buildUsage(message.usage, message.total_cost_usd)
323
+ if (message.subtype === 'success') {
324
+ yield {
325
+ type: EventType.RUN_FINISHED,
326
+ runId,
327
+ threadId,
328
+ model,
329
+ timestamp: now(),
330
+ finishReason: 'stop',
331
+ ...(usage !== undefined && { usage }),
332
+ }
333
+ } else if (message.subtype === 'error_max_turns') {
334
+ yield {
335
+ type: EventType.RUN_FINISHED,
336
+ runId,
337
+ threadId,
338
+ model,
339
+ timestamp: now(),
340
+ finishReason: 'length',
341
+ ...(usage !== undefined && { usage }),
342
+ }
343
+ } else {
344
+ const errorMessage =
345
+ message.errors && message.errors.length > 0
346
+ ? message.errors.join('; ')
347
+ : `Claude Code run failed: ${message.subtype}`
348
+ yield {
349
+ type: EventType.RUN_ERROR,
350
+ model,
351
+ timestamp: now(),
352
+ message: errorMessage,
353
+ code: message.subtype,
354
+ error: { message: errorMessage, code: message.subtype },
355
+ }
356
+ }
357
+ }
358
+
359
+ function* handleStreamEvent(
360
+ message: SdkPartialAssistantMessage,
361
+ ): Generator<StreamChunk> {
362
+ const event = message.event
363
+ if (event.type === 'message_start') {
364
+ partialMessageId = event.message.id ?? genId()
365
+ streamedMessageIds.add(partialMessageId)
366
+ } else if (event.type === 'content_block_start') {
367
+ partialBlockType = event.content_block.type
368
+ if (partialBlockType === 'text') {
369
+ partialTextMessageId = partialMessageId ?? genId()
370
+ partialTextContent = ''
371
+ if (!partialTextStarted) {
372
+ partialTextStarted = true
373
+ yield {
374
+ type: EventType.TEXT_MESSAGE_START,
375
+ messageId: partialTextMessageId,
376
+ model,
377
+ timestamp: now(),
378
+ role: 'assistant',
379
+ }
380
+ }
381
+ } else if (partialBlockType === 'thinking') {
382
+ partialReasoningId = genId()
383
+ yield {
384
+ type: EventType.REASONING_START,
385
+ messageId: partialReasoningId,
386
+ model,
387
+ timestamp: now(),
388
+ }
389
+ yield {
390
+ type: EventType.REASONING_MESSAGE_START,
391
+ messageId: partialReasoningId,
392
+ role: 'reasoning' as const,
393
+ model,
394
+ timestamp: now(),
395
+ }
396
+ }
397
+ } else if (event.type === 'content_block_delta') {
398
+ if (
399
+ event.delta.type === 'text_delta' &&
400
+ partialTextStarted &&
401
+ partialTextMessageId &&
402
+ typeof event.delta.text === 'string'
403
+ ) {
404
+ partialTextContent += event.delta.text
405
+ yield {
406
+ type: EventType.TEXT_MESSAGE_CONTENT,
407
+ messageId: partialTextMessageId,
408
+ model,
409
+ timestamp: now(),
410
+ delta: event.delta.text,
411
+ content: partialTextContent,
412
+ }
413
+ } else if (
414
+ event.delta.type === 'thinking_delta' &&
415
+ partialReasoningId &&
416
+ typeof event.delta.thinking === 'string'
417
+ ) {
418
+ yield {
419
+ type: EventType.REASONING_MESSAGE_CONTENT,
420
+ messageId: partialReasoningId,
421
+ delta: event.delta.thinking,
422
+ model,
423
+ timestamp: now(),
424
+ }
425
+ }
426
+ } else if (event.type === 'content_block_stop') {
427
+ if (partialBlockType === 'text') {
428
+ yield* closePartialText()
429
+ } else if (partialBlockType === 'thinking') {
430
+ yield* closePartialReasoning()
431
+ }
432
+ partialBlockType = null
433
+ }
434
+ }
435
+
436
+ try {
437
+ for await (const sdkMessage of sdkMessages) {
438
+ ctx.onSdkMessage?.(sdkMessage)
439
+
440
+ if (sdkMessage.type === 'system' && sdkMessage.subtype === 'init') {
441
+ yield* startRun()
442
+ ctx.onSessionId?.(sdkMessage.session_id)
443
+ yield {
444
+ type: EventType.CUSTOM,
445
+ model,
446
+ timestamp: now(),
447
+ name: SESSION_ID_EVENT,
448
+ value: {
449
+ sessionId: sdkMessage.session_id,
450
+ model: sdkMessage.model,
451
+ tools: sdkMessage.tools,
452
+ },
453
+ }
454
+ continue
455
+ }
456
+
457
+ // Anything before init still needs RUN_STARTED first.
458
+ yield* startRun()
459
+
460
+ if (sdkMessage.type === 'stream_event') {
461
+ if (sdkMessage.parent_tool_use_id !== null) continue
462
+ yield* handleStreamEvent(sdkMessage)
463
+ } else if (sdkMessage.type === 'assistant') {
464
+ if (sdkMessage.parent_tool_use_id !== null) continue
465
+ yield* handleAssistant(sdkMessage)
466
+ } else if (sdkMessage.type === 'user') {
467
+ if (sdkMessage.parent_tool_use_id !== null) continue
468
+ yield* handleUser(sdkMessage)
469
+ } else if (sdkMessage.type === 'result') {
470
+ yield* handleResult(sdkMessage)
471
+ }
472
+ // All other SDK message types (status, hooks, notifications, ...) are
473
+ // harness-internal and intentionally ignored.
474
+ }
475
+ } catch (error) {
476
+ // The run is dying (abort or SDK failure). Pair any started tool calls
477
+ // with a synthetic result first so the next request's pending-tool-call
478
+ // scan doesn't try to execute them, then let the adapter surface the
479
+ // error as RUN_ERROR.
480
+ yield* synthesizeUnresolvedResults()
481
+ throw error
482
+ }
483
+ }