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>
This commit is contained in:
128
tests/unit/combo-nested.test.js
Normal file
128
tests/unit/combo-nested.test.js
Normal file
@@ -0,0 +1,128 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user