/** * GenerativeModel pure unit tests (no network). * * Covers two core pieces of logic: * 1. Merging OmniMessage[] into one UniMessage (including throwing on mixed roles, and * mapping each content type); * 2. Translating/aggregating UniEvent[] into OmniMessage[] (partial_* ordering, complete * messages, token_usage accumulation, tool_call_id passthrough). * As well as helper functions for token conversion, UniConfig construction, and retry * determination. */ import { describe, expect, it } from "vitest"; import { EmptyResponseError, ThinkingLevel, ToolCallArgumentParseError, } from "@prismshadow/agenthub"; import type { UniConfig, UniEvent, UniMessage, UsageMetadata } from "@prismshadow/agenthub"; import type { LLMOutcome, ThinkingLevelName } from "../src/interfaces.js"; import { EventTranslator, GenerativeModel, ToolCallIdAllocator, buildUniConfig, isAuthenticationError, isIncompleteStreamError, isMalformedJsonParseError, isQuotaExhaustedError, isRetryableError, mapThinkingLevel, mergeOmniToUniMessage, stripToolCallIdSuffix, toolDefinitionsToSchemas, translateEvents, usageToTokenCounts, } from "../src/llm/index.js"; import { assistantText, imageUrlMessage, inlineData, inlineThinking, thinkingMessage, toolCall, toolCallOutput, userText, } from "../src/omnimessage/index.js"; import type { OmniMessage, TextPayload, ThinkingPayload, ToolCallPayload, TokenUsagePayload, } from "../src/omnimessage/index.js"; // Small helper to construct a UniEvent. function ev(partial: Partial & Pick): UniEvent { return { role: "assistant", event_type: "delta", usage_metadata: null, finish_reason: null, ...partial, }; } describe("mergeOmniToUniMessage", () => { it("merges same-role messages into one UniMessage and maps content types", () => { const uni = mergeOmniToUniMessage([ userText("hello"), imageUrlMessage("https://example.com/a.png"), inlineData("user", Buffer.from("xyz").toString("base64"), "image/png"), ]); expect(uni.role).toBe("user"); expect(uni.content_items).toHaveLength(3); expect(uni.content_items[0]).toEqual({ type: "text", text: "hello" }); expect(uni.content_items[1]).toEqual({ type: "image_url", image_url: "https://example.com/a.png", }); const inline = uni.content_items[2]!; expect(inline.type).toBe("inline_data"); if (inline.type === "inline_data") { expect(inline.mime_type).toBe("image/png"); expect(Buffer.isBuffer(inline.data)).toBe(true); expect(inline.data.toString()).toBe("xyz"); } }); it("maps assistant thinking and inline_thinking content", () => { const uni = mergeOmniToUniMessage([ thinkingMessage("step by step"), inlineThinking(Buffer.from("sig").toString("base64"), "application/octet-stream"), ]); expect(uni.role).toBe("assistant"); expect(uni.content_items).toHaveLength(2); expect(uni.content_items[0]).toEqual({ type: "thinking", thinking: "step by step", }); const inline = uni.content_items[1]!; expect(inline.type).toBe("inline_thinking"); if (inline.type === "inline_thinking") { expect(inline.mime_type).toBe("application/octet-stream"); expect(Buffer.isBuffer(inline.data)).toBe(true); expect(inline.data.toString()).toBe("sig"); } }); it("maps assistant tool_call OmniMessage (args JSON string → object)", () => { const uni = mergeOmniToUniMessage([ toolCall({ name: "exec_command", arguments: '{"cmd":"ls -la"}', toolCallId: "call_1", }), ]); expect(uni.role).toBe("assistant"); const item = uni.content_items[0]!; expect(item).toEqual({ type: "tool_call", name: "exec_command", arguments: { cmd: "ls -la" }, tool_call_id: "call_1", }); }); it("maps tool_call_output to tool_result with role user and preserves id", () => { const uni = mergeOmniToUniMessage([ toolCallOutput({ output: "total 0", toolCallId: "call_1" }), ]); expect(uni.role).toBe("user"); expect(uni.content_items[0]).toEqual({ type: "tool_result", text: "total 0", tool_call_id: "call_1", }); }); it("maps tool_call_output images to tool_result.images (data URL array)", () => { const dataUrl = "data:image/png;base64,AAAA"; const uni = mergeOmniToUniMessage([ toolCallOutput({ output: "image/png, 4 B", toolCallId: "call_img", images: [dataUrl] }), ]); expect(uni.role).toBe("user"); expect(uni.content_items[0]).toEqual({ type: "tool_result", text: "image/png, 4 B", images: [dataUrl], tool_call_id: "call_img", }); }); it("throws on mixed roles", () => { expect(() => mergeOmniToUniMessage([userText("hi"), thinkingMessage("reasoning")])).toThrow( /mixed roles/, ); }); it("throws on empty input", () => { expect(() => mergeOmniToUniMessage([])).toThrow(); }); }); describe("usageToTokenCounts", () => { it("maps cached→cache_read, prompt→cache_write, thoughts+response→output", () => { const usage: UsageMetadata = { cached_tokens: 5, prompt_tokens: 10, thoughts_tokens: 3, response_tokens: 7, }; // cache_read = 5; cache_write = 10 (non-cached input); output = 3 + 7 = 10; total = 25. expect(usageToTokenCounts(usage)).toEqual({ cache_read: 5, cache_write: 10, output: 10, total: 25, }); }); it("treats nulls as zero", () => { const usage: UsageMetadata = { cached_tokens: null, prompt_tokens: null, thoughts_tokens: null, response_tokens: null, }; expect(usageToTokenCounts(usage)).toEqual({ cache_read: 0, cache_write: 0, output: 0, total: 0, }); }); }); describe("translateEvents", () => { it("emits text partials (start/delta/stop), a complete text, and token_usage", () => { const events: UniEvent[] = [ ev({ event_type: "start", content_items: [] }), ev({ content_items: [{ type: "text", text: "Hel" }] }), ev({ content_items: [{ type: "text", text: "lo" }] }), ev({ event_type: "stop", content_items: [], finish_reason: "stop", usage_metadata: { cached_tokens: 0, prompt_tokens: 12, thoughts_tokens: 0, response_tokens: 4, }, }), ]; const { messages, requestTokens, sessionTokens } = translateEvents(events); const types = messages.map((m) => (m.payload as { type: string }).type); // partial start, two deltas, partial stop, complete text, token_usage. expect(types).toEqual([ "partial_text", "partial_text", "partial_text", "partial_text", "text", "token_usage", ]); // partial events: start (empty) → delta "Hel" → delta "lo" → stop. const ptexts = messages .filter((m) => (m.payload as { type: string }).type === "partial_text") .map((m) => m.payload as { event_type: string; text: string }); expect(ptexts).toEqual([ { type: "partial_text", role: "assistant", event_type: "start", text: "", stop_reason: "completed", }, { type: "partial_text", role: "assistant", event_type: "delta", text: "Hel", stop_reason: "completed", }, { type: "partial_text", role: "assistant", event_type: "delta", text: "lo", stop_reason: "completed", }, { type: "partial_text", role: "assistant", event_type: "stop", text: "", stop_reason: "completed", }, ]); // complete text message: concatenated, stop_reason completed (finish_reason "stop"). const complete = messages.find((m) => (m.payload as { type: string }).type === "text")! .payload as TextPayload; expect(complete.text).toBe("Hello"); expect(complete.role).toBe("assistant"); expect(complete.stop_reason).toBe("completed"); // token accounting: request total = 12 + 4 = 16. expect(requestTokens.total).toBe(16); expect(requestTokens.output).toBe(4); expect(sessionTokens).toEqual(requestTokens); const tu = messages.at(-1)!.payload as TokenUsagePayload; expect(tu.type).toBe("token_usage"); expect(tu.request.total).toBe(16); expect(tu.session.total).toBe(16); }); it("accumulates partial_tool_call args, uses complete tool_call as authoritative, preserves id", () => { const events: UniEvent[] = [ ev({ event_type: "start", content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: "", tool_call_id: "c1" }, ], }), ev({ content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: '{"cmd":"ls', tool_call_id: "c1", }, ], }), ev({ content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: ' -la"}', tool_call_id: "c1", }, ], }), ev({ event_type: "stop", finish_reason: "tool_call", content_items: [ { type: "tool_call", name: "exec_command", arguments: { cmd: "ls -la" }, tool_call_id: "c1", }, ], }), ]; const { messages } = translateEvents(events); const types = messages.map((m) => (m.payload as { type: string }).type); expect(types).toEqual([ "partial_tool_call", // start "partial_tool_call", // delta "partial_tool_call", // delta "partial_tool_call", // stop "tool_call", // complete "token_usage", ]); // partial start carries name, no args; deltas carry arg fragments. const partials = messages .filter((m) => (m.payload as { type: string }).type === "partial_tool_call") .map((m) => m.payload as { event_type: string; arguments: string; tool_call_id: string }); expect(partials[0]!.event_type).toBe("start"); expect(partials[0]!.arguments).toBe(""); expect(partials[1]!.arguments).toBe('{"cmd":"ls'); expect(partials[2]!.arguments).toBe(' -la"}'); expect(partials[3]!.event_type).toBe("stop"); expect(partials.every((p) => p.tool_call_id === "c1")).toBe(true); // complete tool_call uses the authoritative complete content item. const tc = messages.find((m) => (m.payload as { type: string }).type === "tool_call")! .payload as ToolCallPayload; expect(tc.name).toBe("exec_command"); expect(tc.tool_call_id).toBe("c1"); expect(tc.arguments).toBe('{"cmd":"ls -la"}'); expect(tc.stop_reason).toBe("completed"); }); it("falls back to accumulated arg buffer when no complete tool_call item arrives", () => { const events: UniEvent[] = [ ev({ event_type: "start", content_items: [ { type: "partial_tool_call", name: "do_it", arguments: '{"x":', tool_call_id: "z9" }, ], }), ev({ content_items: [ { type: "partial_tool_call", name: "do_it", arguments: "1}", tool_call_id: "z9" }, ], }), ev({ event_type: "stop", finish_reason: "tool_call", content_items: [] }), ]; const { messages } = translateEvents(events); const tc = messages.find((m) => (m.payload as { type: string }).type === "tool_call")! .payload as ToolCallPayload; expect(tc.arguments).toBe('{"x":1}'); expect(tc.tool_call_id).toBe("z9"); }); it("ignores tool-call fragments with empty tool_call_id (no spurious empty tool_call)", () => { // Regression: some early streamed fragments may carry an empty tool_call_id; this must not // be used to generate an empty tool_call (otherwise it would trigger "Unknown tool" and // AgentHub's "tool_call_id is required" error). const events: UniEvent[] = [ ev({ event_type: "start", content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: "", tool_call_id: "real1" }, ], }), // A streamed fragment with an empty id mixed in. ev({ content_items: [{ type: "partial_tool_call", name: "", arguments: "", tool_call_id: "" }], }), ev({ content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: '{"cmd":"ls"}', tool_call_id: "real1", }, ], }), ev({ event_type: "stop", finish_reason: "tool_call", content_items: [ { type: "tool_call", name: "exec_command", arguments: { cmd: "ls" }, tool_call_id: "real1", }, // A complete tool_call with an empty id should also be ignored. { type: "tool_call", name: "", arguments: {}, tool_call_id: "" }, ], }), ]; const { messages } = translateEvents(events); const toolCalls = messages.filter((m) => (m.payload as { type: string }).type === "tool_call"); expect(toolCalls).toHaveLength(1); expect((toolCalls[0]!.payload as ToolCallPayload).tool_call_id).toBe("real1"); // No message should carry an empty tool_call_id. const emptyIds = messages.filter( (m) => (m.payload as { tool_call_id?: string }).tool_call_id === "", ); expect(emptyIds).toHaveLength(0); }); it("attributes empty-id tool-call argument deltas to the active tool call", () => { const events: UniEvent[] = [ ev({ event_type: "start", content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: "", tool_call_id: "real1" }, ], }), ev({ content_items: [ { type: "partial_tool_call", name: "", arguments: '{"cmd":"l', tool_call_id: "" }, ], }), ev({ content_items: [ { type: "partial_tool_call", name: "", arguments: 's"}', tool_call_id: "" }, ], }), ev({ event_type: "stop", finish_reason: "tool_call", content_items: [ { type: "tool_call", name: "exec_command", arguments: { cmd: "ls" }, tool_call_id: "real1", }, ], }), ]; const { messages } = translateEvents(events); const deltas = messages .filter( (m) => (m.payload as { type: string }).type === "partial_tool_call" && (m.payload as { event_type: string }).event_type === "delta", ) .map((m) => m.payload as { arguments: string; tool_call_id: string }); expect(deltas.map((p) => p.arguments)).toEqual(['{"cmd":"l', 's"}']); expect(deltas.every((p) => p.tool_call_id === "real1")).toBe(true); }); it("emits a complete tool_call immediately when its complete content item arrives mid-stream (async/incremental)", () => { // Two tools: the first's complete content item arrives mid-stream (not at finish) -> should // be produced immediately. const events: UniEvent[] = [ ev({ event_type: "start", content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: '{"cmd":"a"}', tool_call_id: "t1", }, ], }), // t1's complete content item arrives early (before t2), and should finish t1 immediately. ev({ content_items: [ { type: "tool_call", name: "exec_command", arguments: { cmd: "a" }, tool_call_id: "t1" }, ], }), ev({ content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: '{"cmd":"b"}', tool_call_id: "t2", }, { type: "tool_call", name: "exec_command", arguments: { cmd: "b" }, tool_call_id: "t2" }, ], }), ev({ event_type: "stop", finish_reason: "tool_call", content_items: [] }), ]; const { messages } = translateEvents(events); const completeToolCalls = messages.filter( (m) => (m.payload as { type: string }).type === "tool_call", ); // Both complete tool_calls are produced, with t1 before t2 (in arrival order, not as a // batch at finish). expect(completeToolCalls.map((m) => (m.payload as ToolCallPayload).tool_call_id)).toEqual([ "t1", "t2", ]); // t1's complete tool_call appears before t2's start fragment (proving it was produced // before finish). const idxT1Complete = messages.findIndex( (m) => (m.payload as { type: string }).type === "tool_call" && (m.payload as ToolCallPayload).tool_call_id === "t1", ); const idxT2Start = messages.findIndex( (m) => (m.payload as { type: string }).type === "partial_tool_call" && (m.payload as { tool_call_id?: string }).tool_call_id === "t2", ); expect(idxT1Complete).toBeLessThan(idxT2Start); // Each tool is produced exactly once (no duplication at finish). expect(completeToolCalls).toHaveLength(2); }); it("does not write name on delta or stop tool-call partials", () => { const events: UniEvent[] = [ ev({ event_type: "start", content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: "", tool_call_id: "c1" }, ], }), ev({ content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: '{"cmd":"ls"}', tool_call_id: "c1", }, ], }), ev({ event_type: "stop", finish_reason: "tool_call", content_items: [] }), ]; const { messages } = translateEvents(events); const partials = messages.filter( (m) => (m.payload as { type: string }).type === "partial_tool_call", ) as { payload: { event_type: string; name: string } }[]; const start = partials.find((p) => p.payload.event_type === "start")!; const delta = partials.find((p) => p.payload.event_type === "delta")!; const stop = partials.find((p) => p.payload.event_type === "stop")!; expect(start.payload.name).toBe("exec_command"); // start still carries name. expect(delta.payload.name).toBe(""); // delta does not carry name. expect(stop.payload.name).toBe(""); // stop does not carry name. }); it("emits thinking partials and a complete thinking message before text", () => { const events: UniEvent[] = [ ev({ event_type: "start", content_items: [{ type: "thinking", thinking: "Let me" }] }), ev({ content_items: [{ type: "thinking", thinking: " think" }] }), ev({ content_items: [{ type: "text", text: "Answer" }] }), ev({ event_type: "stop", finish_reason: "stop", content_items: [] }), ]; const { messages } = translateEvents(events); const completeTypes = messages .map((m) => (m.payload as { type: string }).type) .filter((t) => t === "thinking" || t === "text"); // thinking complete message emitted before text complete message. expect(completeTypes).toEqual(["thinking", "text"]); const think = messages.find((m) => (m.payload as { type: string }).type === "thinking")! .payload as ThinkingPayload; expect(think.thinking).toBe("Let me think"); }); it("emits complete thinking and text before a mid-stream complete tool_call", () => { // Reproduces the "thinking ends up after tool_call in Trace" regression: the model thinks // first, then outputs text, then the tool call's complete content item arrives before // finish. The complete-message order must be thinking -> text -> tool_call (not // tool_call -> thinking -> text). const events: UniEvent[] = [ ev({ event_type: "start", content_items: [{ type: "thinking", thinking: "I should" }] }), ev({ content_items: [{ type: "thinking", thinking: " run ls" }] }), ev({ content_items: [{ type: "text", text: "Running it." }] }), ev({ content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: '{"cmd":"ls"}', tool_call_id: "c1", }, { type: "tool_call", name: "exec_command", arguments: { cmd: "ls" }, tool_call_id: "c1" }, ], }), ev({ event_type: "stop", finish_reason: "tool_call", content_items: [] }), ]; const { messages } = translateEvents(events); const completeTypes = messages .map((m) => (m.payload as { type: string }).type) .filter((t) => t === "thinking" || t === "text" || t === "tool_call"); // Complete-message order: thinking -> text -> tool_call, each exactly once (flush does not repeat). expect(completeTypes).toEqual(["thinking", "text", "tool_call"]); // Complete thinking/text are marked completed when finished at the boundary (the finish // reason belongs to the tool_call itself). const think = messages.find((m) => (m.payload as { type: string }).type === "thinking")! .payload as ThinkingPayload; expect(think.thinking).toBe("I should run ls"); expect(think.stop_reason).toBe("completed"); const text = messages.find((m) => (m.payload as { type: string }).type === "text")! .payload as TextPayload; expect(text.text).toBe("Running it."); expect(text.stop_reason).toBe("completed"); const tc = messages.find((m) => (m.payload as { type: string }).type === "tool_call")! .payload as ToolCallPayload; expect(tc.stop_reason).toBe("completed"); }); it("flushes thinking/text emitted after a tool_call (does not drop later segments)", () => { // Interleaved output: text appears both before and after tool_call. The reset-after-flush // design should let a new text segment following tool_call still be produced correctly at // finish (a one-shot guard would lose it). const events: UniEvent[] = [ ev({ event_type: "start", content_items: [{ type: "text", text: "before " }] }), ev({ content_items: [{ type: "text", text: "call" }] }), ev({ content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: '{"cmd":"ls"}', tool_call_id: "c1", }, { type: "tool_call", name: "exec_command", arguments: { cmd: "ls" }, tool_call_id: "c1" }, ], }), ev({ content_items: [{ type: "text", text: "after call" }] }), ev({ event_type: "stop", finish_reason: "stop", content_items: [] }), ]; const { messages } = translateEvents(events); const completeTypes = messages .map((m) => (m.payload as { type: string }).type) .filter((t) => t === "text" || t === "tool_call"); // Produces the text before tool_call first, then tool_call, then the new text segment after it. expect(completeTypes).toEqual(["text", "tool_call", "text"]); const texts = messages .filter((m) => (m.payload as { type: string }).type === "text") .map((m) => (m.payload as TextPayload).text); expect(texts).toEqual(["before call", "after call"]); }); it("keeps thinking and text as separate segments across a thinking→text boundary", () => { const events: UniEvent[] = [ ev({ event_type: "start", content_items: [{ type: "thinking", thinking: "ponder" }] }), ev({ content_items: [{ type: "text", text: "answer" }] }), ev({ event_type: "stop", finish_reason: "stop", content_items: [] }), ]; const { messages } = translateEvents(events); const completeTypes = messages .map((m) => (m.payload as { type: string }).type) .filter((t) => t === "thinking" || t === "text"); expect(completeTypes).toEqual(["thinking", "text"]); // The thinking segment finishes before the text segment starts: partial_thinking stop // precedes partial_text start. const idxThinkStop = messages.findIndex( (m) => (m.payload as { type: string; event_type?: string }).type === "partial_thinking" && (m.payload as { event_type?: string }).event_type === "stop", ); const idxTextStart = messages.findIndex( (m) => (m.payload as { type: string; event_type?: string }).type === "partial_text" && (m.payload as { event_type?: string }).event_type === "start", ); expect(idxThinkStop).toBeGreaterThanOrEqual(0); expect(idxThinkStop).toBeLessThan(idxTextStart); }); it("emits text before thinking for a text→thinking boundary (not reordered)", () => { const events: UniEvent[] = [ ev({ event_type: "start", content_items: [{ type: "text", text: "hello" }] }), ev({ content_items: [{ type: "thinking", thinking: "hmm" }] }), ev({ event_type: "stop", finish_reason: "stop", content_items: [] }), ]; const { messages } = translateEvents(events); const completeTypes = messages .map((m) => (m.payload as { type: string }).type) .filter((t) => t === "thinking" || t === "text"); // The generation order is text -> thinking, and the complete-message order must match it // (the old implementation would reverse it to put thinking first). expect(completeTypes).toEqual(["text", "thinking"]); }); it("does not merge two thinking segments separated by text (think→text→think)", () => { const events: UniEvent[] = [ ev({ event_type: "start", content_items: [{ type: "thinking", thinking: "first" }] }), ev({ content_items: [{ type: "text", text: "mid" }] }), ev({ content_items: [{ type: "thinking", thinking: "second" }] }), ev({ event_type: "stop", finish_reason: "stop", content_items: [] }), ]; const { messages } = translateEvents(events); const completeTypes = messages .map((m) => (m.payload as { type: string }).type) .filter((t) => t === "thinking" || t === "text"); // Three separate segments, produced in generation order (the old implementation merged // the two thinking segments and had no second start). expect(completeTypes).toEqual(["thinking", "text", "thinking"]); const thinkings = messages .filter((m) => (m.payload as { type: string }).type === "thinking") .map((m) => (m.payload as ThinkingPayload).thinking); expect(thinkings).toEqual(["first", "second"]); // The second thinking segment reopens a segment: two partial_thinking starts appear. const thinkStarts = messages.filter( (m) => (m.payload as { type: string; event_type?: string }).type === "partial_thinking" && (m.payload as { event_type?: string }).event_type === "start", ); expect(thinkStarts).toHaveLength(2); }); it("does not merge two text segments separated by thinking (text→think→text)", () => { const events: UniEvent[] = [ ev({ event_type: "start", content_items: [{ type: "text", text: "a" }] }), ev({ content_items: [{ type: "thinking", thinking: "b" }] }), ev({ content_items: [{ type: "text", text: "c" }] }), ev({ event_type: "stop", finish_reason: "stop", content_items: [] }), ]; const { messages } = translateEvents(events); const completeTypes = messages .map((m) => (m.payload as { type: string }).type) .filter((t) => t === "thinking" || t === "text"); expect(completeTypes).toEqual(["text", "thinking", "text"]); const texts = messages .filter((m) => (m.payload as { type: string }).type === "text") .map((m) => (m.payload as TextPayload).text); expect(texts).toEqual(["a", "c"]); }); it("flushes thinking/text before a partial-only tool_call (no full item until finish)", () => { // The tool only goes through partial_tool_call deltas (no complete tool_call content item), // and is produced by falling back at finish. The new tool's first delta is a type boundary, // so thinking/text must be flushed first. const events: UniEvent[] = [ ev({ event_type: "start", content_items: [{ type: "thinking", thinking: "plan" }] }), ev({ content_items: [{ type: "text", text: "doing" }] }), ev({ content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: '{"cmd":', tool_call_id: "p1", }, ], }), ev({ content_items: [ { type: "partial_tool_call", name: "", arguments: '"ls"}', tool_call_id: "p1" }, ], }), ev({ event_type: "stop", finish_reason: "tool_call", content_items: [] }), ]; const { messages } = translateEvents(events); const completeTypes = messages .map((m) => (m.payload as { type: string }).type) .filter((t) => t === "thinking" || t === "text" || t === "tool_call"); expect(completeTypes).toEqual(["thinking", "text", "tool_call"]); // Only one thinking, one text, each flushed exactly once before the tool's first delta. const tc = messages.find((m) => (m.payload as { type: string }).type === "tool_call")! .payload as ToolCallPayload; expect(tc.arguments).toBe('{"cmd":"ls"}'); }); it("does not re-flush on continuation tool deltas lacking a tool_call_id", () => { // Some providers' subsequent argument deltas do not carry an id, and are attributed to // activeToolCallId; this must not trigger a duplicate flush, nor produce a spurious // empty thinking/text complete message. const events: UniEvent[] = [ ev({ event_type: "start", content_items: [{ type: "thinking", thinking: "go" }] }), ev({ content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: '{"a":', tool_call_id: "k1", }, ], }), // A continuation delta with no id. ev({ content_items: [{ type: "partial_tool_call", name: "", arguments: "1}", tool_call_id: "" }], }), ev({ event_type: "stop", finish_reason: "tool_call", content_items: [] }), ]; const { messages } = translateEvents(events); const thinkings = messages.filter((m) => (m.payload as { type: string }).type === "thinking"); const texts = messages.filter((m) => (m.payload as { type: string }).type === "text"); expect(thinkings).toHaveLength(1); // Exactly one, not duplicated by the continuation delta expect(texts).toHaveLength(0); // Does not conjure an empty text out of nowhere const toolStarts = messages.filter( (m) => (m.payload as { type: string; event_type?: string }).type === "partial_tool_call" && (m.payload as { event_type?: string }).event_type === "start", ); expect(toolStarts).toHaveLength(1); // The same tool has only one start }); it("accumulates session tokens across two requests", () => { const mkUsage = (p: number, r: number): UsageMetadata => ({ cached_tokens: 0, prompt_tokens: p, thoughts_tokens: 0, response_tokens: r, }); const first = translateEvents([ ev({ content_items: [{ type: "text", text: "a" }] }), ev({ event_type: "stop", finish_reason: "stop", content_items: [], usage_metadata: mkUsage(10, 5), }), ]); expect(first.sessionTokens.total).toBe(15); const second = translateEvents( [ ev({ content_items: [{ type: "text", text: "b" }] }), ev({ event_type: "stop", finish_reason: "stop", content_items: [], usage_metadata: mkUsage(20, 3), }), ], first.sessionTokens, ); expect(second.requestTokens.total).toBe(23); expect(second.sessionTokens.total).toBe(38); }); it("keeps only the last usage snapshot within a request (per-chunk cumulative reports are not summed)", () => { // Regression: Gemini (and some OpenAI-compatible endpoints) report usage as a **cumulative // snapshot** per chunk; summing them would inflate usage by roughly the chunk count // (especially for output), so the last snapshot must be authoritative. const mkUsage = (p: number, t: number, r: number): UsageMetadata => ({ cached_tokens: null, prompt_tokens: p, thoughts_tokens: t, response_tokens: r, }); const { messages, requestTokens, sessionTokens } = translateEvents([ ev({ content_items: [{ type: "text", text: "Hel" }], usage_metadata: mkUsage(16, 488, 18) }), ev({ content_items: [{ type: "text", text: "lo" }], usage_metadata: mkUsage(16, 488, 20) }), ev({ event_type: "stop", finish_reason: "stop", content_items: [], usage_metadata: mkUsage(16, 488, 20), }), ]); // The last snapshot is authoritative: cache_write = 16, output = 488 + 20 = 508, total = 524. expect(requestTokens).toEqual({ cache_read: 0, cache_write: 16, output: 508, total: 524 }); expect(sessionTokens).toEqual(requestTokens); const tu = messages.at(-1)!.payload as TokenUsagePayload; expect(tu.request.total).toBe(524); }); }); describe("EventTranslator.finishInterrupted (PRN-012 structural closure)", () => { it("closes an open text segment with a stop + complete text marked with the interruption reason, and emits no token_usage", () => { const tr = new EventTranslator(); const out: OmniMessage[] = []; // Opens a text segment (start + two deltas), then gets interrupted (no stop / finish received). for (const e of [ ev({ event_type: "start", content_items: [] }), ev({ content_items: [{ type: "text", text: "Par" }] }), ev({ content_items: [{ type: "text", text: "tial" }] }), ]) { for (const m of tr.pushEvent(e)) out.push(m); } for (const m of tr.finishInterrupted("timeout")) out.push(m); const types = out.map((m) => (m.payload as { type: string }).type); expect(types).toEqual([ "partial_text", // start "partial_text", // delta Par "partial_text", // delta tial "partial_text", // stop (backfilled by finishInterrupted) "text", // complete message ]); expect(types).not.toContain("token_usage"); // An interrupted Request has no usage. const stop = out[3]!.payload as { event_type: string; stop_reason: string }; expect(stop.event_type).toBe("stop"); expect(stop.stop_reason).toBe("timeout"); const complete = out[4]!.payload as TextPayload; expect(complete.text).toBe("Partial"); expect(complete.stop_reason).toBe("timeout"); }); it("closes an open thinking segment with the interruption reason on both partial stop and complete message", () => { const tr = new EventTranslator(); const out: OmniMessage[] = []; // Opens a thinking segment (start + delta), then gets interrupted (no stop / finish received). for (const e of [ ev({ event_type: "start", content_items: [] }), ev({ content_items: [{ type: "thinking", thinking: "half a thought" }] }), ]) { for (const m of tr.pushEvent(e)) out.push(m); } for (const m of tr.finishInterrupted("aborted")) out.push(m); const stop = out.find( (m) => (m.payload as { type: string; event_type?: string }).type === "partial_thinking" && (m.payload as { event_type?: string }).event_type === "stop", )!.payload as { stop_reason: string }; expect(stop.stop_reason).toBe("aborted"); const complete = out.find((m) => (m.payload as { type: string }).type === "thinking")! .payload as ThinkingPayload; expect(complete.thinking).toBe("half a thought"); // Streamed concatenation == complete message: the complete thinking's stop_reason matches // partial(stop), no longer hardcoded to completed (regression: flushThinking used to // hardcode completed). expect(complete.stop_reason).toBe("aborted"); }); it("completes an incomplete (partials-only) tool_call with the interruption reason, not 'completed'", () => { const tr = new EventTranslator(); const out: OmniMessage[] = []; for (const e of [ ev({ event_type: "start", content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: "", tool_call_id: "c1" }, ], }), ev({ content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: '{"cmd":"ls', tool_call_id: "c1", }, ], }), ]) { for (const m of tr.pushEvent(e)) out.push(m); } for (const m of tr.finishInterrupted("aborted")) out.push(m); const complete = out.find((m) => (m.payload as { type: string }).type === "tool_call")! .payload as ToolCallPayload; expect(complete.tool_call_id).toBe("c1"); // Key point: not "completed" -> context_engine will not dispatch it for execution (it only // serves structural completeness and observability). expect(complete.stop_reason).toBe("aborted"); expect(complete.arguments).toBe('{"cmd":"ls'); // Keeps the (incomplete) delta accumulated so far. const toolStop = out.find( (m) => (m.payload as { type: string }).type === "partial_tool_call" && (m.payload as { event_type: string }).event_type === "stop", )!.payload as { stop_reason: string }; expect(toolStop.stop_reason).toBe("aborted"); expect(out.map((m) => (m.payload as { type: string }).type)).not.toContain("token_usage"); }); it("does not re-emit nor relabel a tool_call already completed mid-stream (keeps 'completed')", () => { const tr = new EventTranslator(); const out: OmniMessage[] = []; for (const e of [ ev({ event_type: "start", content_items: [ { type: "partial_tool_call", name: "exec_command", arguments: '{"cmd":"ls"}', tool_call_id: "c1", }, ], }), ev({ content_items: [ { type: "tool_call", name: "exec_command", arguments: { cmd: "ls" }, tool_call_id: "c1" }, ], }), ]) { for (const m of tr.pushEvent(e)) out.push(m); } const before = out.length; for (const m of tr.finishInterrupted("timeout")) out.push(m); expect(out.length).toBe(before); // Already produced immediately, not duplicated. const complete = out.find((m) => (m.payload as { type: string }).type === "tool_call")! .payload as ToolCallPayload; expect(complete.stop_reason).toBe("completed"); }); }); describe("config helpers", () => { it("maps thinking levels", () => { expect(mapThinkingLevel("none")).toBe(ThinkingLevel.NONE); expect(mapThinkingLevel("low")).toBe(ThinkingLevel.LOW); expect(mapThinkingLevel("medium")).toBe(ThinkingLevel.MEDIUM); expect(mapThinkingLevel("high")).toBe(ThinkingLevel.HIGH); expect(mapThinkingLevel("xhigh")).toBe(ThinkingLevel.XHIGH); expect(mapThinkingLevel(undefined)).toBeUndefined(); }); it("maps tool definitions to schemas (omitting undefined parameters)", () => { const schemas = toolDefinitionsToSchemas([ { name: "a", description: "desc a", parameters: { type: "object" } }, { name: "b", description: "desc b" }, ]); expect(schemas[0]).toEqual({ name: "a", description: "desc a", parameters: { type: "object" }, }); expect(schemas[1]).toEqual({ name: "b", description: "desc b" }); expect("parameters" in schemas[1]!).toBe(false); }); it("builds UniConfig with only provided fields (thinking level stays out — it is per-request)", () => { const cfg = buildUniConfig({ modelId: "claude-sonnet-4-6", tools: [{ name: "t", description: "d" }], systemPrompt: "You are concise.", maxTokens: 256, thinkingLevel: "high", }); expect(cfg.system_prompt).toBe("You are concise."); expect(cfg.max_tokens).toBe(256); // The thinking level is applied per request (override ?? construction default), never // baked into the frozen config — see the request-config test below. expect("thinking_level" in cfg).toBe(false); expect(cfg.tools).toEqual([{ name: "t", description: "d" }]); const minimal = buildUniConfig({ modelId: "m", tools: [] }); expect("tools" in minimal).toBe(false); expect("system_prompt" in minimal).toBe(false); expect("max_tokens" in minimal).toBe(false); expect("thinking_level" in minimal).toBe(false); // max_tokens -1 (the config's "no cap" sentinel) stays OFF the wire — sent literally, // providers reject a negative max_tokens with a 400. const uncapped = buildUniConfig({ modelId: "m", tools: [], maxTokens: -1 }); expect("max_tokens" in uncapped).toBe(false); }); it("omits tools when empty and never sets tool_choice (strict endpoints reject both)", () => { // Empty tool list (connectivity probe, bare/meta LLM, vision describer): the `tools` key // must be absent, not `[]` — AgentHub forwards any defined array verbatim, and strict // OpenAI-compatible servers (e.g. vLLM) reject `tools: []` with a 400. const empty = buildUniConfig({ modelId: "m", tools: [] }); expect("tools" in empty).toBe(false); // `tool_choice` must never be set: AgentHub only emits it on the wire when UniConfig // defines it, and leaving it off preserves the protocol default. expect("tool_choice" in empty).toBe(false); const withTools = buildUniConfig({ modelId: "m", tools: [{ name: "t", description: "d" }] }); expect("tool_choice" in withTools).toBe(false); }); }); describe("isRetryableError", () => { it("treats 429, 408 and 5xx as retryable", () => { expect(isRetryableError({ status: 429 })).toBe(true); expect(isRetryableError({ status: 408 })).toBe(true); // Request Timeout (transient) expect(isRetryableError({ status: 500 })).toBe(true); expect(isRetryableError({ statusCode: 503 })).toBe(true); }); it("treats 4xx auth/param errors as non-retryable", () => { expect(isRetryableError({ status: 400 })).toBe(false); expect(isRetryableError({ status: 401 })).toBe(false); expect(isRetryableError({ status: 403 })).toBe(false); expect(isRetryableError({ status: 404 })).toBe(false); }); it("treats network error codes as retryable", () => { expect(isRetryableError({ code: "ECONNRESET" })).toBe(true); expect(isRetryableError({ code: "ETIMEDOUT" })).toBe(true); expect(isRetryableError(new Error("socket hang up"))).toBe(true); expect(isRetryableError(new Error("request timeout"))).toBe(true); }); it("treats undici transport failures as retryable, probing the cause chain", () => { // Node fetch wraps a dropped connection as TypeError("terminated") with the real code on // `cause` — exactly the field observed: "terminated: other side closed (UND_ERR_SOCKET)". expect( isRetryableError( new TypeError("terminated", { cause: Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" }), }), ), ).toBe(true); expect(isRetryableError({ code: "UND_ERR_SOCKET" })).toBe(true); expect(isRetryableError({ code: "UND_ERR_CONNECT_TIMEOUT" })).toBe(true); expect(isRetryableError({ code: "UND_ERR_HEADERS_TIMEOUT" })).toBe(true); expect(isRetryableError({ code: "UND_ERR_BODY_TIMEOUT" })).toBe(true); // A cause-less "fetch failed" still counts via the message keywords; "terminated" only // counts alongside transport vocabulary — the real socket case always carries the // UND_ERR_* code on its cause, and bare "terminated" appears in unrelated provider // copy too (see the content-filter counter-case below). expect(isRetryableError(new TypeError("fetch failed"))).toBe(true); expect(isRetryableError(new TypeError("terminated: other side closed"))).toBe(true); expect(isRetryableError(new TypeError("terminated"))).toBe(false); // Provider verdict copy that merely contains the word must NOT retry. expect(isRetryableError(new Error("Request terminated by content filter"))).toBe(false); // Classic Node codes wrapped one level down are found too. expect( isRetryableError( new Error("request failed", { cause: Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }), }), ), ).toBe(true); }); it("treats provider quota/subscription exhaustion as retryable (tight allowlist)", () => { // The field case: a gateway 403 with an OpenAI-compatible body code. expect(isRetryableError({ status: 403, code: "insufficient_user_quota" })).toBe(true); expect(isRetryableError({ status: 403, error: { code: "insufficient_user_quota" } })).toBe( true, ); // Anthropic SDK shape: `error` holds the whole response body. expect( isRetryableError({ status: 403, error: { error: { code: "insufficient_quota" } } }), ).toBe(true); expect(isRetryableError({ status: 402, code: "insufficient_quota" })).toBe(true); // Status-less quota code (some gateways surface only the provider code). expect(isRetryableError({ code: "insufficient_user_quota" })).toBe(true); // 403 with a quota/subscription message but no machine-readable code. expect(isRetryableError({ status: 403, message: "no active subscription" })).toBe(true); expect(isRetryableError({ status: 403, message: "订阅额度不足" })).toBe(true); // A plain 403/400/404 without quota signals stays non-retryable. expect(isRetryableError({ status: 403, message: "permission denied" })).toBe(false); expect(isRetryableError({ status: 400, code: "insufficient_user_quota" })).toBe(false); expect(isRetryableError({ status: 404 })).toBe(false); // 401 keeps its auth classification even with a quota-looking message. expect(isRetryableError({ status: 401, message: "quota" })).toBe(false); }); it("auth wins over the quota heuristic: a definitive credential signal is never retried", () => { // The adversarial case: a 403 whose BODY carries a definitive auth code but whose // MESSAGE mentions a subscription (SDKs routinely put the body's message on // err.message). The quota keyword fallback must not swallow it — auth is checked // first, so it classifies failed (dead credential) instead of burning every reconnect. const err = Object.assign(new Error("subscription key invalid"), { status: 403, error: { code: "invalid_api_key" }, }); expect(isAuthenticationError(err)).toBe(true); expect(isQuotaExhaustedError(err)).toBe(false); // belt: an auth signal is never quota expect(isRetryableError(err)).toBe(false); // never retried, never reclassified // Same with the auth signal on the cause chain. expect( isRetryableError( new Error("request failed: quota subscription issue", { cause: { status: 403, error: { type: "authentication_error" } }, }), ), ).toBe(false); }); it("does not retry abort or unknown local errors", () => { const abort = new Error("aborted"); abort.name = "AbortError"; expect(isRetryableError(abort)).toBe(false); expect(isRetryableError(new Error("unexpected token in JSON"))).toBe(false); expect(isRetryableError(null)).toBe(false); expect(isRetryableError(undefined)).toBe(false); }); }); describe("isQuotaExhaustedError", () => { it("matches only 402/403 (or status-less) errors carrying a known quota code", () => { expect(isQuotaExhaustedError({ status: 403, code: "insufficient_user_quota" })).toBe(true); expect(isQuotaExhaustedError({ status: 402, error: { code: "insufficient_quota" } })).toBe( true, ); expect(isQuotaExhaustedError({ code: "insufficient_quota" })).toBe(true); // The code may sit on the cause chain (wrapped by a higher layer). expect( isQuotaExhaustedError( new Error("request failed", { cause: { status: 403, code: "insufficient_user_quota" }, }), ), ).toBe(true); expect( isQuotaExhaustedError( Object.assign(new Error("request failed"), { status: 403, cause: { code: "insufficient_user_quota" }, }), ), ).toBe(true); // Any other status keeps its own classification. expect(isQuotaExhaustedError({ status: 429, code: "insufficient_quota" })).toBe(false); expect(isQuotaExhaustedError({ status: 401, code: "insufficient_quota" })).toBe(false); }); it("matches a 403 whose message names quota/subscription, but not a plain 403", () => { expect(isQuotaExhaustedError({ status: 403, message: "monthly quota exceeded" })).toBe(true); expect(isQuotaExhaustedError({ status: 403, message: "订阅额度不足" })).toBe(true); expect(isQuotaExhaustedError({ status: 403, message: "forbidden" })).toBe(false); // The message shortcut is 403-only: a status-less error needs the explicit code. expect(isQuotaExhaustedError({ message: "quota exceeded" })).toBe(false); expect(isQuotaExhaustedError(null)).toBe(false); }); }); describe("isAuthenticationError", () => { it("classifies HTTP 401 as auth, own or wrapped", () => { expect(isAuthenticationError({ status: 401 })).toBe(true); expect(isAuthenticationError({ statusCode: 401 })).toBe(true); expect(isAuthenticationError(new Error("request failed", { cause: { status: 401 } }))).toBe( true, ); }); it("classifies known auth codes/types and the SDK AuthenticationError class", () => { expect(isAuthenticationError({ code: "invalid_api_key" })).toBe(true); expect(isAuthenticationError({ status: 403, error: { type: "authentication_error" } })).toBe( true, ); // Anthropic SDK shape: `error` holds the whole response body. expect(isAuthenticationError({ error: { error: { code: "unauthorized" } } })).toBe(true); class AuthenticationError extends Error {} expect(isAuthenticationError(new AuthenticationError("bad key"))).toBe(true); expect( isAuthenticationError(new Error("request failed", { cause: { code: "invalid_api_key" } })), ).toBe(true); }); it("does not classify a bare 403 or unrelated failures as auth", () => { expect(isAuthenticationError({ status: 403 })).toBe(false); expect(isAuthenticationError({ status: 400, message: "invalid param" })).toBe(false); expect(isAuthenticationError({ status: 403, code: "insufficient_user_quota" })).toBe(false); expect(isAuthenticationError(new Error("socket hang up"))).toBe(false); expect(isAuthenticationError(null)).toBe(false); }); }); describe("isMalformedJsonParseError", () => { it("detects JSON.parse SyntaxError by exception type, including the cause chain", () => { // AgentHub uses JSON.parse internally; a parse failure throws a SyntaxError, so it can be // determined directly by exception type. expect( isMalformedJsonParseError(new SyntaxError("Unexpected token < in JSON at position 0")), ).toBe(true); // An error wrapped by a higher layer can still be determined via the cause chain. expect( isMalformedJsonParseError( new Error("request failed", { cause: new SyntaxError("Unexpected end of JSON input"), }), ), ).toBe(true); // A non-SyntaxError does not count as malformed (even if the message mentions JSON), // leaving classification to the network/failure path. expect(isMalformedJsonParseError(new Error("Unexpected token < in JSON at position 0"))).toBe( false, ); expect(isMalformedJsonParseError(new Error("socket hang up"))).toBe(false); }); it("detects AgentHub 0.4 parse/validation error classes (truncated tool args, thinking-only)", () => { // A stream truncated mid-arguments surfaces as ToolCallArgumentParseError since agenthub // 0.4 (previously a raw SyntaxError) — must stay malformed so the engine reconnects. expect( isMalformedJsonParseError( new ToolCallArgumentParseError({ client: "Claude5Client", toolName: "exec_command", toolCallId: "toolu_broken_1", rawArguments: '{"cmd": "ec', reason: "Unterminated string in JSON at position 11", }), ), ).toBe(true); // A completed thinking-only response cannot be replayed (400 on the next turn): retrying // via malformed gives the model another chance instead of failing the turn. expect( isMalformedJsonParseError( new EmptyResponseError({ client: "Claude5Client", finishReason: "stop" }), ), ).toBe(true); // Also detectable via the name fallback and the cause chain. expect( isMalformedJsonParseError( new Error("request failed", { cause: new EmptyResponseError({ client: "GPT5_5Client", finishReason: null }), }), ), ).toBe(true); }); }); describe("isIncompleteStreamError", () => { it("detects AgentHub incomplete-stream validation errors by message prefix, incl. cause chain", () => { // The server/proxy cleanly terminates the stream early at an event boundary: AgentHub's // final-event validation throws a plain Error. expect(isIncompleteStreamError(new Error("Streaming response yielded no events"))).toBe(true); expect( isIncompleteStreamError(new Error('Last event must carry usage_metadata, got: {"a":1}')), ).toBe(true); expect(isIncompleteStreamError(new Error("Last event must carry finish_reason, got: {}"))).toBe( true, ); expect( isIncompleteStreamError( new Error("request failed", { cause: new Error("Streaming response yielded no events") }), ), ).toBe(true); expect(isIncompleteStreamError(new Error("socket hang up"))).toBe(false); expect(isIncompleteStreamError(null)).toBe(false); }); }); describe("GenerativeModel per-request thinking level", () => { // Captures the UniConfig each request goes out with (the openStream seam now receives the // per-request resolved config): the effective level = params.thinkingLevel ?? the // construction default, mapped onto the wire enum; neither → the key stays off the wire. function capturingModel(defaultLevel?: ThinkingLevelName): { model: GenerativeModel; configs: (UniConfig | undefined)[]; } { const configs: (UniConfig | undefined)[] = []; class CapturingModel extends GenerativeModel { protected override openStream( _uni: UniMessage, _signal: AbortSignal, config?: UniConfig, ): AsyncIterable { configs.push(config); return (async function* () { yield ev({ content_items: [{ type: "text", text: "ok" }], finish_reason: "stop", usage_metadata: { cached_tokens: 0, prompt_tokens: 1, thoughts_tokens: 0, response_tokens: 1, }, }); })(); } } const model = new CapturingModel({ modelId: "claude-sonnet-4-6", tools: [], ...(defaultLevel !== undefined ? { thinkingLevel: defaultLevel } : {}), }); return { model, configs }; } async function drainAll(gen: AsyncGenerator): Promise { let res = await gen.next(); while (!res.done) res = await gen.next(); } it("applies the construction default when no override is given, per request", async () => { const { model, configs } = capturingModel("medium"); await drainAll(model.streamGenerate({ newMessages: [userText("hi")] })); expect(configs[0]?.thinking_level).toBe(ThinkingLevel.MEDIUM); }); it("a per-request override wins for that request only; the default returns afterwards", async () => { const { model, configs } = capturingModel("medium"); await drainAll(model.streamGenerate({ newMessages: [userText("a")], thinkingLevel: "high" })); await drainAll(model.streamGenerate({ newMessages: [userText("b")] })); expect(configs[0]?.thinking_level).toBe(ThinkingLevel.HIGH); expect(configs[1]?.thinking_level).toBe(ThinkingLevel.MEDIUM); }); it("no default and no override: thinking_level stays off the wire; an override still applies", async () => { const { model, configs } = capturingModel(); await drainAll(model.streamGenerate({ newMessages: [userText("a")] })); await drainAll(model.streamGenerate({ newMessages: [userText("b")], thinkingLevel: "xhigh" })); expect(configs[0] !== undefined && "thinking_level" in configs[0]).toBe(false); expect(configs[1]?.thinking_level).toBe(ThinkingLevel.XHIGH); }); }); describe("GenerativeModel.streamGenerate outcome classification (PRN-013)", () => { // Injects a controlled UniEvent stream through the protected openStream seam to verify the // outcome classification of timeout/network-drop/interrupt/error, without needing a real API. // Construction only creates the config object; no network involved. class SeamModel extends GenerativeModel { constructor( private readonly source: (signal: AbortSignal) => AsyncIterable, timeoutMs = 10000, ) { super({ modelId: "claude-sonnet-4-6", tools: [], requestTimeoutMs: timeoutMs }); } protected override openStream(_uni: UniMessage, signal: AbortSignal): AsyncIterable { return this.source(signal); } } const abortError = (): Error => Object.assign(new Error("aborted"), { name: "AbortError" }); /** * An upstream that IGNORES its AbortSignal: it never yields and never rejects, whatever * happens to the signal. This is the shape that wedged real Sessions (observed with Kimi): * the SDK's stream promise simply never settles, so `await it.next()` hangs forever and the * idle timer cannot rescue it either, because by then `ac` is already aborted and its * `ac.abort()` is a no-op. */ async function* deaf(): AsyncGenerator { await new Promise(() => {}); // never settles, never observes the signal } /** Fails the test loudly instead of letting a regression hang the whole suite. */ function withTimeout(p: Promise, ms: number): Promise { return Promise.race([ p, new Promise((_, reject) => setTimeout(() => reject(new Error(`streamGenerate did not settle within ${ms}ms`)), ms), ), ]); } // Never yields any event, and only ends with an AbortError once the signal aborts // (simulates idle/hanging). async function* hang(signal: AbortSignal): AsyncGenerator { await new Promise((_, reject) => { if (signal.aborted) { reject(abortError()); return; } signal.addEventListener("abort", () => reject(abortError()), { once: true }); }); } // Yields one piece of text, then throws a retryable network error (network drop). async function* dropAfterText(): AsyncGenerator { yield ev({ content_items: [{ type: "text", text: "hi" }] }); throw Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); } // Immediately throws a non-retryable error (auth). async function* authError(): AsyncGenerator { throw Object.assign(new Error("invalid api key"), { status: 401 }); } // AgentHub's response body is not valid JSON (e.g. the gateway returns HTML / a truncated response). async function* malformedJsonAfterText(): AsyncGenerator { yield ev({ content_items: [{ type: "text", text: "hi" }] }); throw new SyntaxError("Unexpected token < in JSON at position 0"); } const typeOf = (m: OmniMessage): string => (m.payload as { type?: string }).type ?? ""; async function drain( gen: AsyncGenerator, ): Promise<{ messages: OmniMessage[]; outcome: LLMOutcome }> { const messages: OmniMessage[] = []; let res = await gen.next(); while (!res.done) { messages.push(res.value); res = await gen.next(); } return { messages, outcome: res.value as LLMOutcome }; } it("returns failed on a build failure such as empty input (never throws)", async () => { const model = new SeamModel((sig) => hang(sig)); const { messages, outcome } = await drain(model.streamGenerate({ newMessages: [] })); expect(outcome.status).toBe("failed"); // A mergeOmniToUniMessage failure converges to failed, never throws expect(messages).toHaveLength(0); }); it("interrupt lands while the consumer is suspended at yield: finish immediately as aborted, never pull the already-aborted upstream again", async () => { // This is exactly the cause of "the session hangs forever after interrupting it in the // browser": when the user interrupts, this generator is usually suspended at `yield` // (the engine is blocked on `await approve(tc)` waiting for manual approval). onUserAbort // has already aborted the upstream stream; when the consumer comes to pull again, if we go // back and call `it.next()` on that now-dead stream, the promise will never settle again -- // and the idle timer cannot save it either (once it fires, it just aborts again, which is a // no-op on an already-aborted stream). The run then never finishes, and the Session is stuck // in running: it can neither send messages nor compact (the frontend's /compact is gated by // !running, so clicking it does nothing). // // The upstream simulates the real cancellation behavior with "pulling again after being // aborted never settles." If the fix is missing, this test hangs until it times out and fails. async function* deadAfterAbort(): AsyncGenerator { yield ev({ content_items: [{ type: "text", text: "hi" }] }); await new Promise(() => {}); // Never settles } const ac = new AbortController(); // Give the idle timeout plenty of headroom, so it's the "pre-interrupt check" doing the // finishing, not the timer as a fallback. const model = new SeamModel(() => deadAfterAbort(), 60_000); const gen = model.streamGenerate({ newMessages: [userText("go")], signal: ac.signal }); const first = await gen.next(); // Gets the first message -> this generator is now suspended at yield expect(first.done).toBe(false); ac.abort(); // User interrupt (we are suspended at yield right now, not inside it.next()) // The already-resolved buffered messages are drained as usual, and afterward it **must** // finish -- the key point is that it ends, rather than going back to pull that dead // upstream and hanging the whole run forever (if the fix is missing, this would never // get a result, and the test times out and fails). let res = await gen.next(); while (!res.done) res = await gen.next(); expect(res.value).toMatchObject({ status: "aborted" }); }); it("classifies an idle timeout as timeout, with no token_usage", async () => { const model = new SeamModel((sig) => hang(sig), 30); // 30ms idle timeout const { messages, outcome } = await drain( model.streamGenerate({ newMessages: [userText("go")] }), ); expect(outcome.status).toBe("timeout"); expect(messages.map(typeOf)).not.toContain("token_usage"); }); it("classifies an idle timeout as timeout even when the stream ends gracefully on abort", async () => { // The underlying implementation does not throw on abort, and ends gracefully with done: // this must still be classified as timeout, not mistakenly as completed. async function* gracefulHang(signal: AbortSignal): AsyncGenerator { await new Promise((resolve) => { if (signal.aborted) { resolve(); return; } signal.addEventListener("abort", () => resolve(), { once: true }); }); } const model = new SeamModel((sig) => gracefulHang(sig), 30); const { messages, outcome } = await drain( model.streamGenerate({ newMessages: [userText("go")] }), ); expect(outcome.status).toBe("timeout"); expect(messages.map(typeOf)).not.toContain("token_usage"); }); it("classifies a network drop as timeout, closing the open text segment, no token_usage", async () => { const model = new SeamModel(() => dropAfterText()); const { messages, outcome } = await drain( model.streamGenerate({ newMessages: [userText("go")] }), ); expect(outcome.status).toBe("timeout"); const complete = messages.find((m) => typeOf(m) === "text"); expect((complete!.payload as TextPayload).text).toBe("hi"); expect((complete!.payload as TextPayload).stop_reason).toBe("timeout"); expect(messages.map(typeOf)).not.toContain("token_usage"); }); it("classifies an AgentHub JSON parse exception as malformed and closes partial output", async () => { const model = new SeamModel(() => malformedJsonAfterText()); const { messages, outcome } = await drain( model.streamGenerate({ newMessages: [userText("go")] }), ); expect(outcome.status).toBe("malformed"); expect(outcome.message).toContain("Unexpected token"); const complete = messages.find((m) => typeOf(m) === "text"); expect((complete!.payload as TextPayload).text).toBe("hi"); expect((complete!.payload as TextPayload).stop_reason).toBe("malformed"); expect(messages.map(typeOf)).not.toContain("token_usage"); }); it("classifies a cleanly-truncated stream (AgentHub last-event validation) as malformed, not failed", async () => { // The server/proxy cleanly drops the stream at an event boundary (no network error thrown): // AgentHub's final-event validation throws a plain Error; this is an incomplete LLM // Request that must go through the malformed reconnect path, and must not abort the task as failed. async function* cleanTruncationAfterText(): AsyncGenerator { yield ev({ content_items: [{ type: "text", text: "hi" }] }); throw new Error('Last event must carry usage_metadata, got: {"content_items":[]}'); } const model = new SeamModel(() => cleanTruncationAfterText()); const { messages, outcome } = await drain( model.streamGenerate({ newMessages: [userText("go")] }), ); expect(outcome.status).toBe("malformed"); expect(messages.map(typeOf)).not.toContain("token_usage"); async function* noEvents(): AsyncGenerator { throw new Error("Streaming response yielded no events"); } const model2 = new SeamModel(() => noEvents()); const { outcome: outcome2 } = await drain( model2.streamGenerate({ newMessages: [userText("go")] }), ); expect(outcome2.status).toBe("malformed"); }); it("a user abort ends the run even when upstream ignores the signal and never settles", async () => { const model = new SeamModel(() => deaf()); const controller = new AbortController(); setTimeout(() => controller.abort(), 20); // the user presses Stop mid-request // Without the race this never settles and the Session stays "running" forever. const { outcome } = await withTimeout( drain(model.streamGenerate({ newMessages: [userText("go")], signal: controller.signal })), 2000, ); expect(outcome.status).toBe("aborted"); }); it("the idle timeout still ends the run when upstream ignores the signal", async () => { const model = new SeamModel(() => deaf(), 50); // 50ms idle budget const { outcome } = await withTimeout( drain(model.streamGenerate({ newMessages: [userText("go")] })), 2000, ); expect(outcome.status).toBe("timeout"); }); it("classifies a credentials failure as its own terminal status auth; params stay failed", async () => { const model = new SeamModel(() => authError()); const { messages, outcome } = await drain( model.streamGenerate({ newMessages: [userText("go")] }), ); // 401 = credentials failure: its own stop reason so hosts can gate input until the // model's API key is updated (same engine behavior as failed). expect(outcome.status).toBe("auth"); expect(outcome.message).toContain("invalid api key"); expect(messages.map(typeOf)).not.toContain("token_usage"); // A genuinely non-retryable parameter error stays a plain failure. async function* paramError(): AsyncGenerator { throw Object.assign(new Error("unknown parameter: max_output_tokens"), { status: 400 }); } const model2 = new SeamModel(() => paramError()); const { outcome: outcome2 } = await drain( model2.streamGenerate({ newMessages: [userText("go")] }), ); expect(outcome2.status).toBe("failed"); }); it("an auth error dressed in quota language still funnels to status auth", async () => { // The adversarial case from the review: 403 + body code invalid_api_key + a message // mentioning the subscription. Must NOT classify as retryable quota (timeout): the // ordering fix keeps the dead credential out of the reconnect loop entirely. async function* dressedAuthError(): AsyncGenerator { throw Object.assign(new Error("subscription key invalid"), { status: 403, error: { code: "invalid_api_key" }, }); } const model = new SeamModel(() => dressedAuthError()); const { messages, outcome } = await drain( model.streamGenerate({ newMessages: [userText("go")] }), ); expect(outcome.status).toBe("auth"); expect(outcome.message).toContain("subscription key invalid"); expect(messages.map(typeOf)).not.toContain("token_usage"); }); it("classifies provider quota exhaustion (403 + code) as timeout — the reconnect path", async () => { async function* quotaError(): AsyncGenerator { throw Object.assign(new Error("订阅额度不足 no active subscription"), { status: 403, code: "insufficient_user_quota", }); } const model = new SeamModel(() => quotaError()); const { messages, outcome } = await drain( model.streamGenerate({ newMessages: [userText("go")] }), ); expect(outcome.status).toBe("timeout"); // The real reason rides on the outcome (request_end -> the Cost center's errors panel // shows it): a retried quota rejection must not surface as a bare "timeout". expect(outcome.message).toContain("insufficient_user_quota"); expect(messages.map(typeOf)).not.toContain("token_usage"); }); it("classifies an undici transport drop (TypeError terminated, cause UND_ERR_SOCKET) as timeout", async () => { async function* socketDrop(): AsyncGenerator { yield ev({ content_items: [{ type: "text", text: "hi" }] }); throw new TypeError("terminated", { cause: Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" }), }); } const model = new SeamModel(() => socketDrop()); const { messages, outcome } = await drain( model.streamGenerate({ newMessages: [userText("go")] }), ); expect(outcome.status).toBe("timeout"); const complete = messages.find((m) => typeOf(m) === "text"); expect((complete!.payload as TextPayload).stop_reason).toBe("timeout"); expect(messages.map(typeOf)).not.toContain("token_usage"); }); it("classifies a user abort (mid idle) as aborted, not timeout", async () => { const controller = new AbortController(); const model = new SeamModel((sig) => hang(sig)); // Default 10s timeout, won't fire first const p = drain( model.streamGenerate({ newMessages: [userText("go")], signal: controller.signal, }), ); setTimeout(() => controller.abort(), 20); const { outcome } = await p; expect(outcome.status).toBe("aborted"); }); }); describe("provider fidelity payloads (opaque, AgentHub 0.4 semantics)", () => { const complete = (messages: ReturnType["messages"]) => messages.filter((m) => !(m.payload as { type: string }).type.startsWith("partial_")); it("captures the thinking fidelity arriving as an empty-text delta (Claude signature_delta)", () => { const { messages } = translateEvents([ ev({ content_items: [{ type: "thinking", thinking: "let me think" }] }), ev({ content_items: [{ type: "thinking", thinking: "", fidelity: { signature: "sig-abc" } }], }), ev({ content_items: [{ type: "text", text: "answer" }] }), ev({ event_type: "stop", content_items: [], finish_reason: "stop" }), ]); const thinking = complete(messages).find( (m) => (m.payload as { type: string }).type === "thinking", )!; const p = thinking.payload as { thinking: string; fidelity?: Record }; expect(p.thinking).toBe("let me think"); expect(p.fidelity).toEqual({ signature: "sig-abc" }); }); it("splits adjacent thinking blocks on differing fidelity (redacted + normal keep their own)", () => { const { messages } = translateEvents([ // A redacted block: sentinel text + fidelity arrive together (Claude content_block_start). ev({ content_items: [ { type: "thinking", thinking: "_REDACTED_THINKING", fidelity: { signature: "sig-red" } }, ], }), // The next, ordinary thinking block. ev({ content_items: [{ type: "thinking", thinking: "visible" }] }), ev({ content_items: [{ type: "thinking", thinking: "", fidelity: { signature: "sig-vis" } }], }), ev({ event_type: "stop", content_items: [], finish_reason: "stop" }), ]); const thinkings = complete(messages).filter( (m) => (m.payload as { type: string }).type === "thinking", ); expect( thinkings.map((m) => { const p = m.payload as { thinking: string; fidelity?: Record }; return [p.thinking, p.fidelity]; }), ).toEqual([ ["_REDACTED_THINKING", { signature: "sig-red" }], ["visible", { signature: "sig-vis" }], ]); }); it("keeps a run of equal fidelity as one thinking block (OpenAI-compatible reasoning_field per delta)", () => { const rf = { reasoning_field: "reasoning_content" }; const { messages } = translateEvents([ ev({ content_items: [{ type: "thinking", thinking: "step 1, ", fidelity: { ...rf } }] }), ev({ content_items: [{ type: "thinking", thinking: "step 2, ", fidelity: { ...rf } }] }), ev({ content_items: [{ type: "thinking", thinking: "done", fidelity: { ...rf } }] }), ev({ content_items: [{ type: "text", text: "answer" }] }), ev({ event_type: "stop", content_items: [], finish_reason: "stop" }), ]); const thinkings = complete(messages).filter( (m) => (m.payload as { type: string }).type === "thinking", ); expect( thinkings.map((m) => { const p = m.payload as { thinking: string; fidelity?: Record }; return [p.thinking, p.fidelity]; }), ).toEqual([["step 1, step 2, done", rf]]); }); it("emits an empty-text thinking with fidelity (GPT-5 encrypted reasoning) and splits on the next one", () => { const { messages } = translateEvents([ ev({ content_items: [ { type: "thinking", thinking: "", fidelity: { id: "rs_1", encrypted_content: "aaa" } }, ], }), ev({ content_items: [ { type: "thinking", thinking: "", fidelity: { id: "rs_2", encrypted_content: "bbb" } }, ], }), ev({ content_items: [{ type: "text", text: "answer" }] }), ev({ event_type: "stop", content_items: [], finish_reason: "stop" }), ]); const thinkings = complete(messages).filter( (m) => (m.payload as { type: string }).type === "thinking", ); expect( thinkings.map((m) => { const p = m.payload as { thinking: string; fidelity?: Record }; return [p.thinking, p.fidelity]; }), ).toEqual([ ["", { id: "rs_1", encrypted_content: "aaa" }], ["", { id: "rs_2", encrypted_content: "bbb" }], ]); }); it("splits text segments on fidelity.phase markers arriving as empty-text deltas (GPT-5)", () => { const { messages } = translateEvents([ ev({ content_items: [{ type: "text", text: "", fidelity: { phase: "planning" } }] }), ev({ content_items: [{ type: "text", text: "plan..." }] }), ev({ content_items: [{ type: "text", text: "", fidelity: { phase: "answer" } }] }), ev({ content_items: [{ type: "text", text: "final" }] }), ev({ event_type: "stop", content_items: [], finish_reason: "stop" }), ]); const texts = complete(messages).filter((m) => (m.payload as { type: string }).type === "text"); expect( texts.map((m) => { const p = m.payload as { text: string; fidelity?: Record }; return [p.text, p.fidelity]; }), ).toEqual([ ["plan...", { phase: "planning" }], ["final", { phase: "answer" }], ]); }); it("closes a text segment on fidelity.signature: later text becomes its own segment (the signature must not cover unsigned text)", () => { const { messages } = translateEvents([ // Gemini stamps a thoughtSignature on the text part it signed; whatever follows is // unsigned. Merging them would replay a signature covering text the provider never // signed, and the provider rejects the resumed turn. ev({ content_items: [{ type: "text", text: "part one", fidelity: { signature: "sigA" } }] }), ev({ content_items: [{ type: "text", text: "part two" }] }), ev({ event_type: "stop", content_items: [], finish_reason: "stop" }), ]); const texts = complete(messages).filter((m) => (m.payload as { type: string }).type === "text"); expect( texts.map((m) => { const p = m.payload as { text: string; fidelity?: Record }; return [p.text, p.fidelity]; }), ).toEqual([ ["part one", { signature: "sigA" }], ["part two", undefined], ]); }); it("accumulates fidelity keys across deltas of one text segment (phase marker + trailing signature)", () => { const { messages } = translateEvents([ // GPT-5 opens the segment with a bare phase marker and signs it only at the end; a // replacing (rather than merging) assignment would drop the phase and lose the // segmentation on replay. ev({ content_items: [{ type: "text", text: "", fidelity: { phase: "answer" } }] }), ev({ content_items: [{ type: "text", text: "final" }] }), ev({ content_items: [{ type: "text", text: "", fidelity: { signature: "sigB" } }] }), ev({ event_type: "stop", content_items: [], finish_reason: "stop" }), ]); const texts = complete(messages).filter((m) => (m.payload as { type: string }).type === "text"); expect( texts.map((m) => { const p = m.payload as { text: string; fidelity?: Record }; return [p.text, p.fidelity]; }), ).toEqual([["final", { phase: "answer", signature: "sigB" }]]); }); it("carries the tool_call fidelity through to the complete message", () => { const { messages } = translateEvents([ ev({ content_items: [ { type: "tool_call", name: "exec_command", arguments: { cmd: "ls" }, tool_call_id: "tc1", fidelity: { signature: "sig-tool" }, }, ], }), ev({ event_type: "stop", content_items: [], finish_reason: "tool_call" }), ]); const tc = complete(messages).find( (m) => (m.payload as { type: string }).type === "tool_call", )!; expect((tc.payload as { fidelity?: Record }).fidelity).toEqual({ signature: "sig-tool", }); }); it("round-trips fidelity payloads back to UniMessage content items (setHistory path)", () => { const uni = mergeOmniToUniMessage([ thinkingMessage("deep", "completed", { signature: "sig-1" }), assistantText("hi", "completed", { phase: "answer", signature: "sig-2" }), toolCall({ name: "t", arguments: "{}", toolCallId: "tc1", fidelity: { signature: "sig-3" } }), ]); expect(uni.content_items).toEqual([ { type: "thinking", thinking: "deep", fidelity: { signature: "sig-1" } }, { type: "text", text: "hi", fidelity: { phase: "answer", signature: "sig-2" } }, { type: "tool_call", name: "t", arguments: {}, tool_call_id: "tc1", fidelity: { signature: "sig-3" }, }, ]); }); }); describe("flushText fidelity parity (PR #39 review)", () => { it("emits an empty-text message carrying a text fidelity instead of dropping it", () => { const { messages } = translateEvents([ ev({ content_items: [{ type: "text", text: "", fidelity: { signature: "sig-t" } }] }), ev({ event_type: "stop", content_items: [], finish_reason: "stop" }), ]); const text = messages.find((m) => (m.payload as { type: string }).type === "text")!; expect((text.payload as { text: string }).text).toBe(""); expect((text.payload as { fidelity?: Record }).fidelity).toEqual({ signature: "sig-t", }); }); }); describe("tool_call_id uniquification (name-as-id providers, e.g. Gemini uses the function name as the id)", () => { const callIdsOf = (messages: OmniMessage[]): string[] => messages .filter((m) => (m.payload as { type: string }).type === "tool_call") .map((m) => (m.payload as ToolCallPayload).tool_call_id); it("the second complete tool_call with a duplicate id within one Request is not dropped and gets the #2 suffix", () => { const { messages } = translateEvents([ ev({ event_type: "stop", finish_reason: "tool_call", content_items: [ { type: "tool_call", name: "get_time", arguments: { city: "Tokyo" }, tool_call_id: "get_time", }, { type: "tool_call", name: "get_time", arguments: { city: "Paris" }, tool_call_id: "get_time", }, ], }), ]); expect(callIdsOf(messages)).toEqual(["get_time", "get_time#2"]); const calls = messages .filter((m) => (m.payload as { type: string }).type === "tool_call") .map((m) => m.payload as ToolCallPayload); expect(calls[0]!.arguments).toBe('{"city":"Tokyo"}'); expect(calls[1]!.arguments).toBe('{"city":"Paris"}'); expect(calls.every((c) => c.stop_reason === "completed")).toBe(true); }); it("parallel calls with distinct ids are unaffected (passed through as-is, no suffix)", () => { const { messages } = translateEvents([ ev({ event_type: "stop", finish_reason: "tool_call", content_items: [ { type: "tool_call", name: "get_time", arguments: {}, tool_call_id: "get_time" }, { type: "tool_call", name: "get_weather", arguments: {}, tool_call_id: "get_weather" }, { type: "tool_call", name: "get_time", arguments: {}, tool_call_id: "get_time" }, ], }), ]); expect(callIdsOf(messages)).toEqual(["get_time", "get_weather", "get_time#2"]); }); it("the registry is shared across Requests: a same-name call in the next round gets a new suffix (frontend tool cards no longer overwrite each other)", () => { const ids = new ToolCallIdAllocator(); const round = (city: string): string[] => { const translator = new EventTranslator(ids); const out: OmniMessage[] = []; const event = ev({ event_type: "stop", finish_reason: "tool_call", content_items: [ { type: "tool_call", name: "get_time", arguments: { city }, tool_call_id: "get_time" }, ], }); for (const m of translator.pushEvent(event)) out.push(m); for (const m of translator.finish()) out.push(m); return callIdsOf(out); }; expect(round("Tokyo")).toEqual(["get_time"]); expect(round("Paris")).toEqual(["get_time#2"]); expect(round("NYC")).toEqual(["get_time#3"]); }); it("on a cross-Request collision, partial fragments and the complete message use the same suffixed id", () => { const ids = new ToolCallIdAllocator(); ids.markUsed("exec"); // this provider id was already taken in the previous turn const translator = new EventTranslator(ids); const out: OmniMessage[] = []; const push = (e: UniEvent): void => { for (const m of translator.pushEvent(e)) out.push(m); }; push( ev({ event_type: "start", content_items: [ { type: "partial_tool_call", name: "exec", arguments: '{"cmd":', tool_call_id: "exec" }, ], }), ); push( ev({ content_items: [ { type: "partial_tool_call", name: "", arguments: '"ls"}', tool_call_id: "exec" }, ], }), ); push( ev({ event_type: "stop", finish_reason: "tool_call", content_items: [ { type: "tool_call", name: "exec", arguments: { cmd: "ls" }, tool_call_id: "exec" }, ], }), ); for (const m of translator.finish()) out.push(m); const partialIds = out .filter((m) => (m.payload as { type: string }).type === "partial_tool_call") .map((m) => (m.payload as { tool_call_id: string }).tool_call_id); expect(partialIds.length).toBeGreaterThanOrEqual(3); // start + delta×2 + stop expect(partialIds.every((id) => id === "exec#2")).toBe(true); expect(callIdsOf(out)).toEqual(["exec#2"]); }); it("outbound restoration: the #n suffix on tool_call / tool_call_output is stripped before sending to the provider; unsuffixed ids pass as-is", () => { const result = mergeOmniToUniMessage([ toolCallOutput({ output: "10:00", toolCallId: "get_time#2" }), ]); expect((result.content_items[0] as { tool_call_id: string }).tool_call_id).toBe("get_time"); const call = mergeOmniToUniMessage([ toolCall({ name: "get_time", arguments: "{}", toolCallId: "get_time#3" }), ]); expect((call.content_items[0] as { tool_call_id: string }).tool_call_id).toBe("get_time"); const passthrough = mergeOmniToUniMessage([ toolCallOutput({ output: "ok", toolCallId: "call_Ab12" }), ]); expect((passthrough.content_items[0] as { tool_call_id: string }).tool_call_id).toBe( "call_Ab12", ); }); it("stripToolCallIdSuffix only strips a trailing #digits (idempotent, never touches an infix)", () => { expect(stripToolCallIdSuffix("get_time#2")).toBe("get_time"); expect(stripToolCallIdSuffix("get_time#12")).toBe("get_time"); expect(stripToolCallIdSuffix("get_time")).toBe("get_time"); expect(stripToolCallIdSuffix("a#2b")).toBe("a#2b"); expect(stripToolCallIdSuffix("a#x")).toBe("a#x"); }); it("ToolCallIdAllocator: probing skips occupied suffixes; markUsed seeding takes effect", () => { const ids = new ToolCallIdAllocator(); ids.markUsed("t"); ids.markUsed("t#2"); expect(ids.allocate("t")).toBe("t#3"); expect(ids.allocate("u")).toBe("u"); expect(ids.allocate("u")).toBe("u#2"); }); it("resume seeding: after setHistory, new same-name calls do not collide with historical ids", async () => { class SeedModel extends GenerativeModel { constructor() { super({ modelId: "claude-sonnet-4-6", tools: [] }); } protected override openStream( _uni: UniMessage, _signal: AbortSignal, ): AsyncIterable { return (async function* () { yield ev({ event_type: "stop", finish_reason: "tool_call", content_items: [ { type: "tool_call", name: "get_time", arguments: { city: "Paris" }, tool_call_id: "get_time", }, ], }); })(); } } const model = new SeedModel(); model.setHistory([ userText("What time is it in Tokyo?"), toolCall({ name: "get_time", arguments: '{"city":"Tokyo"}', toolCallId: "get_time" }), toolCallOutput({ output: "10:00", toolCallId: "get_time" }), ]); const out: OmniMessage[] = []; const gen = model.streamGenerate({ newMessages: [userText("And Paris?")] }); let res = await gen.next(); while (!res.done) { out.push(res.value); res = await gen.next(); } expect(callIdsOf(out)).toEqual(["get_time#2"]); }); });