/** * KHAELOR * File: tests/anthropic/translate.test.ts * Description: SSE-to-ModelEvent translation tests — deltas, block settlement, tool-input accumulation, usage, stop reasons. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { describe, expect, it } from "vitest"; import { mapStopReason, translateRawStream } from "../../src/anthropic/client.js"; import { ModelError } from "../../src/anthropic/errors.js"; import type { ModelEvent } from "../../src/anthropic/types.js"; import { REQUEST_ID, asRawStream, blockStop, inputJsonDelta, messageDelta, messageStart, messageStop, simpleTextStream, textBlockStart, textDelta, toolCallStream, toolUseBlockStart, } from "./fixtures.js"; async function collect(events: Parameters[0], now?: () => number): Promise { const out: ModelEvent[] = []; for await (const event of translateRawStream(asRawStream(events), now)) { out.push(event); } return out; } describe("translateRawStream", () => { it("translates a plain text stream into started / deltas / settled block / completed", async () => { let tick = 1_000; const events = await collect(simpleTextStream(), () => (tick += 25)); expect(events.map((e) => e.type)).toEqual([ "started", "text-delta", "text-delta", "text-block-completed", "completed", ]); expect(events[0]).toEqual({ type: "started", requestId: REQUEST_ID }); expect(events[1]).toEqual({ type: "text-delta", blockIndex: 0, text: "Hello" }); expect(events[2]).toEqual({ type: "text-delta", blockIndex: 0, text: " world" }); // Settled block is the byte-exact concatenation of the deltas. expect(events[3]).toEqual({ type: "text-block-completed", blockIndex: 0, text: "Hello world" }); const completed = events[4]; if (completed?.type !== "completed") throw new Error("expected completed event"); expect(completed.stopReason).toBe("end_turn"); expect(completed.durationMs).toBeGreaterThan(0); // input tokens from message_start; output tokens from cumulative message_delta. expect(completed.usage).toEqual({ inputTokens: 120, outputTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0, }); }); it("accumulates tool input JSON across input_json_delta chunks and parses it once", async () => { const events = await collect(toolCallStream()); expect(events.map((e) => e.type)).toEqual([ "started", "thinking-delta", "thinking-delta", "thinking-block-completed", "text-delta", "text-block-completed", "tool-call-started", "tool-input-delta", "tool-input-delta", "tool-input-delta", "tool-call-completed", "completed", ]); expect(events[3]).toEqual({ type: "thinking-block-completed", blockIndex: 0, thinking: "Consider the weather.", signature: "sig-abc", }); expect(events[6]).toEqual({ type: "tool-call-started", blockIndex: 2, toolUseId: "toolu_01AAA", toolName: "get_weather", }); expect(events[7]).toEqual({ type: "tool-input-delta", blockIndex: 2, toolUseId: "toolu_01AAA", partialJson: '{"city":', }); expect(events[10]).toEqual({ type: "tool-call-completed", blockIndex: 2, toolUseId: "toolu_01AAA", toolName: "get_weather", input: { city: "Paris" }, }); const completed = events[11]; if (completed?.type !== "completed") throw new Error("expected completed event"); expect(completed.stopReason).toBe("tool_use"); expect(completed.usage).toEqual({ inputTokens: 300, outputTokens: 47, cacheReadTokens: 200, cacheWriteTokens: 0, }); }); it("treats an empty tool input as {} (no input_json_delta chunks)", async () => { const events = await collect([ messageStart(), toolUseBlockStart(0, "toolu_02BBB", "list_files"), blockStop(0), messageDelta({ stop_reason: "tool_use" }), messageStop(), ]); const completed = events.find((e) => e.type === "tool-call-completed"); expect(completed).toEqual({ type: "tool-call-completed", blockIndex: 0, toolUseId: "toolu_02BBB", toolName: "list_files", input: {}, }); }); it("throws a fatal invalid-request ModelError on malformed tool input JSON", async () => { const events = [ messageStart(), toolUseBlockStart(0, "toolu_03CCC", "get_weather"), inputJsonDelta(0, '{"city": '), blockStop(0), // truncated JSON messageDelta({ stop_reason: "tool_use" }), messageStop(), ]; await expect(collect(events)).rejects.toSatisfy( (e: unknown) => e instanceof ModelError && e.kind === "invalid-request" && !e.retryable, ); }); it("updates cache usage fields from message_delta when present", async () => { const events = await collect([ messageStart({ input_tokens: 10, cache_creation_input_tokens: 500 }), textBlockStart(0), textDelta(0, "ok"), blockStop(0), messageDelta({ output_tokens: 3, cache_read_input_tokens: 900 }), messageStop(), ]); const completed = events.at(-1); if (completed?.type !== "completed") throw new Error("expected completed event"); expect(completed.usage).toEqual({ inputTokens: 10, outputTokens: 3, cacheReadTokens: 900, cacheWriteTokens: 500, }); }); it("throws a retryable ModelError if the stream ends without message_stop", async () => { const events = [messageStart(), textBlockStart(0), textDelta(0, "partial")]; await expect(collect(events)).rejects.toSatisfy( (e: unknown) => e instanceof ModelError && e.kind === "retryable", ); }); it("normalizes unknown/stop_sequence stop reasons to end_turn", async () => { expect(mapStopReason("stop_sequence")).toBe("end_turn"); expect(mapStopReason("pause_turn")).toBe("end_turn"); expect(mapStopReason(null)).toBe("end_turn"); expect(mapStopReason("tool_use")).toBe("tool_use"); expect(mapStopReason("max_tokens")).toBe("max_tokens"); expect(mapStopReason("refusal")).toBe("refusal"); }); });