import { EVENT_TYPES } from "../../src/types/events.js"; // Text the CLI injects into the conversation as a `user`-role message rather // than the assistant producing it — today that is the SKILL.md body it // synthesises after a `Skill` tool call. Kept OFF the wire protocol on purpose: // it is never broadcast to session subscribers, because a consumer that // accumulates `text` events (the bridge relay) would post injected context as // the assistant's reply. `handleAgentEvent` decides what it actually is. export const INJECTED_TEXT = "injected_text"; export type AgentEvent = | { type: typeof EVENT_TYPES.status; message: string } | { type: typeof EVENT_TYPES.text; message: string } | { type: typeof INJECTED_TEXT; message: string } | { type: typeof EVENT_TYPES.toolResult; result: unknown } | { type: typeof EVENT_TYPES.error; message: string } | { type: typeof EVENT_TYPES.toolCall; toolUseId: string; toolName: string; args: unknown; } | { type: typeof EVENT_TYPES.toolCallResult; toolUseId: string; content: string; /** Anthropic's `tool_result` block carries `is_error: true` when * the MCP server (or other tool) reported an error. Surfaced * here so the failure monitor (#1353) can attribute repeated * errors to a specific MCP server and warn / notify. */ isError?: boolean; } | { type: typeof EVENT_TYPES.claudeSessionId; id: string }; export interface ClaudeContentBlock { type: string; id?: string; name?: string; input?: unknown; tool_use_id?: string; content?: unknown; /** Text content — present in `text` type blocks. */ text?: string; /** Tool-result error flag from the Anthropic API. Present on * `tool_result` blocks when the tool itself reported failure * (MCP server returned an error, 401, ECONNREFUSED, …). */ is_error?: boolean; } export interface ClaudeMessage { content?: ClaudeContentBlock[]; } export type ClaudeStreamEvent = { type: "assistant"; message: ClaudeMessage } | { type: "user"; message: ClaudeMessage } | { type: "result"; result: string; session_id?: string }; // stream_event sub-types emitted when --include-partial-messages is on. export interface StreamEventDelta { type: "content_block_delta"; index: number; delta: { type: string; text?: string }; } export interface RawStreamEvent { type: string; message?: ClaudeMessage; result?: string; session_id?: string; /** Present when type === "stream_event". Carries partial text * deltas for real-time streaming. */ event?: StreamEventDelta | { type: string }; } /** `role` is the role of the MESSAGE the block came from, not the block's own * kind. A text block only counts as assistant prose when the assistant wrote * it; the same block shape under a `user` message is context the CLI injected * (see `INJECTED_TEXT`). Required rather than defaulted: defaulting to * `assistant` is precisely the assumption that produced #2821, and a silent * default would let a new call site reintroduce it. */ export function blockToEvent(block: ClaudeContentBlock, role: "assistant" | "user"): AgentEvent | null { if (block.type === "text" && typeof block.text === "string") { return { type: role === "user" ? INJECTED_TEXT : EVENT_TYPES.text, message: block.text, }; } if (block.type === "tool_use" && block.id && block.name) { return { type: EVENT_TYPES.toolCall, toolUseId: block.id, toolName: block.name, args: block.input, }; } if (block.type === "tool_result" && block.tool_use_id) { const raw = block.content; const content = typeof raw === "string" ? raw : raw === undefined ? "" : JSON.stringify(raw); const event: AgentEvent = { type: EVENT_TYPES.toolCallResult, toolUseId: block.tool_use_id, content, }; if (block.is_error === true) event.isError = true; return event; } return null; } // Extract a text delta from a stream_event, or null if the event // isn't a text delta. Keeps the main parse function under the // cognitive-complexity cap. function extractTextDelta(event: RawStreamEvent): string | null { if (event.type !== "stream_event" || !event.event) return null; const inner = event.event; if (inner.type !== "content_block_delta" || !("delta" in inner) || inner.delta.type !== "text_delta" || typeof inner.delta.text !== "string") { return null; } return inner.delta.text; } // Filter assistant block events: when deltas already streamed the // text, remove text-type events to prevent duplication. function filterAssistantBlocks(blockEvents: AgentEvent[], deltaStreamed: boolean): AgentEvent[] { // eslint-disable-next-line sonarjs/no-selector-parameter -- `deltaStreamed` is parser state, not a caller-chosen mode (the single call site passes a variable). Folding the branch back into parse() measured cognitive complexity 16 against the cap of 15. return deltaStreamed ? blockEvents.filter((agentEvent) => agentEvent.type !== EVENT_TYPES.text) : blockEvents; } // Stateful parser that deduplicates text across the three stages // Claude CLI emits: stream_event deltas → assistant content blocks // → result full text. Uses two flags: // // textStreamedFromDeltas — true once text_delta chunks have been // emitted from stream_event. Controls whether the full-text // `assistant` block is filtered as a duplicate of those chunks. // // textEmitted — true once ANY text (delta or assistant block) has // been emitted, so the `result` event can suppress its duplicate // full-text copy. Prevents text loss when `assistant` arrives // without preceding `stream_event` deltas (short replies, CLI // version without `--include-partial-messages`, etc.). export function createStreamParser(): { parse: (event: RawStreamEvent) => AgentEvent[]; } { let textStreamedFromDeltas = false; let textEmitted = false; function parse(event: RawStreamEvent): AgentEvent[] { // Handle streaming text deltas from --include-partial-messages. const delta = extractTextDelta(event); if (delta !== null) { textStreamedFromDeltas = true; textEmitted = true; return [{ type: EVENT_TYPES.text, message: delta }]; } if (event.type === "stream_event") return []; if (event.type === "result") { const events: AgentEvent[] = []; if (!textEmitted && event.result) { events.push({ type: EVENT_TYPES.text, message: event.result }); } if (event.session_id) { events.push({ type: EVENT_TYPES.claudeSessionId, id: event.session_id, }); } textStreamedFromDeltas = false; textEmitted = false; return events; } if (event.type !== "assistant" && event.type !== "user") { return []; } const role = event.type === "user" ? "user" : "assistant"; const content = event.message?.content; const blockEvents = Array.isArray(content) ? content.map((block) => blockToEvent(block, role)).filter((agentEvent): agentEvent is AgentEvent => agentEvent !== null) : []; if (event.type === "assistant") { const filtered = filterAssistantBlocks(blockEvents, textStreamedFromDeltas); if (filtered.some((agentEvent) => agentEvent.type === EVENT_TYPES.text)) { textEmitted = true; } return [{ type: EVENT_TYPES.status, message: "Thinking..." }, ...filtered]; } return blockEvents; } return { parse }; } // Stateless convenience: one throwaway parser per event, so no dedup // state survives the call. The agent loop must keep a single // createStreamParser() across the whole turn instead — dedup works by // remembering what earlier events in that turn already emitted, which // a per-event parser can never observe. export function parseStreamEvent(event: RawStreamEvent): AgentEvent[] { return createStreamParser().parse(event); }