@ai-sdk/harness-claude-code 1.0.41 → 1.0.42

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,448 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ type Emit = (message: Record<string, unknown>) => void;
4
+
5
+ export type ClaudeMessage = {
6
+ type?: string;
7
+ subtype?: string;
8
+ model?: string;
9
+ error?: string;
10
+ error_status?: number | null;
11
+ attempt?: number;
12
+ max_retries?: number;
13
+ retry_delay_ms?: number;
14
+ patch?: { status?: string; error?: string };
15
+ compact_metadata?: {
16
+ trigger: 'manual' | 'auto';
17
+ pre_tokens?: number;
18
+ post_tokens?: number;
19
+ };
20
+ event?: {
21
+ type?: string;
22
+ index?: number;
23
+ content_block?: { type?: string };
24
+ delta?: { type?: string; text?: string; thinking?: string };
25
+ };
26
+ message?: {
27
+ content?: ReadonlyArray<MessageBlock>;
28
+ usage?: Record<string, unknown>;
29
+ };
30
+ result?: string;
31
+ errors?: ReadonlyArray<string>;
32
+ usage?: Record<string, unknown>;
33
+ total_cost_usd?: number;
34
+ };
35
+
36
+ type MessageBlock = {
37
+ type: string;
38
+ text?: string;
39
+ thinking?: string;
40
+ id?: string;
41
+ name?: string;
42
+ input?: unknown;
43
+ tool_use_id?: string;
44
+ content?: unknown;
45
+ is_error?: boolean;
46
+ };
47
+
48
+ export type ClaudeStreamEventState = {
49
+ /*
50
+ * Map of native tool-use id → tool name. Claude assistant messages emit
51
+ * `tool_use` blocks with both `id` and `name`; the matching `tool_result`
52
+ * block on a later user message carries only `tool_use_id`, so without this
53
+ * map the tool-result event would have to emit `toolName: 'unknown'`.
54
+ */
55
+ nativeToolCallNames: Map<string, string>;
56
+ approvalRequestedToolUseIds: Set<string>;
57
+ partialBlocks: Map<number, { id: string; kind: 'text' | 'thinking' }>;
58
+ stepUsage: Record<string, unknown> | undefined;
59
+ pendingStepToolUseIds: Set<string>;
60
+ pendingStepUsage: Record<string, unknown> | undefined;
61
+ stepOpen: boolean;
62
+ /*
63
+ * Tool-use ids that originated from the MCP server hosting user-supplied
64
+ * tools. The MCP handler emits its own `tool-call`/`tool-result` pair with
65
+ * the user-facing tool name and a synthetic id, so the duplicate
66
+ * `tool_result` block Claude reports for the underlying native id must be
67
+ * suppressed.
68
+ */
69
+ mcpToolUseIds: Set<string>;
70
+ observedTerminalError: string | undefined;
71
+ };
72
+
73
+ export function createClaudeStreamEventState(): ClaudeStreamEventState {
74
+ return {
75
+ nativeToolCallNames: new Map(),
76
+ approvalRequestedToolUseIds: new Set(),
77
+ partialBlocks: new Map(),
78
+ stepUsage: undefined,
79
+ pendingStepToolUseIds: new Set(),
80
+ pendingStepUsage: undefined,
81
+ stepOpen: false,
82
+ mcpToolUseIds: new Set(),
83
+ observedTerminalError: undefined,
84
+ };
85
+ }
86
+
87
+ const UNRECOVERABLE_API_RETRY_STATUSES = new Set([401, 403, 404]);
88
+
89
+ export function createEmitStreamEvent({
90
+ state,
91
+ emit,
92
+ emitWarning,
93
+ emitTerminalError,
94
+ onCompactionBoundary,
95
+ toCommonName,
96
+ }: {
97
+ state: ClaudeStreamEventState;
98
+ emit: Emit;
99
+ emitWarning: (warning: { message: string }) => void;
100
+ emitTerminalError: (message: string | undefined) => void;
101
+ onCompactionBoundary: (boundary: {
102
+ trigger: 'manual' | 'auto';
103
+ tokensBefore?: number;
104
+ tokensAfter?: number;
105
+ }) => void;
106
+ toCommonName: (nativeName: string) => string;
107
+ }): (event: ClaudeMessage) => void {
108
+ let streamStarted = false;
109
+
110
+ return msg => {
111
+ const type = msg.type;
112
+
113
+ // Emit `stream-start` once, on the first message, carrying the model the
114
+ // CLI resolved to (the `system`/`init` message reports it — this is the
115
+ // default model when none was configured).
116
+ if (!streamStarted) {
117
+ const initModel =
118
+ type === 'system' &&
119
+ msg.subtype === 'init' &&
120
+ typeof (msg as { model?: unknown }).model === 'string'
121
+ ? (msg as { model: string }).model
122
+ : undefined;
123
+ emit({
124
+ type: 'stream-start',
125
+ ...(initModel ? { modelId: initModel } : {}),
126
+ });
127
+ streamStarted = true;
128
+ }
129
+
130
+ if (type === 'system' && msg.subtype === 'api_retry') {
131
+ if (
132
+ typeof msg.error_status === 'number' &&
133
+ UNRECOVERABLE_API_RETRY_STATUSES.has(msg.error_status)
134
+ ) {
135
+ emitTerminalError(
136
+ `HTTP ${msg.error_status}: ${msg.error ?? 'provider request failed'}`,
137
+ );
138
+ return;
139
+ }
140
+
141
+ emitWarning({ message: formatApiRetryWarning(msg) });
142
+ return;
143
+ }
144
+
145
+ if (typeof msg.error === 'string' && msg.error.trim()) {
146
+ state.observedTerminalError = msg.error.trim();
147
+ }
148
+
149
+ if (
150
+ type === 'auth_status' &&
151
+ typeof msg.error === 'string' &&
152
+ msg.error.trim()
153
+ ) {
154
+ emitTerminalError(msg.error);
155
+ return;
156
+ }
157
+
158
+ if (
159
+ type === 'system' &&
160
+ msg.subtype === 'task_updated' &&
161
+ msg.patch?.status === 'failed' &&
162
+ typeof msg.patch.error === 'string'
163
+ ) {
164
+ emitTerminalError(msg.patch.error);
165
+ return;
166
+ }
167
+
168
+ if (type === 'system' && msg.subtype === 'compact_boundary') {
169
+ const meta = msg.compact_metadata;
170
+ if (meta) {
171
+ onCompactionBoundary({
172
+ trigger: meta.trigger,
173
+ ...(typeof meta.pre_tokens === 'number'
174
+ ? { tokensBefore: meta.pre_tokens }
175
+ : {}),
176
+ ...(typeof meta.post_tokens === 'number'
177
+ ? { tokensAfter: meta.post_tokens }
178
+ : {}),
179
+ });
180
+ }
181
+ return;
182
+ }
183
+
184
+ if (type === 'stream_event') {
185
+ handleStreamEvent(msg.event, state.partialBlocks, emit);
186
+ return;
187
+ }
188
+
189
+ if (type === 'assistant' && msg.message?.content) {
190
+ const usage = mapUsage(msg.message.usage);
191
+ const toolUseIds: string[] = [];
192
+ let opensStep = false;
193
+ for (const block of msg.message.content) {
194
+ if (
195
+ block.type === 'tool_use' &&
196
+ typeof block.id === 'string' &&
197
+ typeof block.name === 'string'
198
+ ) {
199
+ toolUseIds.push(block.id);
200
+ const mcpPrefix = 'mcp__harness-tools__';
201
+ if (block.name.startsWith(mcpPrefix)) {
202
+ state.pendingStepToolUseIds.add(block.id);
203
+ state.mcpToolUseIds.add(block.id);
204
+ opensStep = true;
205
+ continue;
206
+ }
207
+ state.nativeToolCallNames.set(block.id, block.name);
208
+ if (state.approvalRequestedToolUseIds.has(block.id)) {
209
+ continue;
210
+ }
211
+ state.pendingStepToolUseIds.add(block.id);
212
+ opensStep = true;
213
+ emit({
214
+ type: 'tool-call',
215
+ toolCallId: block.id,
216
+ toolName: toCommonName(block.name),
217
+ nativeName: block.name,
218
+ input: JSON.stringify(block.input ?? {}),
219
+ providerExecuted: true,
220
+ });
221
+ }
222
+ }
223
+ if (opensStep || toolUseIds.length === 0) {
224
+ state.stepOpen = true;
225
+ if (usage) state.pendingStepUsage = usage;
226
+ }
227
+ return;
228
+ }
229
+
230
+ if (type === 'user' && msg.message?.content) {
231
+ for (const block of msg.message.content) {
232
+ if (
233
+ block.type === 'tool_result' &&
234
+ typeof block.tool_use_id === 'string'
235
+ ) {
236
+ if (state.mcpToolUseIds.has(block.tool_use_id)) {
237
+ state.mcpToolUseIds.delete(block.tool_use_id);
238
+ state.pendingStepToolUseIds.delete(block.tool_use_id);
239
+ continue;
240
+ }
241
+ state.approvalRequestedToolUseIds.delete(block.tool_use_id);
242
+ const nativeName =
243
+ state.nativeToolCallNames.get(block.tool_use_id) ?? 'unknown';
244
+ state.nativeToolCallNames.delete(block.tool_use_id);
245
+ const toolName = toCommonName(nativeName);
246
+ const isError = !!block.is_error;
247
+ const content = stringifyContent(block.content);
248
+ /*
249
+ * Claude Code's Bash tool does not report the command's real
250
+ * numeric exit code — the SDK exposes only stdout/stderr text and
251
+ * an is_error flag. Consumers (and the example UI) render bash
252
+ * failures from an `exitCode` field on a structured result, the
253
+ * shape Codex's shell tool provides natively. To match it, derive
254
+ * a binary code from is_error: 1 on failure, 0 on success. This is
255
+ * a stand-in for failed/succeeded, not the process's true exit
256
+ * status.
257
+ */
258
+ const result =
259
+ toolName === 'bash'
260
+ ? { exitCode: isError ? 1 : 0, stdout: content }
261
+ : content;
262
+ emit({
263
+ type: 'tool-result',
264
+ toolCallId: block.tool_use_id,
265
+ toolName,
266
+ result,
267
+ isError,
268
+ });
269
+ state.pendingStepToolUseIds.delete(block.tool_use_id);
270
+ }
271
+ }
272
+ closeStepIfReady({ state, emit });
273
+ }
274
+ };
275
+ }
276
+
277
+ export function finishApprovalStep({
278
+ state,
279
+ emit,
280
+ approvalId,
281
+ }: {
282
+ state: ClaudeStreamEventState;
283
+ emit: Emit;
284
+ approvalId: string;
285
+ }): void {
286
+ state.stepOpen = true;
287
+ state.pendingStepToolUseIds.delete(approvalId);
288
+ closeStepIfReady({ state, emit });
289
+ }
290
+
291
+ export function emitFinishStep({
292
+ state,
293
+ emit,
294
+ usage,
295
+ }: {
296
+ state: ClaudeStreamEventState;
297
+ emit: Emit;
298
+ usage: Record<string, unknown> | undefined;
299
+ }): void {
300
+ emit({
301
+ type: 'finish-step',
302
+ finishReason: { unified: 'stop', raw: 'stop' },
303
+ usage: usage ?? defaultUsage(),
304
+ });
305
+ state.stepUsage = usage ?? state.stepUsage;
306
+ state.pendingStepUsage = undefined;
307
+ state.pendingStepToolUseIds = new Set();
308
+ state.stepOpen = false;
309
+ }
310
+
311
+ function closeStepIfReady({
312
+ state,
313
+ emit,
314
+ }: {
315
+ state: ClaudeStreamEventState;
316
+ emit: Emit;
317
+ }): void {
318
+ if (
319
+ !state.stepOpen ||
320
+ state.pendingStepToolUseIds.size > 0 ||
321
+ state.partialBlocks.size > 0
322
+ ) {
323
+ return;
324
+ }
325
+ emitFinishStep({ state, emit, usage: state.pendingStepUsage });
326
+ }
327
+
328
+ function formatApiRetryWarning(msg: ClaudeMessage): string {
329
+ const details: string[] = [];
330
+ if (typeof msg.attempt === 'number') {
331
+ const maxRetries =
332
+ typeof msg.max_retries === 'number' ? `/${msg.max_retries}` : '';
333
+ details.push(`attempt ${msg.attempt}${maxRetries}`);
334
+ }
335
+ if (typeof msg.error_status === 'number') {
336
+ details.push(`HTTP ${msg.error_status}`);
337
+ }
338
+ if (typeof msg.retry_delay_ms === 'number') {
339
+ details.push(`retrying in ${msg.retry_delay_ms}ms`);
340
+ }
341
+ if (msg.error) details.push(msg.error);
342
+ return details.length > 0
343
+ ? `Claude Code API retry: ${details.join('; ')}`
344
+ : 'Claude Code API retry';
345
+ }
346
+
347
+ function handleStreamEvent(
348
+ event: ClaudeMessage['event'] | undefined,
349
+ partialBlocks: Map<number, { id: string; kind: 'text' | 'thinking' }>,
350
+ send: Emit,
351
+ ): void {
352
+ if (!event || typeof event.index !== 'number') return;
353
+ const index = event.index;
354
+
355
+ if (event.type === 'content_block_start') {
356
+ const blockType = event.content_block?.type;
357
+ if (blockType === 'text') {
358
+ const id = randomUUID();
359
+ partialBlocks.set(index, { id, kind: 'text' });
360
+ send({ type: 'text-start', id });
361
+ } else if (blockType === 'thinking') {
362
+ const id = randomUUID();
363
+ partialBlocks.set(index, { id, kind: 'thinking' });
364
+ send({ type: 'reasoning-start', id });
365
+ }
366
+ return;
367
+ }
368
+
369
+ if (event.type === 'content_block_delta') {
370
+ const block = partialBlocks.get(index);
371
+ if (!block) return;
372
+ if (
373
+ block.kind === 'text' &&
374
+ event.delta?.type === 'text_delta' &&
375
+ typeof event.delta.text === 'string'
376
+ ) {
377
+ send({ type: 'text-delta', id: block.id, delta: event.delta.text });
378
+ } else if (
379
+ block.kind === 'thinking' &&
380
+ event.delta?.type === 'thinking_delta' &&
381
+ typeof event.delta.thinking === 'string'
382
+ ) {
383
+ send({
384
+ type: 'reasoning-delta',
385
+ id: block.id,
386
+ delta: event.delta.thinking,
387
+ });
388
+ }
389
+ return;
390
+ }
391
+
392
+ if (event.type === 'content_block_stop') {
393
+ const block = partialBlocks.get(index);
394
+ if (!block) return;
395
+ partialBlocks.delete(index);
396
+ if (block.kind === 'text') {
397
+ send({ type: 'text-end', id: block.id });
398
+ } else {
399
+ send({ type: 'reasoning-end', id: block.id });
400
+ }
401
+ }
402
+ }
403
+
404
+ function stringifyContent(content: unknown): string {
405
+ if (typeof content === 'string') return content;
406
+ if (Array.isArray(content)) {
407
+ return content
408
+ .map(entry =>
409
+ entry && typeof entry === 'object' && 'text' in entry
410
+ ? String((entry as { text?: unknown }).text ?? '')
411
+ : JSON.stringify(entry),
412
+ )
413
+ .join('');
414
+ }
415
+ return JSON.stringify(content);
416
+ }
417
+
418
+ export function mapUsage(usage: unknown): Record<string, unknown> | undefined {
419
+ if (!usage || typeof usage !== 'object') return undefined;
420
+ const u = usage as {
421
+ input_tokens?: number;
422
+ cache_creation_input_tokens?: number;
423
+ cache_read_input_tokens?: number;
424
+ output_tokens?: number;
425
+ };
426
+ return {
427
+ inputTokens: {
428
+ total:
429
+ (u.input_tokens ?? 0) +
430
+ (u.cache_creation_input_tokens ?? 0) +
431
+ (u.cache_read_input_tokens ?? 0),
432
+ noCache: u.input_tokens ?? 0,
433
+ cacheRead: u.cache_read_input_tokens ?? 0,
434
+ cacheWrite: u.cache_creation_input_tokens ?? 0,
435
+ },
436
+ outputTokens: {
437
+ total: u.output_tokens ?? 0,
438
+ text: u.output_tokens ?? 0,
439
+ },
440
+ };
441
+ }
442
+
443
+ export function defaultUsage(): Record<string, unknown> {
444
+ return {
445
+ inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
446
+ outputTokens: { total: 0, text: 0 },
447
+ };
448
+ }