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
+10 -129
View File
@@ -1,9 +1,10 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import type { Subprocess } from 'bun';
import type { PiEvent, MessageCost } from '../../api/pi/types';
import type { PiEvent } from '../../api/pi/types';
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
import { parseStream } from './stream-parser';
import { getProxySecret } from './proxy';
import { DATA_PATH, getHomeDir, getGlobalToolsDir, getUserToolsDir } from '../../data-path';
import { generateContainerContext } from '../../generate-container-context';
@@ -36,6 +37,7 @@ function generateMcpConfig(email: string): string {
env: {
PI_TOOLS_DIRS: toolsDirs,
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
MCP_TOOLS_LOG: join(DATA_PATH, email, 'logs', 'mcp-tools.log'),
},
},
},
@@ -263,141 +265,20 @@ export async function spawnClaudeStreaming(
// Process NDJSON stream
try {
const stdout = proc.stdout as ReadableStream<Uint8Array>;
const reader = stdout.getReader();
const decoder = new TextDecoder();
let buffer = '';
let textBuffer = '';
let gotResult = false;
const processLine = (line: string) => {
if (!line.trim()) return;
try {
const msg = JSON.parse(line) as Record<string, unknown>;
const type = msg.type as string;
if (type === 'stream_event') {
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') {
textBuffer += delta.text;
onEvent({ type: 'delta', text: delta.text });
}
}
} else if (type === 'assistant') {
const message = msg.message as Record<string, unknown> | undefined;
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') {
onEvent({ type: 'text', text: block.text });
textBuffer = '';
} else if (block.type === 'tool_use') {
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
textBuffer = '';
}
onEvent({
type: 'tool:start',
toolCallId: (block.id as string) ?? '',
toolName: (block.name as string) ?? 'unknown',
toolInput: (block.input as Record<string, unknown>) ?? {},
});
}
}
}
} else if (type === 'user') {
const message = msg.message as Record<string, unknown> | undefined;
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (Array.isArray(content)) {
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,
});
}
}
}
} else if (type === 'system' && msg.subtype === 'init') {
const sessionId = msg.session_id as string | undefined;
if (sessionId) {
setClaudeSession(sessionKey, sessionId);
}
} else if (type === 'result') {
gotResult = true;
clearTimeout(timeout);
const isError = (msg.is_error as boolean) ?? false;
const resultText = (msg.result as string) ?? '';
if (isError) {
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
textBuffer = '';
}
onEvent({ type: 'error', message: resultText || 'Claude Code returned an error' });
return;
}
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
textBuffer = '';
}
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) {
setClaudeSession(sessionKey, sessionId);
}
onEvent({ type: 'result', cost });
}
} catch {
// Skip malformed JSON lines
}
const callbacks = {
onEvent,
onSessionId: (sessionId: string) => setClaudeSession(sessionKey, sessionId),
};
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);
}
}
if (buffer.trim()) {
processLine(buffer);
}
const state = await parseStream(stdout, callbacks);
clearTimeout(timeout);
if (!gotResult) {
if (!state.gotResult) {
const exitCode = await proc.exited;
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text();
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
if (state.textBuffer) {
onEvent({ type: 'text', text: state.textBuffer });
}
if (exitCode !== 0) {
onEvent({ type: 'error', message: `Claude Code exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}` });