Files
2026-07-13 13:36:53 +08:00

303 lines
9.3 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import {
processCustomEvent,
processSseData,
parseSseBlock,
} from "../src/main/sse-parser";
// ─── parseSseBlock ──────────────────────────────────────
describe("parseSseBlock", () => {
it("parses a standard data-only SSE block", () => {
const result = parseSseBlock('data: {"choices":[]}');
expect(result).toEqual({ eventType: "", data: '{"choices":[]}' });
});
it("parses an SSE block with event + data", () => {
const block = 'event: hermes.tool.progress\ndata: {"tool":"search"}';
const result = parseSseBlock(block);
expect(result).toEqual({
eventType: "hermes.tool.progress",
data: '{"tool":"search"}',
});
});
it("returns null when no data line present", () => {
expect(parseSseBlock("event: something")).toBeNull();
expect(parseSseBlock("")).toBeNull();
expect(parseSseBlock(": comment")).toBeNull();
});
it("handles [DONE] data", () => {
const result = parseSseBlock("data: [DONE]");
expect(result).toEqual({ eventType: "", data: "[DONE]" });
});
it("handles extra whitespace in event type", () => {
const result = parseSseBlock("event: hermes.tool.progress \ndata: {}");
expect(result).toEqual({
eventType: "hermes.tool.progress",
data: "{}",
});
});
});
// ─── processCustomEvent ─────────────────────────────────
describe("processCustomEvent", () => {
it("handles hermes.tool.progress with emoji and label", () => {
const onToolProgress = vi.fn();
const onToolEvent = vi.fn();
const handled = processCustomEvent(
"hermes.tool.progress",
JSON.stringify({
tool: "search_web",
toolCallId: "call-1",
emoji: "🔍",
label: "Searching",
status: "running",
}),
{ onToolProgress, onToolEvent },
);
expect(handled).toBe(true);
expect(onToolProgress).not.toHaveBeenCalled();
expect(onToolEvent).toHaveBeenCalledWith({
callId: "call-1",
hasStableCallId: true,
name: "search_web",
status: "running",
label: "Searching",
emoji: "🔍",
});
});
it("handles completed hermes.tool.progress as a structured tool event", () => {
const onToolEvent = vi.fn();
const handled = processCustomEvent(
"hermes.tool.progress",
JSON.stringify({
tool: "terminal",
toolCallId: "call-terminal",
status: "completed",
}),
{ onToolEvent },
);
expect(handled).toBe(true);
expect(onToolEvent).toHaveBeenCalledWith({
callId: "call-terminal",
hasStableCallId: true,
name: "terminal",
status: "completed",
label: "terminal",
});
});
it("marks fallback tool ids as synthetic when the gateway omits call ids", () => {
const onToolEvent = vi.fn();
processCustomEvent(
"hermes.tool.progress",
JSON.stringify({
tool: "terminal",
status: "running",
label: "terminal",
}),
{ onToolEvent },
);
expect(onToolEvent).toHaveBeenCalledWith({
callId: "terminal:terminal",
hasStableCallId: false,
name: "terminal",
status: "running",
label: "terminal",
});
});
it("uses tool name as fallback when label is missing", () => {
const onToolProgress = vi.fn();
processCustomEvent(
"hermes.tool.progress",
JSON.stringify({ tool: "read_file", emoji: "📄" }),
{ onToolProgress },
);
expect(onToolProgress).toHaveBeenCalledWith("📄 read_file");
});
it("handles missing emoji gracefully", () => {
const onToolProgress = vi.fn();
processCustomEvent(
"hermes.tool.progress",
JSON.stringify({ tool: "terminal", label: "Running command" }),
{ onToolProgress },
);
expect(onToolProgress).toHaveBeenCalledWith("Running command");
});
it("ignores unknown event types", () => {
const onToolProgress = vi.fn();
const handled = processCustomEvent("unknown.event", "{}", {
onToolProgress,
});
expect(handled).toBe(false);
expect(onToolProgress).not.toHaveBeenCalled();
});
it("ignores malformed JSON data", () => {
const onToolProgress = vi.fn();
const handled = processCustomEvent("hermes.tool.progress", "not-json", {
onToolProgress,
});
expect(handled).toBe(false);
expect(onToolProgress).not.toHaveBeenCalled();
});
it("does nothing when onToolProgress callback is absent", () => {
const handled = processCustomEvent(
"hermes.tool.progress",
JSON.stringify({ tool: "x" }),
{},
);
expect(handled).toBe(false);
});
});
// ─── processSseData ─────────────────────────────────────
describe("processSseData", () => {
function makeState(): { hasContent: boolean; lastError: string } {
return { hasContent: false, lastError: "" };
}
it("signals done on [DONE] with content", () => {
const onDone = vi.fn();
const state = { hasContent: true, lastError: "" };
const result = processSseData(
"[DONE]",
{ onChunk: vi.fn(), onDone },
state,
);
expect(result.done).toBe(true);
expect(onDone).toHaveBeenCalled();
});
it("signals done on [DONE] without content (returns lastError)", () => {
const state = { hasContent: false, lastError: "some error" };
const result = processSseData("[DONE]", { onChunk: vi.fn() }, state);
expect(result.done).toBe(true);
expect(result.error).toBe("some error");
});
it("extracts content from delta and calls onChunk", () => {
const onChunk = vi.fn();
const state = makeState();
const data = JSON.stringify({
choices: [{ delta: { content: "Hello world" } }],
});
const result = processSseData(data, { onChunk }, state);
expect(result.done).toBe(false);
expect(result.hasContent).toBe(true);
expect(onChunk).toHaveBeenCalledWith("Hello world");
});
it("extracts usage data including cost and rate limits", () => {
const onUsage = vi.fn();
const onChunk = vi.fn();
const state = makeState();
const data = JSON.stringify({
choices: [{ delta: {} }],
usage: {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
cost: 0.0023,
rate_limit_remaining: 42,
rate_limit_reset: 1700000000,
},
});
processSseData(data, { onChunk, onUsage }, state);
expect(onUsage).toHaveBeenCalledWith({
promptTokens: 100,
completionTokens: 50,
totalTokens: 150,
cost: 0.0023,
rateLimitRemaining: 42,
rateLimitReset: 1700000000,
});
});
it("handles usage without optional fields (cost undefined)", () => {
const onUsage = vi.fn();
const state = makeState();
const data = JSON.stringify({
choices: [{ delta: {} }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
});
processSseData(data, { onChunk: vi.fn(), onUsage }, state);
expect(onUsage).toHaveBeenCalledWith({
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
cost: undefined,
rateLimitRemaining: undefined,
rateLimitReset: undefined,
});
});
it("captures SSE error messages", () => {
const state = makeState();
const data = JSON.stringify({
error: { message: "Rate limit exceeded" },
});
processSseData(data, { onChunk: vi.fn() }, state);
expect(state.lastError).toBe("Rate limit exceeded");
});
it("detects legacy inline tool progress pattern", () => {
const onToolProgress = vi.fn();
const onChunk = vi.fn();
const state = makeState();
const data = JSON.stringify({
choices: [{ delta: { content: "`🔍 search_web`" } }],
});
processSseData(data, { onChunk, onToolProgress }, state);
expect(onToolProgress).toHaveBeenCalledWith("🔍 search_web");
// Should NOT call onChunk for tool progress
expect(onChunk).not.toHaveBeenCalled();
expect(state.hasContent).toBe(false);
});
it("passes normal content through even if it contains backticks", () => {
const onChunk = vi.fn();
const state = makeState();
const data = JSON.stringify({
choices: [{ delta: { content: "Use `npm install` to install." } }],
});
processSseData(data, { onChunk }, state);
expect(onChunk).toHaveBeenCalledWith("Use `npm install` to install.");
});
it("gracefully handles malformed JSON", () => {
const onChunk = vi.fn();
const state = makeState();
const result = processSseData("not-json{", { onChunk }, state);
expect(result.done).toBe(false);
expect(onChunk).not.toHaveBeenCalled();
});
it("handles empty delta (no content field)", () => {
const onChunk = vi.fn();
const state = makeState();
const data = JSON.stringify({ choices: [{ delta: {} }] });
processSseData(data, { onChunk }, state);
expect(onChunk).not.toHaveBeenCalled();
expect(state.hasContent).toBe(false);
});
it("handles missing choices array", () => {
const onChunk = vi.fn();
const state = makeState();
const data = JSON.stringify({ id: "chatcmpl-123" });
processSseData(data, { onChunk }, state);
expect(onChunk).not.toHaveBeenCalled();
});
});