5b22ad06da
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2128 lines
87 KiB
TypeScript
2128 lines
87 KiB
TypeScript
/**
|
||
* 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<UniEvent> & Pick<UniEvent, "content_items">): 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<UniEvent> {
|
||
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<OmniMessage, LLMOutcome | void>): Promise<void> {
|
||
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<UniEvent>,
|
||
timeoutMs = 10000,
|
||
) {
|
||
super({ modelId: "claude-sonnet-4-6", tools: [], requestTimeoutMs: timeoutMs });
|
||
}
|
||
protected override openStream(_uni: UniMessage, signal: AbortSignal): AsyncIterable<UniEvent> {
|
||
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<UniEvent> {
|
||
await new Promise(() => {}); // never settles, never observes the signal
|
||
}
|
||
|
||
/** Fails the test loudly instead of letting a regression hang the whole suite. */
|
||
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
||
return Promise.race([
|
||
p,
|
||
new Promise<never>((_, 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<UniEvent> {
|
||
await new Promise<void>((_, 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<UniEvent> {
|
||
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<UniEvent> {
|
||
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<UniEvent> {
|
||
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<OmniMessage, LLMOutcome | void>,
|
||
): 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<UniEvent> {
|
||
yield ev({ content_items: [{ type: "text", text: "hi" }] });
|
||
await new Promise<never>(() => {}); // 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<UniEvent> {
|
||
await new Promise<void>((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<UniEvent> {
|
||
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<UniEvent> {
|
||
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<UniEvent> {
|
||
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<UniEvent> {
|
||
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<UniEvent> {
|
||
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<UniEvent> {
|
||
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<typeof translateEvents>["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<string, unknown> };
|
||
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<string, unknown> };
|
||
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<string, unknown> };
|
||
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<string, unknown> };
|
||
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<string, unknown> };
|
||
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<string, unknown> };
|
||
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<string, unknown> };
|
||
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<string, unknown> }).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<string, unknown> }).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<UniEvent> {
|
||
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"]);
|
||
});
|
||
});
|