Files
2026-07-13 13:39:12 +08:00

430 lines
16 KiB
TypeScript

/**
* Tests for task-aware combo routing (port of upstream PR #2045).
*
* Coverage:
* - classifyTask: light / standard / heavy / critical levels
* - reorderByTaskWeight: floats higher-power models for heavy tasks
* - getConversationCacheKey: stable per conversation, distinct per thread
* - getOrSetConversationAffinityIndex: affinity pin + TTL behaviour
* - isTaskRoutingStrategy: gate for task-aware strategies
* - Guard: non-task-aware strategies are unaffected (identity path)
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import {
classifyTask,
getConversationCacheKey,
getOrSetConversationAffinityIndex,
isTaskRoutingStrategy,
reorderByTaskWeight,
scoreModelForTask,
TASK_LEVEL_WEIGHT,
comboConversationAffinity,
clearConversationAffinity,
} from "../../open-sse/services/taskAwareRouting.ts";
import type { ResolvedComboTarget } from "../../open-sse/services/combo/types.ts";
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeTarget(modelStr: string, idx = 0): ResolvedComboTarget {
const [provider = "", model = modelStr] = modelStr.includes("/")
? modelStr.split("/")
: ["", modelStr];
return {
kind: "model" as const,
stepId: `step-${idx}`,
executionKey: `exec-${idx}`,
modelStr,
provider,
providerId: null,
connectionId: null,
weight: 1,
label: null,
};
}
function makeBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return { messages: [{ role: "user", content: "Hello" }], ...overrides };
}
// ── classifyTask ──────────────────────────────────────────────────────────────
describe("classifyTask", () => {
it("classifies empty body as light (all signals tiny → default-light path)", () => {
const t = classifyTask({});
// Empty body: 0 chars, 0 messages, 0 tools, 0 output tokens → light
assert.equal(t.level, "light");
assert.equal(t.weight, TASK_LEVEL_WEIGHT.light);
});
it("classifies a simple short greeting as light", () => {
const t = classifyTask({ messages: [{ role: "user", content: "hi there" }], max_tokens: 100 });
assert.equal(t.level, "light");
assert.equal(t.weight, TASK_LEVEL_WEIGHT.light);
});
it("classifies a translate request as light", () => {
const t = classifyTask({
messages: [{ role: "user", content: "translate this short sentence" }],
max_tokens: 256,
});
assert.equal(t.level, "light");
});
it("classifies a quick rewrite request as light", () => {
const t = classifyTask({
messages: [{ role: "user", content: "quick rewrite this sentence to be more concise" }],
max_tokens: 300,
});
assert.equal(t.level, "light");
});
it("classifies long conversation + big prompt as heavy (two signals)", () => {
// 20 messages (1 signal) + prompt >= 24000 chars (1 signal) = 2 → heavy
const bigContent = "context ".repeat(3500); // ~28000 chars
const messages = [
{ role: "user", content: bigContent },
...Array.from({ length: 20 }, (_, i) => ({
role: i % 2 === 0 ? "assistant" : "user",
content: "turn " + i,
})),
];
const t = classifyTask({ messages });
assert.equal(t.level, "heavy");
assert.equal(t.weight, TASK_LEVEL_WEIGHT.heavy);
});
it("classifies high reasoning effort as heavy", () => {
const t = classifyTask({
messages: [{ role: "user", content: "analyze this code" }],
reasoning_effort: "high",
});
assert.equal(t.level, "heavy");
});
it("classifies huge context (>= 100k chars) as critical", () => {
const t = classifyTask({
messages: [{ role: "user", content: "x".repeat(120_000) }],
});
assert.equal(t.level, "critical");
assert.equal(t.weight, TASK_LEVEL_WEIGHT.critical);
});
it("classifies huge output request (>= 32768 tokens) as critical", () => {
const t = classifyTask({
messages: [{ role: "user", content: "write a comprehensive report" }],
max_tokens: 40_000,
});
assert.equal(t.level, "critical");
});
it("classifies security+tools+effort as critical", () => {
const t = classifyTask({
messages: [
{
role: "user",
content: `Find a critical bug bounty attack chain for RCE or supply chain impact.\n${"context ".repeat(2000)}`,
},
],
tools: [{ name: "read" }, { name: "grep" }, { name: "web" }, { name: "bash" }],
reasoning_effort: "high",
max_tokens: 12_000,
});
assert.equal(t.level, "critical");
});
it("classifies moderate prompt without signals as standard (must exceed light thresholds)", () => {
// To be classified standard (not light), prompt must either: be > 4000 chars,
// have > 3 messages, tools, or not match a light keyword — "explain briefly" would be light.
// A prompt with "analyze" keyword + 5000 chars crosses into standard.
const t = classifyTask({
messages: [
{
role: "user",
content: "Please analyze how TCP works in detail.\n" + "context ".repeat(500),
},
{ role: "assistant", content: "TCP uses..." },
{ role: "user", content: "And what about UDP?" },
{ role: "assistant", content: "UDP is..." },
],
});
assert.equal(t.level, "standard");
});
it("returns reasons array for each classification", () => {
const t = classifyTask({
messages: [{ role: "user", content: "x".repeat(120_000) }],
});
assert.ok(Array.isArray(t.reasons), "reasons should be an array");
assert.ok(t.reasons.length > 0, "critical task should have at least one reason");
});
});
// ── reorderByTaskWeight ───────────────────────────────────────────────────────
describe("reorderByTaskWeight", () => {
it("returns same reference for single-target list", () => {
const targets = [makeTarget("anthropic/claude-haiku-4.5", 0)];
const task = classifyTask({ messages: [{ role: "user", content: "hello" }] });
const out = reorderByTaskWeight(targets, task, new Set());
assert.strictEqual(out, targets);
});
it("returns same reference for empty list", () => {
const targets: ResolvedComboTarget[] = [];
const task = classifyTask({});
const out = reorderByTaskWeight(targets, task, new Set());
assert.strictEqual(out, targets);
});
it("routes light tasks to lighter models (haiku before opus)", () => {
const targets = [
makeTarget("anthropic/claude-opus-4.6", 0),
makeTarget("anthropic/claude-haiku-4.5", 1),
];
const task = classifyTask({
messages: [{ role: "user", content: "quick rewrite this sentence" }],
max_tokens: 300,
});
const out = reorderByTaskWeight(targets, task, new Set());
assert.equal(out[0].modelStr, "anthropic/claude-haiku-4.5");
});
it("routes critical tasks to stronger models (opus before haiku)", () => {
const targets = [
makeTarget("anthropic/claude-haiku-4.5", 0),
makeTarget("anthropic/claude-opus-4.6", 1),
];
const task = classifyTask({
messages: [
{
role: "user",
content: `Security review for account takeover and cross-tenant RCE impact.\n${"code ".repeat(3000)}`,
},
],
tools: [{ name: "read" }, { name: "grep" }, { name: "bash" }, { name: "web" }],
reasoning_effort: "high",
});
const required = new Set(["reasoning"]);
const out = reorderByTaskWeight(targets, task, required);
assert.equal(out[0].modelStr, "anthropic/claude-opus-4.6");
assert.ok(
scoreModelForTask(out[0].modelStr, task, required) >=
scoreModelForTask(out[1].modelStr, task, required),
"first model should score >= second"
);
});
it("keeps hard-cap vision model first even for light tasks", () => {
// anthropic/claude-haiku-4.5 has vision; deepseek-chat does not
const targets = [
makeTarget("deepseek/deepseek-chat", 0),
makeTarget("anthropic/claude-haiku-4.5", 1),
];
const task = classifyTask({ messages: [{ role: "user", content: "what is in this image?" }] });
const out = reorderByTaskWeight(targets, task, new Set(["vision"]));
assert.equal(out[0].modelStr, "anthropic/claude-haiku-4.5");
});
it("never drops any targets", () => {
const targets = [
makeTarget("anthropic/claude-opus-4.6", 0),
makeTarget("anthropic/claude-haiku-4.5", 1),
makeTarget("deepseek/deepseek-chat", 2),
];
const task = classifyTask({ messages: [{ role: "user", content: "x".repeat(120_000) }] });
const out = reorderByTaskWeight(targets, task, new Set());
assert.equal(out.length, 3);
});
it("is stable: ties preserve original order", () => {
// Two identical models should not swap positions
const targets = [
makeTarget("anthropic/claude-haiku-4.5", 0),
makeTarget("anthropic/claude-haiku-4.5", 1),
];
const task = classifyTask({});
const out = reorderByTaskWeight(targets, task, new Set());
assert.equal(out[0].stepId, "step-0");
assert.equal(out[1].stepId, "step-1");
});
});
// ── isTaskRoutingStrategy ─────────────────────────────────────────────────────
describe("isTaskRoutingStrategy", () => {
it("returns true for task-aware strategy names", () => {
for (const name of ["smart", "task", "task-aware", "task_aware", "auto"]) {
assert.ok(isTaskRoutingStrategy(name), `Expected ${name} to be task-routing`);
}
});
it("is case-insensitive", () => {
assert.ok(isTaskRoutingStrategy("SMART"));
assert.ok(isTaskRoutingStrategy("Task-Aware"));
});
it("returns false for standard strategy names", () => {
for (const name of [
"priority",
"fallback",
"round-robin",
"weighted",
"random",
"fill-first",
"p2c",
"least-used",
"cost-optimized",
"reset-aware",
"reset-window",
"context-optimized",
"headroom",
"lkgp",
"context-relay",
]) {
assert.ok(!isTaskRoutingStrategy(name), `Expected ${name} to NOT be task-routing`);
}
});
it("returns false for null/undefined/empty", () => {
assert.ok(!isTaskRoutingStrategy(null));
assert.ok(!isTaskRoutingStrategy(undefined));
assert.ok(!isTaskRoutingStrategy(""));
});
});
// ── getConversationCacheKey ───────────────────────────────────────────────────
describe("getConversationCacheKey", () => {
it("returns null for null input", () => {
assert.equal(getConversationCacheKey(null as unknown as Record<string, unknown>), null);
});
it("returns a string for a body with messages", () => {
const key = getConversationCacheKey({
messages: [
{ role: "system", content: "You are helpful" },
{ role: "user", content: "hi" },
],
});
assert.equal(typeof key, "string");
assert.ok((key as string).length > 0);
});
it("is stable across calls for the same conversation start", () => {
const body = {
messages: [
{ role: "system", content: "You are a helpful assistant" },
{ role: "user", content: "Hello, how are you?" },
],
};
const k1 = getConversationCacheKey(body);
const k2 = getConversationCacheKey(body);
assert.equal(k1, k2);
});
it("differs for different first messages", () => {
const k1 = getConversationCacheKey({
messages: [{ role: "user", content: "Explain TCP" }],
});
const k2 = getConversationCacheKey({
messages: [{ role: "user", content: "Explain UDP" }],
});
assert.notEqual(k1, k2);
});
it("same key even when later messages are appended (conversation affinity)", () => {
const systemMsg = { role: "system", content: "You are a coding assistant" };
const firstUser = { role: "user", content: "What is a pointer?" };
const k1 = getConversationCacheKey({ messages: [systemMsg, firstUser] });
const k2 = getConversationCacheKey({
messages: [
systemMsg,
firstUser,
{ role: "assistant", content: "A pointer stores an address..." },
{ role: "user", content: "Can you give an example?" },
],
});
assert.equal(k1, k2);
});
it("prefers explicit conversation_id when present", () => {
const k1 = getConversationCacheKey({
conversation_id: "thread-abc-123",
messages: [{ role: "user", content: "hello" }],
});
const k2 = getConversationCacheKey({ conversation_id: "thread-abc-123" });
assert.equal(k1, k2);
assert.notEqual(k1, null);
});
it("different conversation_ids produce different keys", () => {
const k1 = getConversationCacheKey({ conversation_id: "thread-1" });
const k2 = getConversationCacheKey({ conversation_id: "thread-2" });
assert.notEqual(k1, k2);
});
});
// ── Conversation affinity (getOrSetConversationAffinityIndex) ─────────────────
describe("conversation affinity", () => {
before(() => clearConversationAffinity());
after(() => clearConversationAffinity());
it("returns currentIndex for a new conversation key", () => {
const idx = getOrSetConversationAffinityIndex("combo-x", "conv-aaa", 2);
assert.equal(idx, 2);
});
it("returns the pinned index for the same conversation key", () => {
clearConversationAffinity();
getOrSetConversationAffinityIndex("combo-y", "conv-bbb", 0);
// Second call should return the pinned index (0), not a new currentIndex (1)
const idx = getOrSetConversationAffinityIndex("combo-y", "conv-bbb", 1);
assert.equal(idx, 0);
});
it("different conversation keys get independent pins", () => {
clearConversationAffinity();
const i1 = getOrSetConversationAffinityIndex("combo-z", "conv-c1", 0);
const i2 = getOrSetConversationAffinityIndex("combo-z", "conv-c2", 1);
assert.equal(i1, 0);
assert.equal(i2, 1);
// Both pins should stay
assert.equal(getOrSetConversationAffinityIndex("combo-z", "conv-c1", 99), 0);
assert.equal(getOrSetConversationAffinityIndex("combo-z", "conv-c2", 99), 1);
});
it("clearConversationAffinity clears only the named combo prefix", () => {
clearConversationAffinity();
getOrSetConversationAffinityIndex("combo-a", "conv-1", 0);
getOrSetConversationAffinityIndex("combo-b", "conv-1", 1);
clearConversationAffinity("combo-a");
// combo-a entry should be gone
const sizeAfter = comboConversationAffinity.size;
assert.equal(sizeAfter, 1);
});
});
// ── Guard: non-task-aware strategy leaves targets unchanged ───────────────────
describe("non-task-aware strategy guard", () => {
it("reorderByTaskWeight is still safe to call externally but only wired for task strategies", () => {
// The function itself is pure — what matters is that combo.ts only calls it
// when isTaskRoutingStrategy() is true. Verify that explicitly.
const nonTaskStrategies = ["priority", "fallback", "round-robin", "cost-optimized"];
for (const strategy of nonTaskStrategies) {
assert.ok(!isTaskRoutingStrategy(strategy), `${strategy} should not trigger task routing`);
}
});
it("reorderByTaskWeight on standard task with equal-power models returns same reference", () => {
// If all scores are equal, no reordering → returns same array reference
const targets = [makeTarget("anthropic/claude-haiku-4.5", 0)];
const task = classifyTask({});
const out = reorderByTaskWeight(targets, task, new Set());
assert.strictEqual(out, targets);
});
});