Nested combos (comboA lists comboB, comboC, …) now stay one slot each: the inner combo always runs as fallback to produce a single answer. Failed hops are no longer written to Details/usage, and streaming no longer inserts a 0-token placeholder row. - chat.js: comboStack cycle detection; nested combos forced to fallback; persistUsage="success-only" for combo hops - combo.js: discardResponse() cancels unused bodies (fusion timeout / fallback) so dropped streams fire onStreamComplete; getComboModelsFromData keeps nested names and honors enabled=false - requestDetail.js: tokensForDetail() canonicalizes Claude/Gemini usage; shouldPersistRequestDetail() skips streaming-start and non-success hops - streamingHandler.js: drop the 0-token streaming placeholder write - RequestDetailsTab.js: read Gemini/Claude token names; show "streaming" status in amber - tests: add combo-nested.test.js (13 cases) - gitignore: ignore local .vitest/ artifacts Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
129 lines
4.7 KiB
JavaScript
129 lines
4.7 KiB
JavaScript
import { describe, it, expect, vi } from "vitest";
|
|
|
|
import { getComboModelsFromData, discardResponse, handleFusionChat } from "../../open-sse/services/combo.js";
|
|
import { tokensForDetail, shouldPersistRequestDetail } from "../../open-sse/handlers/chatCore/requestDetail.js";
|
|
|
|
vi.mock("@/lib/usageDb.js", () => ({
|
|
saveRequestUsage: vi.fn(),
|
|
saveRequestDetail: vi.fn(),
|
|
}));
|
|
vi.mock("../../open-sse/utils/stream.js", () => ({
|
|
COLORS: {},
|
|
formatSSE: vi.fn(),
|
|
}));
|
|
|
|
const log = { info: () => {}, warn: () => {}, debug: () => {} };
|
|
|
|
describe("nested combo members stay units", () => {
|
|
it("keeps comboB as a member of comboA (does not flatten leaves into the parent)", () => {
|
|
const combos = [
|
|
{ name: "comboA", models: ["comboB", "comboC", "x/y"] },
|
|
{ name: "comboB", models: ["a/m1", "b/m2"] },
|
|
{ name: "comboC", models: ["c/m3"] },
|
|
];
|
|
expect(getComboModelsFromData("comboA", combos)).toEqual(["comboB", "comboC", "x/y"]);
|
|
});
|
|
|
|
it("returns null for a plain provider/model string", () => {
|
|
expect(getComboModelsFromData("openai/gpt-4", [{ name: "comboA", models: ["a/m1"] }])).toBeNull();
|
|
});
|
|
|
|
it("still returns direct members of a top-level combo", () => {
|
|
const combos = [{ name: "solo", models: ["a/m1", "b/m2"] }];
|
|
expect(getComboModelsFromData("solo", combos)).toEqual(["a/m1", "b/m2"]);
|
|
});
|
|
|
|
it("ignores a disabled combo", () => {
|
|
const combos = [{ name: "solo", enabled: false, models: ["a/m1"] }];
|
|
expect(getComboModelsFromData("solo", combos)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("shouldPersistRequestDetail", () => {
|
|
it("never persists the streaming-start placeholder (0-token fake success)", () => {
|
|
expect(shouldPersistRequestDetail("all", "streaming-start")).toBe(false);
|
|
expect(shouldPersistRequestDetail("success-only", "streaming-start")).toBe(false);
|
|
});
|
|
|
|
it("combo hops skip failed attempts so nested fallback does not inflate Details", () => {
|
|
expect(shouldPersistRequestDetail("success-only", "error")).toBe(false);
|
|
expect(shouldPersistRequestDetail("success-only", "success")).toBe(true);
|
|
});
|
|
|
|
it("direct (non-combo) requests still persist errors", () => {
|
|
expect(shouldPersistRequestDetail("all", "error")).toBe(true);
|
|
expect(shouldPersistRequestDetail("all", "success")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("tokensForDetail", () => {
|
|
it("canonicalizes Claude input_tokens/output_tokens so the Details tab is not 0/0", () => {
|
|
expect(tokensForDetail({ input_tokens: 120, output_tokens: 40 })).toEqual(
|
|
expect.objectContaining({ prompt_tokens: 120, completion_tokens: 40 }),
|
|
);
|
|
});
|
|
|
|
it("keeps OpenAI prompt/completion names", () => {
|
|
expect(tokensForDetail({ prompt_tokens: 10, completion_tokens: 3 })).toEqual(
|
|
expect.objectContaining({ prompt_tokens: 10, completion_tokens: 3 }),
|
|
);
|
|
});
|
|
|
|
it("returns zeros for missing usage (streaming placeholder)", () => {
|
|
expect(tokensForDetail(null)).toEqual({ prompt_tokens: 0, completion_tokens: 0 });
|
|
});
|
|
});
|
|
|
|
describe("discardResponse", () => {
|
|
it("cancels an unused body so a dropped stream can finish", () => {
|
|
const cancel = vi.fn();
|
|
discardResponse({ body: { cancel } });
|
|
expect(cancel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("is a no-op for timeout/error sentinels and missing bodies", () => {
|
|
expect(() => discardResponse({ __timeout: true, body: { cancel: () => { throw new Error("no"); } } })).not.toThrow();
|
|
expect(() => discardResponse(null)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("fusion cancels unused panel bodies", () => {
|
|
it("cancels a late panel Response so it is not left as a 0-token streaming row", async () => {
|
|
const cancel = vi.fn();
|
|
const ok = (content) => {
|
|
const json = { choices: [{ message: { role: "assistant", content } }] };
|
|
const make = () => ({ ok: true, status: 200, clone: make, json: async () => json });
|
|
return make();
|
|
};
|
|
const slow = () => new Promise((resolve) => {
|
|
setTimeout(() => {
|
|
resolve({
|
|
ok: true,
|
|
status: 200,
|
|
body: { cancel },
|
|
clone() { return this; },
|
|
json: async () => ({ choices: [{ message: { content: "late" } }] }),
|
|
});
|
|
}, 40);
|
|
});
|
|
|
|
const handleSingleModel = vi.fn(async (_body, model) => {
|
|
if (model === "p/slow") return slow();
|
|
if (model === "p/judge") return ok("FINAL");
|
|
return ok(`ans-${model}`);
|
|
});
|
|
|
|
await handleFusionChat({
|
|
body: { messages: [{ role: "user", content: "Q" }] },
|
|
models: ["p/a", "p/b", "p/slow"],
|
|
handleSingleModel,
|
|
log,
|
|
judgeModel: "p/judge",
|
|
tuning: { minPanel: 2, stragglerGraceMs: 1, panelHardTimeoutMs: 200 },
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
expect(cancel).toHaveBeenCalled();
|
|
});
|
|
});
|