extract stream parser module with tests, add mcp tool call logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 13:49:26 +00:00
co-authored by Claude Opus 4.6
parent 86ddcbfead
commit c38d5b0ea1
4 changed files with 550 additions and 133 deletions
+206
View File
@@ -0,0 +1,206 @@
/**
* Claude Code NDJSON Stream Parser
*
* Parses the stream-json output format from Claude Code CLI into PiEvents.
* Handles: stream_event (deltas), assistant (text/tool_use), user (tool_result),
* system init, and result messages.
*/
import type { PiEvent, MessageCost } from '../../api/pi/types';
type SessionCallback = (sessionId: string) => void;
type StreamParserCallbacks = {
onEvent: (event: PiEvent) => void;
onSessionId: SessionCallback;
};
type ParseState = {
textBuffer: string;
gotResult: boolean;
};
function flushTextBuffer(state: ParseState, onEvent: (event: PiEvent) => void): void {
if (state.textBuffer) {
onEvent({ type: 'text', text: state.textBuffer });
state.textBuffer = '';
}
}
function handleStreamEvent(msg: Record<string, unknown>, state: ParseState, onEvent: (event: PiEvent) => void): void {
const event = msg.event as Record<string, unknown> | undefined;
if (event?.type === 'content_block_delta') {
const delta = event.delta as Record<string, unknown> | undefined;
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
state.textBuffer += delta.text;
onEvent({ type: 'delta', text: delta.text });
}
}
}
function handleAssistant(msg: Record<string, unknown>, state: ParseState, onEvent: (event: PiEvent) => void): void {
const message = msg.message as Record<string, unknown> | undefined;
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content)) return;
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') {
onEvent({ type: 'text', text: block.text });
state.textBuffer = '';
} else if (block.type === 'tool_use') {
flushTextBuffer(state, onEvent);
onEvent({
type: 'tool:start',
toolCallId: (block.id as string) ?? '',
toolName: (block.name as string) ?? 'unknown',
toolInput: (block.input as Record<string, unknown>) ?? {},
});
}
}
}
function handleUser(msg: Record<string, unknown>, onEvent: (event: PiEvent) => void): void {
const message = msg.message as Record<string, unknown> | undefined;
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content)) return;
for (const block of content) {
if (block.type === 'tool_result') {
let output = '';
if (typeof block.content === 'string') {
output = block.content;
} else if (Array.isArray(block.content)) {
output = (block.content as Array<Record<string, unknown>>)
.filter((c) => c.type === 'text')
.map((c) => c.text as string)
.join('\n');
}
onEvent({
type: 'tool:result',
toolCallId: (block.tool_use_id as string) ?? '',
output,
isError: (block.is_error as boolean) ?? false,
});
}
}
}
function handleResult(
msg: Record<string, unknown>,
state: ParseState,
callbacks: StreamParserCallbacks,
): void {
state.gotResult = true;
const isError = (msg.is_error as boolean) ?? false;
const resultText = (msg.result as string) ?? '';
if (isError) {
flushTextBuffer(state, callbacks.onEvent);
callbacks.onEvent({ type: 'error', message: resultText || 'Claude Code returned an error' });
return;
}
flushTextBuffer(state, callbacks.onEvent);
const usage = msg.usage as Record<string, number> | undefined;
const cost: MessageCost = {
inputTokens: usage?.input_tokens ?? 0,
outputTokens: usage?.output_tokens ?? 0,
totalUSD: (msg.total_cost_usd as number) ?? 0,
};
const sessionId = msg.session_id as string | undefined;
if (sessionId) {
callbacks.onSessionId(sessionId);
}
callbacks.onEvent({ type: 'result', cost });
}
/**
* Process a single NDJSON line from Claude Code's stream output.
* Returns false if the line was skipped (empty or malformed), true otherwise.
*/
export function processLine(
line: string,
state: ParseState,
callbacks: StreamParserCallbacks,
): boolean {
if (!line.trim()) return false;
let msg: Record<string, unknown>;
try {
msg = JSON.parse(line) as Record<string, unknown>;
} catch {
return false;
}
const type = msg.type as string;
switch (type) {
case 'stream_event':
handleStreamEvent(msg, state, callbacks.onEvent);
break;
case 'assistant':
handleAssistant(msg, state, callbacks.onEvent);
break;
case 'user':
handleUser(msg, callbacks.onEvent);
break;
case 'system':
if (msg.subtype === 'init') {
const sessionId = msg.session_id as string | undefined;
if (sessionId) callbacks.onSessionId(sessionId);
}
break;
case 'result':
handleResult(msg, state, callbacks);
break;
default:
return false;
}
return true;
}
/**
* Create a fresh parse state for a new stream.
*/
export function createParseState(): ParseState {
return { textBuffer: '', gotResult: false };
}
/**
* Read an NDJSON stream and process each line.
* Returns the final parse state.
*/
export async function parseStream(
stdout: ReadableStream<Uint8Array>,
callbacks: StreamParserCallbacks,
): Promise<ParseState> {
const reader = stdout.getReader();
const decoder = new TextDecoder();
const state = createParseState();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop()!;
for (const line of lines) {
processLine(line, state, callbacks);
}
}
if (buffer.trim()) {
processLine(buffer, state, callbacks);
}
return state;
}
export type { ParseState, StreamParserCallbacks };