Files
9router/tests/unit/combo-nested.test.js
luulam 81a47f36c6 fix(combo): keep nested combos as single units and stop 0-token detail rows
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>
2026-09-17 14:03:59 +07:00

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();
});
});