@ai-sdk/harness-claude-code 1.0.41 → 1.0.43
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/CHANGELOG.md +15 -0
- package/dist/bridge/index.mjs +284 -236
- package/dist/bridge/index.mjs.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/bridge/create-emit-stream-event.ts +448 -0
- package/src/bridge/index.ts +35 -386
|
@@ -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
|
+
}
|