/** * Pydantic-AI SSE event mapper. * * Translates the event shape emitted by pydantic-AI–style Django backends * (text_delta / tool_call / tool_result / done / error / approval_required) * into the canonical `ChatStreamEvent` stream consumed by the Chat reducer. * * Backends that don't expose a stable `tool_call_id` are supported via a * per-stream FIFO queue keyed by tool name. The earlier "Map" * approach lost the first toolId when a tool was invoked twice in one turn * (e.g. `list_tasks` for search and again for confirmation) — using a queue * keeps each call/result pair correctly matched. */ import type { ChatStreamEvent } from '../../../types'; import type { ParseSSEOptions } from '../sse'; export interface PydanticAIEvent { type: | 'text_delta' | 'tool_call' | 'tool_result' | 'done' | 'error' | 'approval_required'; delta?: string; tool?: string; args?: unknown; result?: unknown; /** * Structured frontend payload — present on `tool_result` when the tool has * a `result_schema`. The LLM sees only `result` (compact text); the * frontend uses `data` to render rich UI (e.g. vehicle cards). */ data?: unknown; total_tokens?: number; error?: string; tool_call_id?: string; session_id?: string; } /** Per-stream FIFO queue keyed by tool name. Created via `createToolIdQueue`. */ export interface ToolIdQueue { /** Allocate a new toolId for a `tool_call` event and enqueue it under `name`. */ push(name: string): string; /** Pop the oldest toolId for `name` (or return an orphan marker if none). */ shift(name: string): string; /** Reset all queues (e.g. on stream close). */ clear(): void; } export function createToolIdQueue(): ToolIdQueue { const queues = new Map(); let counter = 0; return { push(name) { const id = `${name}-${counter++}-${Date.now()}`; const q = queues.get(name) ?? []; q.push(id); queues.set(name, q); return id; }, shift(name) { return queues.get(name)?.shift() ?? `${name}-orphan-${counter++}`; }, clear() { queues.clear(); }, }; } /** * Translate a single pydantic-AI event into zero or more `ChatStreamEvent`s. * Pass a `ToolIdQueue` shared across the lifetime of one stream so that * `tool_call` / `tool_result` pairs match correctly. */ export function* mapPydanticAIEvent( ev: PydanticAIEvent, toolIds: ToolIdQueue, ): Generator { switch (ev.type) { case 'text_delta': if (ev.delta) yield { type: 'chunk', delta: ev.delta }; return; case 'tool_call': { const name = ev.tool ?? 'tool'; const toolId = toolIds.push(name); yield { type: 'tool_call_start', toolId, name, input: ev.args }; return; } case 'tool_result': { const name = ev.tool ?? 'tool'; const toolId = toolIds.shift(name); // `data` is the structured JSON for the frontend; `result` is the LLM-facing text. const output: unknown = ev.data !== undefined ? ev.data : ev.result; yield { type: 'tool_call_end', toolId, output, status: 'success' }; return; } case 'done': yield { type: 'message_end', patch: { tokensOut: ev.total_tokens } }; return; case 'error': yield { type: 'error', code: 'backend_error', message: ev.error ?? 'Unknown error' }; return; case 'approval_required': // Surfaced via a separate side-channel (see ResumableTransport in // host packages). Not translated to a ChatStreamEvent here. return; } } /** * Convenience factory: returns a `ParseSSEOptions['map']` callback that * decodes raw SSE frames as `PydanticAIEvent` JSON and yields zero or * more `ChatStreamEvent`s through `mapPydanticAIEvent`. * * Allocates an internal `ToolIdQueue` — call this once per stream. */ export function createPydanticAISSEMap(): NonNullable { const toolIds = createToolIdQueue(); return (raw) => { if (!raw.data) return null; let parsed: PydanticAIEvent; try { parsed = JSON.parse(raw.data) as PydanticAIEvent; } catch { return null; } const out: ChatStreamEvent[] = []; for (const evt of mapPydanticAIEvent(parsed, toolIds)) { out.push(evt); } if (out.length === 0) return null; if (out.length === 1) return out[0]!; return out; }; }