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:
@@ -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 };
|
||||
Reference in New Issue
Block a user