mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(compression): compression engines + async pipeline + Compression Studios (#3848)
Integrated into release/v3.8.25.
This commit is contained in:
committed by
GitHub
parent
c8b9544d54
commit
4ffc55cfe4
16
README.md
16
README.md
@@ -1014,6 +1014,22 @@ Special thanks to **[RTK - Rust Token Killer](https://github.com/rtk-ai/rtk)** b
|
||||
|
||||
Special thanks to **[Troglodita](https://github.com/leninejunior/troglodita)** by **[Lenine Júnior](https://github.com/leninejunior)** — the PT-BR token compression project ("por que gastar muitos tokens quando poucos resolve?") whose Portuguese-native rules power OmniRoute's pt-BR language pack: pleonasm reduction, filler removal tuned for Brazilian Portuguese grammar, and technical abbreviations for the dev BR community.
|
||||
|
||||
Special thanks to **[headroom](https://github.com/chopratejas/headroom)** by **[chopratejas](https://github.com/chopratejas)** — the reversible context-compression project whose SmartCrusher (per-type routing, reversible block compaction, internal hash cache) directly inspired OmniRoute's `headroom` engine and the `ccr` retrieve-marker pattern.
|
||||
|
||||
Special thanks to **[TOON](https://github.com/toon-format/toon)** by **[toon-format](https://github.com/toon-format)** and **[GCF — Graph Compact Format](https://github.com/blackwell-systems/gcf)** by **[Dayna Blackwell / Blackwell Systems](https://github.com/blackwell-systems)** — the compact, schema-aware "JSON for LLMs" notations whose columnar, header-plus-rows model shaped OmniRoute's `headroom`/SmartCrusher tabular stage: a dependency-free, lossless compaction of homogeneous JSON arrays with an explicit `[N rows]` marker.
|
||||
|
||||
Special thanks to **[token-optimizer-mcp](https://github.com/ooples/token-optimizer-mcp)** by **[ooples](https://github.com/ooples)** — the Brotli/SQLite cache + per-session context-delta project whose content-addressed delta model inspired OmniRoute's `session-dedup` engine (cross-turn block deduplication with reversible references).
|
||||
|
||||
Special thanks to **[token-savior](https://github.com/Mibayy/token-savior)** by **[Mibayy](https://github.com/Mibayy)** — the Bash-output compaction + MCP-profiles project whose failure-aware bail-out and tool-profile model inspired OmniRoute's compression bail-out discipline and MCP tool-manifest cardinality reduction.
|
||||
|
||||
Special thanks to **[LLMLingua](https://github.com/microsoft/LLMLingua)** by **[Microsoft](https://github.com/microsoft)** — the prompt-compression research (LLMLingua / LLMLingua-2) whose token-level semantic pruning inspired OmniRoute's async `llmlingua` engine (prose-only, code-safe, fail-open), together with the JS/ONNX port **[llmlingua-2-js](https://github.com/atjsh/llmlingua-2-js)** by **[atjsh](https://github.com/atjsh)** (MobileBERT / XLM-RoBERTa ONNX models) as its intended worker-thread backend.
|
||||
|
||||
Special thanks to **[ts-morph](https://github.com/dsherret/ts-morph)** by **[David Sherret](https://github.com/dsherret)** — the TypeScript Compiler API toolkit whose AST approach inspired OmniRoute's parser-based code-comment removal, which correctly preserves string, template, and regex literals where naïve regex stripping corrupts them.
|
||||
|
||||
Special thanks to **[React Flow / xyflow](https://github.com/xyflow/xyflow)** by **[xyflow](https://github.com/xyflow)** — the node-based graph library that powers OmniRoute's real-time **Compression Studio** and **Combo/Routing Studio** dashboards.
|
||||
|
||||
Special thanks to **[LangGraph](https://github.com/langchain-ai/langgraph)** by **[LangChain](https://github.com/langchain-ai)** — the agent-graph framework whose LangGraph Studio live workflow-graph visualization inspired OmniRoute's Compression and Combo Studios: watching compression engines and combo fallbacks cascade in real time.
|
||||
|
||||
## ❤️ Support
|
||||
|
||||
OmniRoute is free and open source, built and maintained in the open. If it saves you time or money, consider supporting development:
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"open-sse/executors/muse-spark-web.ts": 1284,
|
||||
"open-sse/executors/perplexity-web.ts": 868,
|
||||
"open-sse/handlers/audioSpeech.ts": 952,
|
||||
"open-sse/handlers/chatCore.ts": 5808,
|
||||
"open-sse/handlers/chatCore.ts": 5811,
|
||||
"open-sse/handlers/imageGeneration.ts": 3777,
|
||||
"open-sse/handlers/responseSanitizer.ts": 1103,
|
||||
"open-sse/handlers/search.ts": 1442,
|
||||
@@ -72,7 +72,7 @@
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1069,
|
||||
"src/app/api/oauth/[provider]/[action]/route.ts": 903,
|
||||
"src/app/api/providers/[id]/models/route.ts": 2487,
|
||||
"src/app/api/providers/[id]/models/route.ts": 2489,
|
||||
"src/app/api/providers/[id]/test/route.ts": 842,
|
||||
"src/app/api/usage/analytics/route.ts": 941,
|
||||
"src/app/api/v1/models/catalog.ts": 1435,
|
||||
@@ -129,5 +129,6 @@
|
||||
"_rebaseline_2026_06_14_r3_3838_opencode_quota": "PR #3838 own growth: usage.ts 3394→3408 (+14 = clearer OpenCode Go missing-quota-API messages with OMNIROUTE_OPENCODE_GO_QUOTA_URL override hint + upstream issue links). Message text only; no logic extractable.",
|
||||
"_rebaseline_2026_06_14_r3_3839_veo_video": "PR #3839 own growth: schemas.ts 2522→2523 (+1 = Veo video model (predictLongRunning) validation for Gemini/Vertex dynamic discovery).",
|
||||
"_rebaseline_2026_06_14_r3_3836_kiro_discovery": "PR #3836 own growth: models/route.ts 2426→2487 (+61 = kiro live per-account discovery branch wiring fetchKiroAvailableModels into the existing cache/auto-fetch/fallback discovery flow). Structural shrink of this route tracked in #3789.",
|
||||
"_rebaseline_2026_06_14_2997_disable_cooling": "Re-baseline #2997 (per-connection disable-cooling): EditConnectionModal.tsx 1171→1174 (+toggle UI) + auth.ts 2207→2216 (honor de disableCooling no markAccountUnavailable, pós-prettier). Lógica coesa; não-extraível. (combo.ts/RequestLoggerV2 drift já documentado em _r3_3835.)"
|
||||
"_rebaseline_2026_06_14_2997_disable_cooling": "Re-baseline #2997 (per-connection disable-cooling): EditConnectionModal.tsx 1171→1174 (+toggle UI) + auth.ts 2207→2216 (honor de disableCooling no markAccountUnavailable, pós-prettier). Lógica coesa; não-extraível. (combo.ts/RequestLoggerV2 drift já documentado em _r3_3835.)",
|
||||
"_rebaseline_2026_06_14_r3_3848_compression": "PR #3848 own growth: chatCore.ts 5808→5811 (+3 = compression engine pipeline hooks). Also carries inherited release/v3.8.25 drift not touched by this PR: models/route.ts 2487→2489 (+2, post-#3836/prettier). Updating the frozen values restores Fast Quality Gates on the current base."
|
||||
}
|
||||
|
||||
@@ -2388,7 +2388,7 @@ export async function handleChatCore({
|
||||
// --- Modular Compression Pipeline (Phase 1 Lite + Phase 2 Standard/Caveman + Phase 3 Aggressive) ---
|
||||
// Runs BEFORE the existing reactive compressContext() to proactively reduce tokens.
|
||||
try {
|
||||
const { selectCompressionStrategy, applyCompression } =
|
||||
const { selectCompressionStrategy, applyCompressionAsync } =
|
||||
await import("../services/compression/strategySelector.ts");
|
||||
const { trackCompressionStats } = await import("../services/compression/stats.ts");
|
||||
let config: CompressionConfig = compressionSettings ?? {
|
||||
@@ -2623,7 +2623,7 @@ export async function handleChatCore({
|
||||
);
|
||||
let compressionAnalyticsRecorded = false;
|
||||
if (mode !== "off") {
|
||||
const result = applyCompression(compressionInputBody, mode, {
|
||||
const result = await applyCompressionAsync(compressionInputBody, mode, {
|
||||
model: effectiveModel,
|
||||
config,
|
||||
});
|
||||
@@ -2637,6 +2637,33 @@ export async function handleChatCore({
|
||||
);
|
||||
}
|
||||
|
||||
// Fire-and-forget: emit live compression event for dashboard (U5).
|
||||
// Guard: only emit when compression actually ran and produced stats.
|
||||
if (result.compressed && result.stats) {
|
||||
try {
|
||||
emit("compression.completed", {
|
||||
requestId: traceId,
|
||||
comboId: result.stats.compressionComboId ?? null,
|
||||
mode,
|
||||
originalTokens: result.stats.originalTokens,
|
||||
compressedTokens: result.stats.compressedTokens,
|
||||
savingsPercent: result.stats.savingsPercent,
|
||||
engineBreakdown: result.stats.engineBreakdown ?? [],
|
||||
validationWarnings: result.stats.validationWarnings,
|
||||
fallbackApplied: result.stats.fallbackApplied,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (_emitErr) {
|
||||
// never propagate into the hot path — but log like the sibling
|
||||
// fire-and-forget blocks so a throwing event bus isn't fully silent.
|
||||
log?.debug?.(
|
||||
"COMPRESSION",
|
||||
"compression.completed emit skipped: " +
|
||||
(_emitErr instanceof Error ? _emitErr.message : String(_emitErr))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.compressed || result.stats.fallbackApplied || cavemanOutputModeApplied) {
|
||||
trackCompressionStats(result.stats);
|
||||
compressionAnalyticsRecorded = true;
|
||||
|
||||
224
open-sse/mcp-server/toolCardinality.ts
Normal file
224
open-sse/mcp-server/toolCardinality.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* MCP Tool Cardinality Reduction — TV5 / F4.3
|
||||
*
|
||||
* Pure, stateless utility for reducing a tool manifest to the subset
|
||||
* permitted by a caller profile. Announcing fewer tools in the MCP
|
||||
* manifest saves tokens in the model's context window ("layer 5"
|
||||
* compression).
|
||||
*
|
||||
* ACTIVATION NOTE: The live MCP server registration loop in server.ts is
|
||||
* UNCHANGED by this file. This is a pure utility; wiring it into the server
|
||||
* startup path is a separate follow-up task. The default server behaviour
|
||||
* remains identical to before.
|
||||
*
|
||||
* Key rules for reduceToolManifest:
|
||||
* 1. A tool is kept if any of its scopes intersects `allowScopes` (when set).
|
||||
* 2. A tool listed in `allowTools` is always kept (regardless of scopes).
|
||||
* 3. A tool listed in `denyTools` is always removed (takes priority over
|
||||
* both allowScopes and allowTools).
|
||||
* 4. When neither `allowScopes` nor `allowTools` is present the profile
|
||||
* allows everything — the full manifest is returned unchanged.
|
||||
* 5. If `maxTools` is set the result is capped: allowTools-listed entries
|
||||
* come first, then the remaining entries sorted by name. Deterministic.
|
||||
* 6. Input is never mutated.
|
||||
* 7. Return type mirrors the input type: array → array, object → object.
|
||||
*/
|
||||
|
||||
import { estimateCompressionTokens } from "../services/compression/stats.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Minimal shape of an MCP tool manifest entry.
|
||||
* Structurally compatible with McpToolDefinition from schemas/tools.ts —
|
||||
* captures the fields needed for filtering; additional properties pass through.
|
||||
*/
|
||||
export interface ToolManifestEntry {
|
||||
/** MCP tool identifier */
|
||||
name: string;
|
||||
/** Human-readable description (used for token estimation) */
|
||||
description?: string;
|
||||
/** Required scopes for this tool. May be absent for tools that need no scope. */
|
||||
scopes?: readonly string[] | string[];
|
||||
/** Allow additional fields to pass through unchanged */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named profile that controls which tools are announced to a model.
|
||||
*
|
||||
* Filtering semantics:
|
||||
* - If neither `allowScopes` nor `allowTools` is set → keep everything.
|
||||
* - A tool is kept when:
|
||||
* scope_intersection(tool.scopes, allowScopes) is non-empty
|
||||
* OR tool.name is in allowTools
|
||||
* - Then denyTools removes named tools (deny beats allow).
|
||||
* - Finally, maxTools caps the count (allowTools entries are prioritised).
|
||||
*/
|
||||
export interface ToolProfile {
|
||||
/** Arbitrary label for logging/debugging */
|
||||
name: string;
|
||||
/**
|
||||
* Whitelist of scopes. A tool qualifies when any of its scopes appears here.
|
||||
* Supports wildcard suffix matching: "read:*" matches "read:x", "read:y", etc.
|
||||
*/
|
||||
allowScopes?: readonly string[] | string[];
|
||||
/**
|
||||
* Explicit tool names that are always kept (regardless of scope).
|
||||
* Evaluated before denyTools.
|
||||
*/
|
||||
allowTools?: readonly string[] | string[];
|
||||
/**
|
||||
* Tool names that are always removed. Takes priority over allowScopes and
|
||||
* allowTools.
|
||||
*/
|
||||
denyTools?: readonly string[] | string[];
|
||||
/**
|
||||
* Maximum number of tools in the output. When set and the filtered set
|
||||
* exceeds this limit:
|
||||
* 1. allowTools-listed entries fill the cap first (in name order).
|
||||
* 2. Remaining slots are filled from the rest, sorted by name.
|
||||
* Deterministic: same input → same output.
|
||||
*/
|
||||
maxTools?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true when `grantedScope` covers `requiredScope`.
|
||||
* Mirrors the matching logic in scopeEnforcement.ts.
|
||||
*/
|
||||
function scopeMatches(grantedScope: string, requiredScope: string): boolean {
|
||||
if (grantedScope === "*" || grantedScope === requiredScope) {
|
||||
return true;
|
||||
}
|
||||
if (grantedScope.endsWith("*")) {
|
||||
const prefix = grantedScope.slice(0, -1);
|
||||
return requiredScope.startsWith(prefix);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any scope in `toolScopes` is covered by any scope in
|
||||
* `allowScopes`.
|
||||
*/
|
||||
function scopeIntersects(toolScopes: readonly string[], allowScopes: readonly string[]): boolean {
|
||||
for (const ts of toolScopes) {
|
||||
for (const as of allowScopes) {
|
||||
if (scopeMatches(as, ts)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies profile filtering to a flat array of entries.
|
||||
* Returns a new array (does not mutate input).
|
||||
*/
|
||||
function filterEntries(entries: ToolManifestEntry[], profile: ToolProfile): ToolManifestEntry[] {
|
||||
const allowScopes = profile.allowScopes ?? [];
|
||||
const allowTools = profile.allowTools ?? [];
|
||||
const denyTools = profile.denyTools ?? [];
|
||||
|
||||
const denySet = new Set<string>(denyTools as string[]);
|
||||
const allowToolSet = new Set<string>(allowTools as string[]);
|
||||
|
||||
// No filtering rules → return all entries unchanged.
|
||||
const hasFilter = allowScopes.length > 0 || allowTools.length > 0;
|
||||
|
||||
// Step 1: filter by scope / explicit allow, then apply deny.
|
||||
const filtered = entries.filter((entry) => {
|
||||
// Deny always wins.
|
||||
if (denySet.has(entry.name)) return false;
|
||||
|
||||
// No filter → keep everything.
|
||||
if (!hasFilter) return true;
|
||||
|
||||
// Explicit allow-list overrides scope checks.
|
||||
if (allowToolSet.has(entry.name)) return true;
|
||||
|
||||
// Scope intersection.
|
||||
if (allowScopes.length > 0 && (entry.scopes?.length ?? 0) > 0) {
|
||||
return scopeIntersects(entry.scopes as readonly string[], allowScopes as readonly string[]);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Step 2: cap by maxTools. A negative max would silently drop tail entries via
|
||||
// slice(0, -n) — treat max < 0 as "no cap" (invalid-state guard).
|
||||
const max = profile.maxTools;
|
||||
if (max === undefined || max < 0 || filtered.length <= max) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// Priority ordering: allowTools-listed entries first (by name asc),
|
||||
// then the rest by name asc. Both groups are sorted for determinism.
|
||||
const prioritised = filtered
|
||||
.filter((e) => allowToolSet.has(e.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const rest = filtered
|
||||
.filter((e) => !allowToolSet.has(e.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return [...prioritised, ...rest].slice(0, max);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reduce a tool manifest according to the given profile.
|
||||
*
|
||||
* Overloads preserve the return shape: array → array, Record → Record.
|
||||
*/
|
||||
export function reduceToolManifest(
|
||||
manifest: ToolManifestEntry[],
|
||||
profile: ToolProfile
|
||||
): ToolManifestEntry[];
|
||||
export function reduceToolManifest(
|
||||
manifest: Record<string, ToolManifestEntry>,
|
||||
profile: ToolProfile
|
||||
): Record<string, ToolManifestEntry>;
|
||||
export function reduceToolManifest(
|
||||
manifest: ToolManifestEntry[] | Record<string, ToolManifestEntry>,
|
||||
profile: ToolProfile
|
||||
): ToolManifestEntry[] | Record<string, ToolManifestEntry> {
|
||||
if (Array.isArray(manifest)) {
|
||||
return filterEntries(manifest, profile);
|
||||
}
|
||||
|
||||
// Object/Record variant: convert → filter → convert back.
|
||||
const entries = Object.values(manifest);
|
||||
const filtered = filterEntries(entries, profile);
|
||||
return Object.fromEntries(filtered.map((e) => [e.name, e]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rough token estimate for a tool manifest.
|
||||
*
|
||||
* Delegates to `estimateCompressionTokens` from the existing compression
|
||||
* stats module (chars / 4 ceiling). Accepts both array and Record forms.
|
||||
*/
|
||||
export function estimateManifestTokens(
|
||||
manifest: ToolManifestEntry[] | Record<string, ToolManifestEntry>
|
||||
): number {
|
||||
const entries = Array.isArray(manifest) ? manifest : Object.values(manifest);
|
||||
if (entries.length === 0) return 0;
|
||||
|
||||
return entries.reduce((sum, entry) => {
|
||||
const nameTokens = estimateCompressionTokens(entry.name);
|
||||
const descTokens = estimateCompressionTokens(entry.description ?? "");
|
||||
return sum + nameTokens + descTokens;
|
||||
}, 0);
|
||||
}
|
||||
@@ -242,6 +242,15 @@ import {
|
||||
listCompressionCombosInput,
|
||||
compressionComboStatsInput,
|
||||
} from "../schemas/tools.ts";
|
||||
import { handleCcrRetrieve } from "../../services/compression/engines/ccr/index.ts";
|
||||
|
||||
const ccrRetrieveInput = z.object({
|
||||
hash: z
|
||||
.string()
|
||||
.length(24)
|
||||
.regex(/^[0-9a-f]{24}$/)
|
||||
.describe("24-hex content hash from a [CCR retrieve hash=<hash>] marker"),
|
||||
});
|
||||
|
||||
export async function handleSetCompressionEngine(
|
||||
args: z.infer<typeof setCompressionEngineInput>
|
||||
@@ -333,4 +342,15 @@ export const compressionTools = {
|
||||
handler: (args: z.infer<typeof compressionComboStatsInput>) =>
|
||||
handleCompressionComboStats(args),
|
||||
},
|
||||
omniroute_ccr_retrieve: {
|
||||
name: "omniroute_ccr_retrieve",
|
||||
description:
|
||||
"Retrieve the verbatim content block stored by the CCR compression engine. " +
|
||||
"When a large block is compressed, a marker `[CCR retrieve hash=<24hex> chars=N]` " +
|
||||
"is inserted. Pass the hash from the marker to this tool to get the original text back. " +
|
||||
"Scope: read:compression. Always available (sticky-on).",
|
||||
scopes: ["read:compression"],
|
||||
inputSchema: ccrRetrieveInput,
|
||||
handler: (args: z.infer<typeof ccrRetrieveInput>) => handleCcrRetrieve(args),
|
||||
},
|
||||
};
|
||||
|
||||
381
open-sse/services/compression/engines/ccr/index.ts
Normal file
381
open-sse/services/compression/engines/ccr/index.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* CCR (Content-Compression-Retrieve) engine (H4)
|
||||
*
|
||||
* Replaces large contiguous blocks of text with a content-addressed
|
||||
* retrieve marker: `[CCR retrieve hash=<24hex> chars=<N>]`
|
||||
*
|
||||
* The verbatim block is stored in an in-module content-addressed store
|
||||
* (keyed by 24-hex content hash). The `retrieve` MCP tool (or the
|
||||
* `handleCcrRetrieve` helper exported here) returns the block on demand.
|
||||
*
|
||||
* Algorithm:
|
||||
* - Scan non-system messages; for each `type:"text"` part or string content,
|
||||
* find contiguous text blocks ≥ minChars characters.
|
||||
* - Replace the block with `[CCR retrieve hash=<24hex> chars=<N>]` only if
|
||||
* the marker is shorter than the original block.
|
||||
* - Store the original block keyed by hash in the CCR store.
|
||||
*
|
||||
* Feedback:
|
||||
* - `recordRetrieval(hash)` increments a retrieval counter for that hash.
|
||||
* - `shouldSkipCompression(hash)` returns true once the counter reaches
|
||||
* RETRIEVAL_THRESHOLD, signalling "do not compress this block again".
|
||||
*
|
||||
* Conservative guards:
|
||||
* - Never touch `role: "system"`.
|
||||
* - Only replace if it shrinks (marker shorter than original).
|
||||
* - Only replace blocks ≥ minChars (default 600).
|
||||
* - `stackable: true`, `stackPriority: 4` (runs just after session-dedup(3)).
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { createCompressionStats } from "../../stats.ts";
|
||||
import type {
|
||||
CompressionEngine,
|
||||
CompressionEngineApplyOptions,
|
||||
EngineConfigField,
|
||||
EngineValidationResult,
|
||||
} from "../types.ts";
|
||||
import type { CompressionResult } from "../../types.ts";
|
||||
|
||||
// ─── constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ENGINE_ID = "ccr";
|
||||
/** Default minimum character count for a block to be a CCR candidate. */
|
||||
const DEFAULT_MIN_CHARS = 600;
|
||||
/** Number of retrievals before a block is flagged "do-not-compress". */
|
||||
const RETRIEVAL_THRESHOLD = 3;
|
||||
/** Regex to match CCR markers for reconstruction. */
|
||||
const MARKER_RE = /\[CCR retrieve hash=([0-9a-f]{24}) chars=\d+\]/g;
|
||||
|
||||
// ─── content-addressed store ──────────────────────────────────────────────────
|
||||
|
||||
/** Map from 24-hex hash → verbatim block text. */
|
||||
const ccrStore = new Map<string, string>();
|
||||
/** Map from 24-hex hash → retrieval count (feedback signal). */
|
||||
const retrievalCounts = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Compute a 24-hex content hash for a text block (SHA-256 prefix).
|
||||
*/
|
||||
function hashContent(text: string): string {
|
||||
return crypto.createHash("sha256").update(text).digest("hex").slice(0, 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a block in the CCR store, returning its hash.
|
||||
*/
|
||||
function storeBlock(text: string): string {
|
||||
const hash = hashContent(text);
|
||||
if (!ccrStore.has(hash)) {
|
||||
ccrStore.set(hash, text);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the verbatim block for a given hash.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
export function retrieveBlock(hash: string): string | null {
|
||||
return ccrStore.get(hash) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a retrieval event for a given hash (feedback signal).
|
||||
*/
|
||||
export function recordRetrieval(hash: string): void {
|
||||
retrievalCounts.set(hash, (retrievalCounts.get(hash) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the block has been retrieved often enough that it
|
||||
* should be excluded from compression in future requests.
|
||||
*/
|
||||
export function shouldSkipCompression(hash: string): boolean {
|
||||
return (retrievalCounts.get(hash) ?? 0) >= RETRIEVAL_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the CCR store and retrieval counts (for testing).
|
||||
*/
|
||||
export function resetCcrStore(): void {
|
||||
ccrStore.clear();
|
||||
retrievalCounts.clear();
|
||||
}
|
||||
|
||||
// ─── MCP tool handler (pure function) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handler for the `omniroute_ccr_retrieve` MCP tool.
|
||||
* Returns the verbatim block for the given hash, or an error object.
|
||||
*/
|
||||
export function handleCcrRetrieve(args: { hash: string }): { content: string } | { error: string } {
|
||||
if (!args.hash || typeof args.hash !== "string") {
|
||||
return { error: "hash parameter is required and must be a string" };
|
||||
}
|
||||
|
||||
const block = retrieveBlock(args.hash);
|
||||
if (block === null) {
|
||||
return {
|
||||
error: `CCR block not found for hash=${args.hash}. The block may have expired or the hash is invalid.`,
|
||||
};
|
||||
}
|
||||
|
||||
recordRetrieval(args.hash);
|
||||
return { content: block };
|
||||
}
|
||||
|
||||
// ─── message content processing ──────────────────────────────────────────────
|
||||
|
||||
type MessageLike = {
|
||||
role?: string;
|
||||
content?: string | Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a CCR marker string for a block.
|
||||
*/
|
||||
function buildMarker(hash: string, charCount: number): string {
|
||||
return `[CCR retrieve hash=${hash} chars=${charCount}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a large text block with a CCR marker if it shrinks the content.
|
||||
* Returns the new text and a flag indicating whether replacement happened.
|
||||
*/
|
||||
function maybeCcrReplace(
|
||||
text: string,
|
||||
minChars: number
|
||||
): { text: string; replaced: boolean; hash: string | null } {
|
||||
if (text.length < minChars) {
|
||||
return { text, replaced: false, hash: null };
|
||||
}
|
||||
|
||||
const hash = hashContent(text);
|
||||
|
||||
// Skip if this hash is flagged as do-not-compress
|
||||
if (shouldSkipCompression(hash)) {
|
||||
return { text, replaced: false, hash: null };
|
||||
}
|
||||
|
||||
const marker = buildMarker(hash, text.length);
|
||||
|
||||
// Only replace if it actually shrinks
|
||||
if (marker.length >= text.length) {
|
||||
return { text, replaced: false, hash: null };
|
||||
}
|
||||
|
||||
storeBlock(text);
|
||||
return { text: marker, replaced: true, hash };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all non-system messages: find large text blocks and replace with CCR markers.
|
||||
*/
|
||||
function processMessages(
|
||||
messages: MessageLike[],
|
||||
minChars: number
|
||||
): { messages: MessageLike[]; replacedCount: number } {
|
||||
let replacedCount = 0;
|
||||
|
||||
const result = messages.map((msg) => {
|
||||
if (msg.role === "system") return { ...msg };
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
const { text, replaced } = maybeCcrReplace(msg.content, minChars);
|
||||
if (replaced) {
|
||||
replacedCount++;
|
||||
return { ...msg, content: text };
|
||||
}
|
||||
return { ...msg };
|
||||
}
|
||||
|
||||
if (Array.isArray(msg.content)) {
|
||||
let changed = false;
|
||||
const newContent = msg.content.map((part) => {
|
||||
if (part["type"] !== "text" || typeof part["text"] !== "string") return part;
|
||||
const { text, replaced } = maybeCcrReplace(part["text"] as string, minChars);
|
||||
if (replaced) {
|
||||
changed = true;
|
||||
replacedCount++;
|
||||
return { ...part, text };
|
||||
}
|
||||
return part;
|
||||
});
|
||||
if (changed) {
|
||||
return { ...msg, content: newContent };
|
||||
}
|
||||
return { ...msg };
|
||||
}
|
||||
|
||||
return { ...msg };
|
||||
});
|
||||
|
||||
return { messages: result, replacedCount };
|
||||
}
|
||||
|
||||
// ─── schema & validation ──────────────────────────────────────────────────────
|
||||
|
||||
const CCR_SCHEMA: EngineConfigField[] = [
|
||||
{
|
||||
key: "enabled",
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: "minChars",
|
||||
type: "number",
|
||||
label: "Minimum block characters",
|
||||
description: "Minimum character count for a block to be a CCR candidate.",
|
||||
defaultValue: DEFAULT_MIN_CHARS,
|
||||
min: 100,
|
||||
max: 1_000_000,
|
||||
},
|
||||
];
|
||||
|
||||
function validateCcrConfig(config: Record<string, unknown>): EngineValidationResult {
|
||||
const errors: string[] = [];
|
||||
if (config["enabled"] !== undefined && typeof config["enabled"] !== "boolean") {
|
||||
errors.push("enabled must be a boolean");
|
||||
}
|
||||
if (config["minChars"] !== undefined) {
|
||||
const v = config["minChars"];
|
||||
if (typeof v !== "number" || !Number.isFinite(v) || v < 1) {
|
||||
errors.push("minChars must be a positive number");
|
||||
}
|
||||
}
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ─── reconstruction helper ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reconstruct a body by replacing all `[CCR retrieve hash=<24hex> chars=N]`
|
||||
* markers with the stored verbatim blocks.
|
||||
*
|
||||
* Returns a new body object with markers restored to their original text.
|
||||
*/
|
||||
export function reconstructCcr(body: Record<string, unknown>): Record<string, unknown> {
|
||||
const messages = body["messages"];
|
||||
if (!Array.isArray(messages)) return body;
|
||||
|
||||
type MsgLike = {
|
||||
role?: string;
|
||||
content?: string | Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const restored = (messages as MsgLike[]).map((msg) => {
|
||||
const content = msg["content"];
|
||||
|
||||
if (typeof content === "string") {
|
||||
const reconstructed = content.replace(MARKER_RE, (_m, hash: string) => {
|
||||
return ccrStore.get(hash) ?? _m;
|
||||
});
|
||||
return reconstructed !== content ? { ...msg, content: reconstructed } : { ...msg };
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
let changed = false;
|
||||
const newContent = content.map((part) => {
|
||||
if (part["type"] !== "text" || typeof part["text"] !== "string") return part;
|
||||
const reconstructed = (part["text"] as string).replace(MARKER_RE, (_m, hash: string) => {
|
||||
return ccrStore.get(hash) ?? _m;
|
||||
});
|
||||
if (reconstructed !== part["text"]) {
|
||||
changed = true;
|
||||
return { ...part, text: reconstructed };
|
||||
}
|
||||
return part;
|
||||
});
|
||||
return changed ? { ...msg, content: newContent } : { ...msg };
|
||||
}
|
||||
|
||||
return { ...msg };
|
||||
});
|
||||
|
||||
return { ...body, messages: restored };
|
||||
}
|
||||
|
||||
// ─── engine export ────────────────────────────────────────────────────────────
|
||||
|
||||
export const ccrEngine: CompressionEngine = {
|
||||
id: ENGINE_ID,
|
||||
name: "CCR (Content-Compression-Retrieve)",
|
||||
description:
|
||||
"Replaces large blocks of text with content-addressed retrieve markers " +
|
||||
"`[CCR retrieve hash=<24hex> chars=N]`. The original block is stored and " +
|
||||
"retrievable via the `omniroute_ccr_retrieve` MCP tool (H4).",
|
||||
icon: "archive",
|
||||
targets: ["messages"],
|
||||
stackable: true,
|
||||
// stackPriority 4 = runs just after session-dedup (3), before headroom (15),
|
||||
// caveman (20), aggressive (30), ultra (40).
|
||||
stackPriority: 4,
|
||||
metadata: {
|
||||
id: ENGINE_ID,
|
||||
name: "CCR (Content-Compression-Retrieve)",
|
||||
description:
|
||||
"Reversible compression: large blocks → retrieve marker. " +
|
||||
"Original retrievable via MCP tool (H4).",
|
||||
inputScope: "messages",
|
||||
targetLatencyMs: 1,
|
||||
supportsPreview: true,
|
||||
stable: true,
|
||||
},
|
||||
|
||||
apply(body: Record<string, unknown>, options?: CompressionEngineApplyOptions): CompressionResult {
|
||||
const stepConfig = options?.stepConfig ?? {};
|
||||
|
||||
if (stepConfig["enabled"] === false) {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
const minChars =
|
||||
typeof stepConfig["minChars"] === "number"
|
||||
? (stepConfig["minChars"] as number)
|
||||
: DEFAULT_MIN_CHARS;
|
||||
|
||||
const messages = body["messages"];
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const { messages: newMessages, replacedCount } = processMessages(
|
||||
messages as MessageLike[],
|
||||
minChars
|
||||
);
|
||||
|
||||
if (replacedCount === 0) {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
const newBody: Record<string, unknown> = { ...body, messages: newMessages };
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const stats = createCompressionStats(
|
||||
body,
|
||||
newBody,
|
||||
"stacked",
|
||||
["ccr"],
|
||||
[`ccr-replaced-${replacedCount}-blocks`],
|
||||
durationMs
|
||||
);
|
||||
|
||||
return { body: newBody, compressed: true, stats };
|
||||
},
|
||||
|
||||
compress(body: Record<string, unknown>, config?: Record<string, unknown>): CompressionResult {
|
||||
return this.apply(body, { stepConfig: config ?? {} });
|
||||
},
|
||||
|
||||
getConfigSchema(): EngineConfigField[] {
|
||||
return CCR_SCHEMA;
|
||||
},
|
||||
|
||||
validateConfig(config: Record<string, unknown>): EngineValidationResult {
|
||||
return validateCcrConfig(config);
|
||||
},
|
||||
};
|
||||
260
open-sse/services/compression/engines/headroom/index.ts
Normal file
260
open-sse/services/compression/engines/headroom/index.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* headroom compression engine — SmartCrusher: tabular compaction of homogeneous JSON arrays.
|
||||
*
|
||||
* Implements item H3 (SmartCrusher lossless compaction) + N5 (explicit [N rows] count marker)
|
||||
* + GP5' (columnar/tabular encoder, dependency-free) from the compression research plan:
|
||||
* docs/research/compression/headroom-plano-implementacao.md
|
||||
* docs/research/compression/rodada4-internet-e-coerencia.md
|
||||
* docs/research/compression/gcf-proxy-relatorio-plano.md
|
||||
*
|
||||
* Algorithm:
|
||||
* - Scans non-system message contents (string contents and ```json fenced blocks).
|
||||
* - When content parses as a homogeneous array of objects (≥ minRows, default 8),
|
||||
* replaces it with a compact columnar block (```omni-tabular ...```).
|
||||
* - The columnar block carries: [N rows] count marker, type hints, a header row,
|
||||
* and value-only data rows.
|
||||
* - LOSSLESS: decode(encode(arr)) deep-equals the original (proven by round-trip tests).
|
||||
* - Conservative: only replaces when the compact form is strictly smaller.
|
||||
* - Never touches system messages.
|
||||
*
|
||||
* Encoder format: see tabular.ts (CSV-style with RFC 4180 quoting for special chars,
|
||||
* JSON-stringified nested values quoted as string cells, type-hint metadata row).
|
||||
*
|
||||
* Note: TOON (@toon-format/toon, ~24.6k★) could be a future drop-in encoder here for
|
||||
* improved compression on complex shapes. Plain columnar is used now (dep-free, zero
|
||||
* supply-chain risk, ≥30% savings on typical homogeneous arrays already met).
|
||||
*/
|
||||
|
||||
import { createCompressionStats } from "../../stats.ts";
|
||||
import type {
|
||||
CompressionEngine,
|
||||
CompressionEngineApplyOptions,
|
||||
EngineConfigField,
|
||||
EngineValidationResult,
|
||||
} from "../types.ts";
|
||||
import type { CompressionResult } from "../../types.ts";
|
||||
import { crushMessages, DEFAULT_MIN_ROWS } from "./smartcrusher.ts";
|
||||
import {
|
||||
TABULAR_FENCE_OPEN,
|
||||
TABULAR_FENCE_CLOSE,
|
||||
decodeTabular,
|
||||
TABULAR_MARKER_RE,
|
||||
} from "./tabular.ts";
|
||||
|
||||
export { encodeTabular, decodeTabular } from "./tabular.ts";
|
||||
|
||||
// ─── constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ENGINE_ID = "headroom";
|
||||
|
||||
// ─── schema & validation ──────────────────────────────────────────────────────
|
||||
|
||||
const HEADROOM_SCHEMA: EngineConfigField[] = [
|
||||
{
|
||||
key: "enabled",
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: "minRows",
|
||||
type: "number",
|
||||
label: "Minimum rows to compact",
|
||||
description:
|
||||
"Minimum number of rows in a homogeneous JSON array to trigger tabular compaction. Default: 8.",
|
||||
defaultValue: DEFAULT_MIN_ROWS,
|
||||
min: 2,
|
||||
max: 10000,
|
||||
},
|
||||
];
|
||||
|
||||
function validateHeadroomConfig(config: Record<string, unknown>): EngineValidationResult {
|
||||
const errors: string[] = [];
|
||||
if (config["enabled"] !== undefined && typeof config["enabled"] !== "boolean") {
|
||||
errors.push("enabled must be a boolean");
|
||||
}
|
||||
if (config["minRows"] !== undefined) {
|
||||
const v = config["minRows"];
|
||||
if (typeof v !== "number" || !Number.isFinite(v) || v < 2) {
|
||||
errors.push("minRows must be a number ≥ 2");
|
||||
}
|
||||
}
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ─── engine export ────────────────────────────────────────────────────────────
|
||||
|
||||
export const headroomEngine: CompressionEngine = {
|
||||
id: ENGINE_ID,
|
||||
name: "Headroom SmartCrusher",
|
||||
description:
|
||||
"Lossless tabular compaction of homogeneous JSON arrays (H3 + N5 + GP5'). " +
|
||||
"Replaces repetitive JSON arrays with compact columnar blocks, " +
|
||||
"including explicit [N rows] count markers for auditability.",
|
||||
icon: "compress",
|
||||
targets: ["messages", "tool_results"],
|
||||
stackable: true,
|
||||
// stackPriority 15 = between rtk (10) and caveman (20), as specified in headroom-plano.
|
||||
stackPriority: 15,
|
||||
metadata: {
|
||||
id: ENGINE_ID,
|
||||
name: "Headroom SmartCrusher",
|
||||
description:
|
||||
"Lossless tabular compaction of homogeneous JSON arrays with [N rows] count markers.",
|
||||
inputScope: "messages",
|
||||
targetLatencyMs: 5,
|
||||
supportsPreview: true,
|
||||
stable: true,
|
||||
},
|
||||
|
||||
apply(body: Record<string, unknown>, options?: CompressionEngineApplyOptions): CompressionResult {
|
||||
const stepConfig = options?.stepConfig ?? {};
|
||||
|
||||
if (stepConfig["enabled"] === false) {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
const minRows =
|
||||
typeof stepConfig["minRows"] === "number"
|
||||
? (stepConfig["minRows"] as number)
|
||||
: DEFAULT_MIN_ROWS;
|
||||
|
||||
const messages = body["messages"];
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const { messages: crushedMessages, changed } = crushMessages(
|
||||
messages as Array<{
|
||||
role?: string;
|
||||
content?: string | Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
}>,
|
||||
minRows
|
||||
);
|
||||
|
||||
if (!changed) {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
const newBody: Record<string, unknown> = {
|
||||
...body,
|
||||
messages: crushedMessages,
|
||||
};
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const stats = createCompressionStats(
|
||||
body,
|
||||
newBody,
|
||||
"stacked",
|
||||
["headroom-smartcrusher"],
|
||||
["tabular-compaction"],
|
||||
durationMs
|
||||
);
|
||||
|
||||
return { body: newBody, compressed: true, stats };
|
||||
},
|
||||
|
||||
compress(body: Record<string, unknown>, config?: Record<string, unknown>): CompressionResult {
|
||||
return this.apply(body, { stepConfig: config ?? {} });
|
||||
},
|
||||
|
||||
getConfigSchema(): EngineConfigField[] {
|
||||
return HEADROOM_SCHEMA;
|
||||
},
|
||||
|
||||
validateConfig(config: Record<string, unknown>): EngineValidationResult {
|
||||
return validateHeadroomConfig(config);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── reconstruction helper ────────────────────────────────────────────────────
|
||||
|
||||
type MessageLike = {
|
||||
role?: string;
|
||||
content?: string | Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverse the headroom tabular compaction: find every ```omni-tabular block
|
||||
* in message contents and decode it back to the original JSON string.
|
||||
*
|
||||
* Returns a new body with all tabular blocks expanded.
|
||||
*/
|
||||
export function reconstructHeadroom(body: Record<string, unknown>): Record<string, unknown> {
|
||||
const messages = body["messages"];
|
||||
if (!Array.isArray(messages)) return body;
|
||||
|
||||
let changed = false;
|
||||
const restored = (messages as MessageLike[]).map((msg): MessageLike => {
|
||||
if (typeof msg.content === "string") {
|
||||
const reconstructed = restoreText(msg.content);
|
||||
if (reconstructed !== msg.content) {
|
||||
changed = true;
|
||||
return { ...msg, content: reconstructed };
|
||||
}
|
||||
return { ...msg };
|
||||
}
|
||||
|
||||
if (Array.isArray(msg.content)) {
|
||||
let contentChanged = false;
|
||||
const newContent = msg.content.map((part: Record<string, unknown>) => {
|
||||
if (part["type"] !== "text" || typeof part["text"] !== "string") return part;
|
||||
const reconstructed = restoreText(part["text"] as string);
|
||||
if (reconstructed !== part["text"]) {
|
||||
contentChanged = true;
|
||||
return { ...part, text: reconstructed };
|
||||
}
|
||||
return part;
|
||||
});
|
||||
if (contentChanged) {
|
||||
changed = true;
|
||||
return { ...msg, content: newContent };
|
||||
}
|
||||
return { ...msg };
|
||||
}
|
||||
|
||||
return { ...msg };
|
||||
});
|
||||
|
||||
if (!changed) return body;
|
||||
return { ...body, messages: restored };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore all omni-tabular blocks in a text string back to their original JSON.
|
||||
*/
|
||||
function restoreText(text: string): string {
|
||||
// Fast path: no fence marker present
|
||||
if (!text.includes(TABULAR_FENCE_OPEN)) return text;
|
||||
|
||||
const fence = TABULAR_FENCE_OPEN;
|
||||
const closeTag = TABULAR_FENCE_CLOSE;
|
||||
|
||||
let result = text;
|
||||
let offset = 0;
|
||||
|
||||
// Find each occurrence of the tabular fence
|
||||
let searchFrom = 0;
|
||||
while (true) {
|
||||
const fenceStart = result.indexOf(fence, searchFrom);
|
||||
if (fenceStart === -1) break;
|
||||
|
||||
const contentStart = fenceStart + fence.length + 1; // skip "\n" after fence open
|
||||
const fenceEnd = result.indexOf("\n" + closeTag, contentStart);
|
||||
if (fenceEnd === -1) break;
|
||||
|
||||
const blockContent = result.slice(contentStart, fenceEnd);
|
||||
const decoded = decodeTabular(fence + "\n" + blockContent + "\n" + closeTag);
|
||||
const jsonStr = JSON.stringify(decoded);
|
||||
|
||||
const fullFence = result.slice(fenceStart, fenceEnd + closeTag.length + 1); // +1 for the "\n"
|
||||
result = result.slice(0, fenceStart) + jsonStr + result.slice(fenceStart + fullFence.length);
|
||||
|
||||
searchFrom = fenceStart + jsonStr.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
179
open-sse/services/compression/engines/headroom/smartcrusher.ts
Normal file
179
open-sse/services/compression/engines/headroom/smartcrusher.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* smartcrusher.ts — SmartCrusher: JSON array → tabular compaction (H3 lossless stage).
|
||||
*
|
||||
* Scans message contents (strings and ```json fenced blocks inside strings) for
|
||||
* homogeneous arrays of objects. When found and when the tabular form is strictly
|
||||
* smaller, replaces the JSON array text with a compact omni-tabular block.
|
||||
*
|
||||
* Conservative guards (inherited from headroom upstream):
|
||||
* - Never touch role: "system" messages.
|
||||
* - Only compact arrays that are homogeneous (all objects share the same key set).
|
||||
* - Minimum row count gate (default 8).
|
||||
* - Skip if tabular form is NOT smaller than the original JSON (no regression).
|
||||
* - Skip if the array elements are not "scalar-ish" at top level (i.e. objects, not nested arrays of arrays).
|
||||
*/
|
||||
|
||||
import { encodeTabularBlock, kindOf, wrapTabular } from "./tabular.ts";
|
||||
|
||||
/** Default minimum number of rows to trigger compaction. */
|
||||
export const DEFAULT_MIN_ROWS = 8;
|
||||
|
||||
/** The fenced block marker we look for to compact json arrays inline. */
|
||||
const JSON_FENCE_RE = /```json\n([\s\S]*?)\n```/g;
|
||||
|
||||
/**
|
||||
* Checks whether an array of values is homogeneous (all entries are plain objects
|
||||
* sharing the same set of keys, with scalar-ish leaf values).
|
||||
*
|
||||
* Returns the shared keys array if homogeneous, null otherwise.
|
||||
*/
|
||||
export function detectHomogeneous(arr: unknown[]): string[] | null {
|
||||
if (arr.length === 0) return null;
|
||||
|
||||
// Every element must be a plain (non-null, non-array) object
|
||||
for (const item of arr) {
|
||||
if (item === null || typeof item !== "object" || Array.isArray(item)) return null;
|
||||
}
|
||||
|
||||
// Build the union of keys from the first element
|
||||
const firstKeys = Object.keys(arr[0] as Record<string, unknown>).sort();
|
||||
|
||||
// All other elements must have the EXACT same key set (same keys, same count)
|
||||
for (const item of arr.slice(1)) {
|
||||
const itemKeys = Object.keys(item as Record<string, unknown>).sort();
|
||||
if (itemKeys.length !== firstKeys.length) return null;
|
||||
for (let i = 0; i < firstKeys.length; i++) {
|
||||
if (itemKeys[i] !== firstKeys[i]) return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-column TYPE uniformity. The decoder applies a single kind per column
|
||||
// (derived from row 0), so every row's value in a column must share that kind —
|
||||
// otherwise the round-trip would be lossy (e.g. a nullable column would decode
|
||||
// every cell as null, or a mixed number/string column would NaN-out). A column
|
||||
// with mixed kinds makes the array effectively heterogeneous → leave it untouched.
|
||||
const first = arr[0] as Record<string, unknown>;
|
||||
for (const key of firstKeys) {
|
||||
const expected = kindOf(first[key]);
|
||||
for (const item of arr) {
|
||||
if (kindOf((item as Record<string, unknown>)[key]) !== expected) return null;
|
||||
}
|
||||
}
|
||||
|
||||
return firstKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to crush a JSON string (already verified to be a homogeneous array) into
|
||||
* a tabular form. Returns the compact string if it shrinks the input; null otherwise.
|
||||
*/
|
||||
export function tryCompactJson(jsonStr: string, minRows: number = DEFAULT_MIN_ROWS): string | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed) || parsed.length < minRows) return null;
|
||||
|
||||
const keys = detectHomogeneous(parsed);
|
||||
if (!keys) return null;
|
||||
|
||||
const arr = parsed as Record<string, unknown>[];
|
||||
const blockContent = encodeTabularBlock(arr);
|
||||
const compact = wrapTabular(blockContent);
|
||||
|
||||
// Only use compact form if it is strictly smaller
|
||||
if (compact.length >= jsonStr.length) return null;
|
||||
|
||||
return compact;
|
||||
}
|
||||
|
||||
type MessageLike = {
|
||||
role?: string;
|
||||
content?: string | Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Process a single text string: try to compact it as a whole JSON array,
|
||||
* or find and compact any ```json fenced blocks inside it.
|
||||
*
|
||||
* Returns the new string and whether it changed.
|
||||
*/
|
||||
export function crushText(text: string, minRows: number = DEFAULT_MIN_ROWS): string {
|
||||
// 1. Try the whole string as a JSON array first
|
||||
const trimmed = text.trimStart();
|
||||
if (trimmed.startsWith("[")) {
|
||||
const compacted = tryCompactJson(text.trim(), minRows);
|
||||
if (compacted !== null) return compacted;
|
||||
}
|
||||
|
||||
// 2. Try to compact ```json fenced blocks inside the text
|
||||
let result = text;
|
||||
let offset = 0;
|
||||
const regex = new RegExp(JSON_FENCE_RE.source, "g");
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const fullMatch = match[0]; // ```json\n...\n```
|
||||
const innerJson = match[1];
|
||||
const compacted = tryCompactJson(innerJson.trim(), minRows);
|
||||
if (compacted !== null) {
|
||||
const start = match.index + offset;
|
||||
const end = start + fullMatch.length;
|
||||
result = result.slice(0, start) + compacted + result.slice(end);
|
||||
offset += compacted.length - fullMatch.length;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process messages array in place (returns new array).
|
||||
* Skips system messages. Returns changed flag.
|
||||
*/
|
||||
export function crushMessages(
|
||||
messages: MessageLike[],
|
||||
minRows: number = DEFAULT_MIN_ROWS
|
||||
): { messages: MessageLike[]; changed: boolean } {
|
||||
let changed = false;
|
||||
|
||||
const result = messages.map((msg): MessageLike => {
|
||||
// Guard: never touch system messages
|
||||
if (msg.role === "system") return { ...msg };
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
const crushed = crushText(msg.content, minRows);
|
||||
if (crushed !== msg.content) {
|
||||
changed = true;
|
||||
return { ...msg, content: crushed };
|
||||
}
|
||||
return { ...msg };
|
||||
}
|
||||
|
||||
if (Array.isArray(msg.content)) {
|
||||
let contentChanged = false;
|
||||
const newContent = msg.content.map((part: Record<string, unknown>) => {
|
||||
if (part["type"] !== "text" || typeof part["text"] !== "string") return part;
|
||||
const crushed = crushText(part["text"] as string, minRows);
|
||||
if (crushed !== part["text"]) {
|
||||
contentChanged = true;
|
||||
return { ...part, text: crushed };
|
||||
}
|
||||
return part;
|
||||
});
|
||||
if (contentChanged) {
|
||||
changed = true;
|
||||
return { ...msg, content: newContent };
|
||||
}
|
||||
return { ...msg };
|
||||
}
|
||||
|
||||
return { ...msg };
|
||||
});
|
||||
|
||||
return { messages: result, changed };
|
||||
}
|
||||
253
open-sse/services/compression/engines/headroom/tabular.ts
Normal file
253
open-sse/services/compression/engines/headroom/tabular.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* tabular.ts — dependency-free columnar encoder/decoder for homogeneous JSON arrays.
|
||||
*
|
||||
* Format design (lossless, GP5' inspired):
|
||||
*
|
||||
* ```omni-tabular
|
||||
* [N rows]
|
||||
* __kinds__,s,n,n,b ← type hints row (hidden metadata)
|
||||
* col1,col2,col3,col4 ← header row
|
||||
* "\"val1\"",42,99,true ← data rows (strings JSON-encoded to avoid multi-line issues)
|
||||
* ...
|
||||
* ```
|
||||
*
|
||||
* Cell encoding rules (CSV-style, lossless):
|
||||
* - Strings: JSON.stringify-ed (so newlines → \\n, quotes → \\", etc.), then
|
||||
* encodeCell-quoted if the JSON repr contains comma, quote, newline, or leading/trailing space.
|
||||
* - Numbers / booleans / null: written as their unambiguous string representation.
|
||||
* - Objects or arrays (nested): JSON.stringify-ed (same as strings for encoding), then quoted.
|
||||
*
|
||||
* Decode rules:
|
||||
* - Kind `s`: cell value is a JSON string literal → JSON.parse restores original string.
|
||||
* - Kind `n`: Number(cell).
|
||||
* - Kind `b`: cell === "true".
|
||||
* - Kind `null`: null.
|
||||
* - Kind `j`: JSON.parse restores original object/array.
|
||||
*
|
||||
* All special characters (newlines, commas, quotes, tabs) inside string/json cells are
|
||||
* safely contained inside the JSON representation, which is then CSV-quoted. The line
|
||||
* split in the decoder always operates on the outer structure, which is free of raw
|
||||
* newlines. This is the key losslessness guarantee.
|
||||
*
|
||||
* Note: TOON (@toon-format/toon, ~24.6k★) could be a future drop-in encoder here for
|
||||
* potentially better compression on heterogeneous shapes; this plain columnar encoder is
|
||||
* used instead to avoid npm dependencies (supply-chain safety).
|
||||
*/
|
||||
|
||||
export const TABULAR_FENCE_OPEN = "```omni-tabular";
|
||||
export const TABULAR_FENCE_CLOSE = "```";
|
||||
export const TABULAR_MARKER_RE = /```omni-tabular\n(\[[\d]+ rows\]\n[\s\S]*?)\n```/g;
|
||||
|
||||
/** Cell type hints stored in the second metadata row. */
|
||||
type CellKind = "s" | "n" | "b" | "null" | "j"; // string / number / boolean / null / json-object-or-array
|
||||
|
||||
export function kindOf(val: unknown): CellKind {
|
||||
if (val === null) return "null";
|
||||
if (typeof val === "number") return "n";
|
||||
if (typeof val === "boolean") return "b";
|
||||
if (typeof val === "object") return "j"; // object or array
|
||||
return "s";
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a raw string as a CSV cell.
|
||||
* Wraps in `"` and doubles any internal `"` characters (RFC 4180).
|
||||
* Used for strings that might contain commas, quotes, or spaces.
|
||||
*/
|
||||
function encodeCell(raw: string): string {
|
||||
const needsQuoting =
|
||||
raw.includes(",") ||
|
||||
raw.includes('"') ||
|
||||
raw.includes("\n") ||
|
||||
raw.includes("\r") ||
|
||||
raw.startsWith(" ") ||
|
||||
raw.endsWith(" ");
|
||||
if (!needsQuoting) return raw;
|
||||
return '"' + raw.replace(/"/g, '""') + '"';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one CSV row (a single line) into cells.
|
||||
* Handles RFC 4180 quoting with `""` escaping.
|
||||
* Lines are guaranteed to not contain unescaped newlines (all newlines are inside
|
||||
* JSON-escaped strings, so the CSV layer never sees a real newline within a cell).
|
||||
*/
|
||||
export function parseCsvRow(line: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let i = 0;
|
||||
const len = line.length;
|
||||
|
||||
while (i < len) {
|
||||
if (line[i] === '"') {
|
||||
// Quoted cell: consume up to the closing unescaped quote
|
||||
let cell = "";
|
||||
i++; // skip opening quote
|
||||
while (i < len) {
|
||||
if (line[i] === '"') {
|
||||
if (i + 1 < len && line[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i += 2;
|
||||
} else {
|
||||
i++; // skip closing quote
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
cell += line[i++];
|
||||
}
|
||||
}
|
||||
cells.push(cell);
|
||||
// Skip trailing comma
|
||||
if (i < len && line[i] === ",") {
|
||||
i++;
|
||||
// Trailing comma at end of line → empty cell
|
||||
if (i === len) cells.push("");
|
||||
}
|
||||
} else {
|
||||
// Unquoted cell: read until comma or end
|
||||
const start = i;
|
||||
while (i < len && line[i] !== ",") i++;
|
||||
cells.push(line.slice(start, i));
|
||||
if (i < len) {
|
||||
i++; // skip comma
|
||||
// Trailing comma at end of line → empty cell
|
||||
if (i === len) cells.push("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a homogeneous array of objects to a compact columnar block (without fence).
|
||||
*
|
||||
* The block format is:
|
||||
* [N rows]
|
||||
* __kinds__,<kind0>,<kind1>,...
|
||||
* <key0>,<key1>,...
|
||||
* <row0-cell0>,<row0-cell1>,...
|
||||
* ...
|
||||
*/
|
||||
export function encodeTabularBlock(arr: Record<string, unknown>[]): string {
|
||||
if (arr.length === 0) return "";
|
||||
|
||||
// Collect union of all keys (first-seen order)
|
||||
const keysSet = new Set<string>();
|
||||
for (const row of arr) {
|
||||
for (const k of Object.keys(row)) keysSet.add(k);
|
||||
}
|
||||
const keys = Array.from(keysSet);
|
||||
const n = arr.length;
|
||||
|
||||
// Determine kinds from first row (homogeneous array → kinds are uniform)
|
||||
const kinds: CellKind[] = keys.map((k) => kindOf(arr[0][k]));
|
||||
|
||||
const kindsRow = "__kinds__," + kinds.join(",");
|
||||
const headerRow = keys.map(encodeCell).join(",");
|
||||
|
||||
const dataRows = arr.map((row) => {
|
||||
return keys
|
||||
.map((k) => {
|
||||
const val = row[k];
|
||||
const kind = kindOf(val);
|
||||
if (kind === "null") return "null";
|
||||
if (kind === "n") return String(val);
|
||||
if (kind === "b") return String(val);
|
||||
// kind "s" or "j": JSON.stringify the value, then CSV-quote the result.
|
||||
// JSON.stringify a string gives '"the string with \\n escaped"'
|
||||
// JSON.stringify an object/array gives the full JSON representation.
|
||||
// Both are safe to CSV-quote (no raw newlines in the serialized output).
|
||||
return encodeCell(JSON.stringify(val));
|
||||
})
|
||||
.join(",");
|
||||
});
|
||||
|
||||
return `[${n} rows]\n${kindsRow}\n${headerRow}\n${dataRows.join("\n")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a columnar block back to the original array.
|
||||
* Input is the raw block content (without the fence markers).
|
||||
*/
|
||||
export function decodeTabularBlock(block: string): Record<string, unknown>[] {
|
||||
const lines = block.split("\n");
|
||||
if (lines.length < 3) return [];
|
||||
|
||||
// Line 0: [N rows]
|
||||
const countLine = lines[0];
|
||||
const countMatch = countLine.match(/^\[(\d+) rows\]$/);
|
||||
if (!countMatch) return [];
|
||||
const n = parseInt(countMatch[1], 10);
|
||||
|
||||
// Line 1: kinds row
|
||||
const kindsLine = lines[1];
|
||||
if (!kindsLine.startsWith("__kinds__,")) return [];
|
||||
const kindsRaw = parseCsvRow(kindsLine.slice("__kinds__,".length));
|
||||
const kinds = kindsRaw as CellKind[];
|
||||
|
||||
// Line 2: header row
|
||||
const headerLine = lines[2];
|
||||
const keys = parseCsvRow(headerLine);
|
||||
|
||||
// Lines 3..3+n-1: data rows
|
||||
const result: Record<string, unknown>[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const rowLine = lines[3 + i];
|
||||
if (rowLine === undefined) break;
|
||||
const cells = parseCsvRow(rowLine);
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (let j = 0; j < keys.length; j++) {
|
||||
const key = keys[j];
|
||||
const cell = cells[j] ?? "";
|
||||
const kind = kinds[j];
|
||||
if (kind === "null") {
|
||||
obj[key] = null;
|
||||
} else if (kind === "n") {
|
||||
obj[key] = Number(cell);
|
||||
} else if (kind === "b") {
|
||||
obj[key] = cell === "true";
|
||||
} else {
|
||||
// kind "s" or "j": cell is a JSON-encoded value → JSON.parse restores it.
|
||||
try {
|
||||
obj[key] = JSON.parse(cell);
|
||||
} catch {
|
||||
obj[key] = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push(obj);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a tabular block content in the omni-tabular fence.
|
||||
*/
|
||||
export function wrapTabular(blockContent: string): string {
|
||||
return `${TABULAR_FENCE_OPEN}\n${blockContent}\n${TABULAR_FENCE_CLOSE}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API — encode an array to a fenced tabular string.
|
||||
*/
|
||||
export function encodeTabular(arr: Record<string, unknown>[]): string {
|
||||
return wrapTabular(encodeTabularBlock(arr));
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API — decode a fenced tabular string back to an array.
|
||||
*/
|
||||
export function decodeTabular(text: string): Record<string, unknown>[] {
|
||||
// Strip fence markers if present
|
||||
let inner = text;
|
||||
if (inner.startsWith(TABULAR_FENCE_OPEN + "\n")) {
|
||||
inner = inner.slice(TABULAR_FENCE_OPEN.length + 1);
|
||||
}
|
||||
if (inner.endsWith("\n" + TABULAR_FENCE_CLOSE)) {
|
||||
inner = inner.slice(0, inner.length - TABULAR_FENCE_CLOSE.length - 1);
|
||||
} else if (inner.endsWith(TABULAR_FENCE_CLOSE)) {
|
||||
inner = inner.slice(0, inner.length - TABULAR_FENCE_CLOSE.length);
|
||||
}
|
||||
return decodeTabularBlock(inner);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { registerCompressionEngine, getCompressionEngine } from "./registry.ts";
|
||||
import { aggressiveEngine, cavemanEngine, liteEngine, ultraEngine } from "./cavemanAdapter.ts";
|
||||
import { rtkEngine } from "./rtk/index.ts";
|
||||
import { sessionDedupEngine } from "./session-dedup/index.ts";
|
||||
import { headroomEngine } from "./headroom/index.ts";
|
||||
import { ccrEngine } from "./ccr/index.ts";
|
||||
import { llmlinguaEngine } from "./llmlingua/index.ts";
|
||||
|
||||
let registered = false;
|
||||
|
||||
@@ -15,6 +19,10 @@ export function registerBuiltinCompressionEngines(): void {
|
||||
{ id: "aggressive", engine: aggressiveEngine },
|
||||
{ id: "ultra", engine: ultraEngine },
|
||||
{ id: "rtk", engine: rtkEngine },
|
||||
{ id: "session-dedup", engine: sessionDedupEngine },
|
||||
{ id: "headroom", engine: headroomEngine },
|
||||
{ id: "ccr", engine: ccrEngine },
|
||||
{ id: "llmlingua", engine: llmlinguaEngine },
|
||||
];
|
||||
|
||||
for (const { id, engine } of engines) {
|
||||
|
||||
378
open-sse/services/compression/engines/llmlingua/index.ts
Normal file
378
open-sse/services/compression/engines/llmlingua/index.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* LLMLingua async compression engine (L1/L3 — F2.1).
|
||||
*
|
||||
* Implements the `CompressionEngine` contract with an async path (`applyAsync`)
|
||||
* that compresses prose via a pluggable backend.
|
||||
*
|
||||
* ## Design
|
||||
*
|
||||
* ### Backend abstraction
|
||||
* `LlmlinguaBackend` is a simple `(text: string) => Promise<string>` contract.
|
||||
* Tests inject a fake backend via `setLlmlinguaBackend()`. Production code uses
|
||||
* `workerBackend` from `./worker.ts` (a stub today — see that file for the L1
|
||||
* VPS-validation follow-up before the real ONNX model is wired).
|
||||
*
|
||||
* ### Code-block protection (inviolable)
|
||||
* Before any prose segment reaches the backend, `extractPreservedBlocks` from
|
||||
* `preservation.ts` is used to tombstone fenced code blocks (and other
|
||||
* preserved constructs) into placeholder strings. The prose between placeholders
|
||||
* is what gets sent to the backend. Code blocks are re-stitched verbatim into
|
||||
* the output. This is done REGARDLESS of what the backend does — the engine
|
||||
* physically never passes code to the model.
|
||||
*
|
||||
* ### Fail-open points (all errors → original body)
|
||||
* 1. Backend rejects for a prose segment → catch → segment kept as-is.
|
||||
* 2. Any unexpected error in `applyAsync` → outer try/catch → original body,
|
||||
* compressed:false, stats:null returned (no throw).
|
||||
*
|
||||
* ### Sync `apply` (pass-through)
|
||||
* The sync path is always a no-op (`compressed:false`, original body). The real
|
||||
* work is async-only; `applyStackedCompressionAsync` calls `applyAsync` when
|
||||
* present and falls back to `apply` otherwise.
|
||||
*
|
||||
* ### stackPriority
|
||||
* 35 — runs after structural engines (CCR=4, session-dedup=3, headroom=15,
|
||||
* caveman=20) but before ultra (40). Semantic pruning is most effective after
|
||||
* simpler structural compression has already reduced noise.
|
||||
*
|
||||
* ### Production follow-up (L1 — not yet done)
|
||||
* Replace `workerBackend` stub in `./worker.ts` with real `@atjsh/llmlingua-2`
|
||||
* (MobileBERT ONNX, 99 MB) in a worker_threads.Worker. Gate by minimum token
|
||||
* count (≥2 k estimated). Validate on VPS per Hard Rule #18. See `./worker.ts`
|
||||
* for the exact spec.
|
||||
*/
|
||||
|
||||
import { createCompressionStats } from "../../stats.ts";
|
||||
import { extractPreservedBlocks } from "../../preservation.ts";
|
||||
import type {
|
||||
CompressionEngine,
|
||||
CompressionEngineApplyOptions,
|
||||
EngineConfigField,
|
||||
EngineValidationResult,
|
||||
} from "../types.ts";
|
||||
import type { CompressionResult } from "../../types.ts";
|
||||
import { workerBackend } from "./worker.ts";
|
||||
|
||||
// ─── backend abstraction ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A backend takes a prose text segment and returns a compressed version.
|
||||
* Any rejection or error MUST be caught by the caller; the engine fail-opens.
|
||||
*/
|
||||
export type LlmlinguaBackend = (text: string) => Promise<string>;
|
||||
|
||||
/** Module-level injectable backend (null = use default production backend). */
|
||||
let _backend: LlmlinguaBackend | null = null;
|
||||
|
||||
/**
|
||||
* Override the backend — intended for tests only.
|
||||
* Pass `null` to restore the default production backend.
|
||||
*/
|
||||
export function setLlmlinguaBackend(b: LlmlinguaBackend | null): void {
|
||||
_backend = b;
|
||||
}
|
||||
|
||||
/** Resolve the active backend: injected fake (for tests) or production stub. */
|
||||
function resolveBackend(): LlmlinguaBackend {
|
||||
return _backend ?? workerBackend;
|
||||
}
|
||||
|
||||
// ─── prose/code splitting ─────────────────────────────────────────────────────
|
||||
|
||||
interface TextSegment {
|
||||
kind: "prose" | "preserved";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `text` into alternating prose / preserved segments using
|
||||
* `extractPreservedBlocks` from preservation.ts.
|
||||
*
|
||||
* Preserved blocks (fenced code, inline code, math, headings, URLs, etc.)
|
||||
* are returned verbatim and are NEVER sent to the backend. Only the prose
|
||||
* segments between preserved blocks are eligible for compression.
|
||||
*
|
||||
* Implementation: `extractPreservedBlocks` returns a text with NUL-delimited
|
||||
* placeholder strings in place of preserved blocks, plus a `blocks` array
|
||||
* mapping placeholder → original content. We split on those placeholders to
|
||||
* interleave prose and preserved segments.
|
||||
*/
|
||||
function splitProseAndPreserved(text: string): TextSegment[] {
|
||||
const { text: withPlaceholders, blocks } = extractPreservedBlocks(text);
|
||||
|
||||
if (blocks.length === 0) {
|
||||
return [{ kind: "prose", text }];
|
||||
}
|
||||
|
||||
const segments: TextSegment[] = [];
|
||||
const placeholderToOriginal = new Map(blocks.map((b) => [b.placeholder, b.content]));
|
||||
|
||||
// Build a pattern matching any placeholder. Placeholders contain NUL
|
||||
// characters so they are guaranteed not to appear in user text.
|
||||
const escapedPhs = blocks.map((b) => b.placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
const splitRe = new RegExp(`(${escapedPhs.join("|")})`, "g");
|
||||
|
||||
const parts = withPlaceholders.split(splitRe);
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
const original = placeholderToOriginal.get(part);
|
||||
if (original !== undefined) {
|
||||
segments.push({ kind: "preserved", text: original });
|
||||
} else {
|
||||
segments.push({ kind: "prose", text: part });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// ─── message processing ───────────────────────────────────────────────────────
|
||||
|
||||
type MessageLike = {
|
||||
role?: string;
|
||||
content?: string | Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compress a single prose string via the backend.
|
||||
* On any error, fail-open and return the original text.
|
||||
*/
|
||||
async function compressProseText(
|
||||
text: string,
|
||||
backend: LlmlinguaBackend
|
||||
): Promise<{ text: string; didCompress: boolean }> {
|
||||
if (!text.trim()) return { text, didCompress: false };
|
||||
try {
|
||||
const compressed = await backend(text);
|
||||
// Accept only if it actually gets shorter (reject no-ops or expansions)
|
||||
if (typeof compressed === "string" && compressed.length < text.length) {
|
||||
return { text: compressed, didCompress: true };
|
||||
}
|
||||
return { text, didCompress: false };
|
||||
} catch {
|
||||
// Fail-open: backend error → original segment
|
||||
return { text, didCompress: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single message text string:
|
||||
* 1. Split into prose / preserved segments.
|
||||
* 2. Compress each prose segment via the backend (fail-open per segment).
|
||||
* 3. Re-stitch with preserved segments verbatim.
|
||||
* Returns the new text and whether any compression happened.
|
||||
*/
|
||||
async function compressMessageText(
|
||||
text: string,
|
||||
backend: LlmlinguaBackend
|
||||
): Promise<{ text: string; didCompress: boolean }> {
|
||||
const segments = splitProseAndPreserved(text);
|
||||
let anyCompressed = false;
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg.kind === "preserved") {
|
||||
// Never send preserved content (code, math, etc.) to the backend
|
||||
parts.push(seg.text);
|
||||
} else {
|
||||
const { text: out, didCompress } = await compressProseText(seg.text, backend);
|
||||
parts.push(out);
|
||||
if (didCompress) anyCompressed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { text: parts.join(""), didCompress: anyCompressed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all non-system messages, compressing prose in each text content part.
|
||||
* Fail-opens per message: any unexpected error → that message kept as-is.
|
||||
*/
|
||||
async function processMessages(
|
||||
messages: MessageLike[],
|
||||
backend: LlmlinguaBackend
|
||||
): Promise<{ messages: MessageLike[]; compressedCount: number }> {
|
||||
let compressedCount = 0;
|
||||
const result: MessageLike[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Never touch system messages
|
||||
if (msg.role === "system") {
|
||||
result.push({ ...msg });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof msg.content === "string") {
|
||||
const { text, didCompress } = await compressMessageText(msg.content, backend);
|
||||
if (didCompress) {
|
||||
compressedCount++;
|
||||
result.push({ ...msg, content: text });
|
||||
} else {
|
||||
result.push({ ...msg });
|
||||
}
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
let changed = false;
|
||||
const newContent: Array<Record<string, unknown>> = [];
|
||||
for (const part of msg.content) {
|
||||
if (part["type"] === "text" && typeof part["text"] === "string") {
|
||||
const { text, didCompress } = await compressMessageText(
|
||||
part["text"] as string,
|
||||
backend
|
||||
);
|
||||
if (didCompress) {
|
||||
changed = true;
|
||||
compressedCount++;
|
||||
newContent.push({ ...part, text });
|
||||
} else {
|
||||
newContent.push(part);
|
||||
}
|
||||
} else {
|
||||
newContent.push(part);
|
||||
}
|
||||
}
|
||||
result.push(changed ? { ...msg, content: newContent } : { ...msg });
|
||||
} else {
|
||||
result.push({ ...msg });
|
||||
}
|
||||
} catch {
|
||||
// Fail-open per message: keep original
|
||||
result.push({ ...msg });
|
||||
}
|
||||
}
|
||||
|
||||
return { messages: result, compressedCount };
|
||||
}
|
||||
|
||||
// ─── config schema ────────────────────────────────────────────────────────────
|
||||
|
||||
const LLMLINGUA_SCHEMA: EngineConfigField[] = [
|
||||
{
|
||||
key: "enabled",
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
defaultValue: true,
|
||||
},
|
||||
];
|
||||
|
||||
function validateLlmlinguaConfig(config: Record<string, unknown>): EngineValidationResult {
|
||||
const errors: string[] = [];
|
||||
if (config["enabled"] !== undefined && typeof config["enabled"] !== "boolean") {
|
||||
errors.push("enabled must be a boolean");
|
||||
}
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ─── engine export ─────────────────────────────────────────────────────────────
|
||||
|
||||
const ENGINE_ID = "llmlingua";
|
||||
|
||||
export const llmlinguaEngine: CompressionEngine = {
|
||||
id: ENGINE_ID,
|
||||
name: "LLMLingua-2 (Semantic Pruning)",
|
||||
description:
|
||||
"Async semantic token pruning via LLMLingua-2 (ONNX/worker-thread backend). " +
|
||||
"Compresses prose in non-system messages; fenced code blocks and other preserved " +
|
||||
"constructs are never altered. Fail-opens on any backend error. Production backend: " +
|
||||
"vendored @atjsh/llmlingua-2 (MobileBERT 99 MB) in a worker thread — see " +
|
||||
"./worker.ts for the L1 follow-up spec (VPS validation required per Hard Rule #18).",
|
||||
icon: "brain",
|
||||
targets: ["messages"],
|
||||
stackable: true,
|
||||
// stackPriority 35: runs after structural engines (CCR=4, session-dedup=3,
|
||||
// headroom=15, caveman=20) but before ultra (40). Semantic pruning is more
|
||||
// effective on already-structurally-compressed text.
|
||||
stackPriority: 35,
|
||||
metadata: {
|
||||
id: ENGINE_ID,
|
||||
name: "LLMLingua-2 (Semantic Pruning)",
|
||||
description:
|
||||
"ONNX-based semantic token classification. Compresses prose only; " +
|
||||
"code blocks and preserved constructs are protected. Fail-open on " +
|
||||
"model/worker error.",
|
||||
inputScope: "messages",
|
||||
targetLatencyMs: 200,
|
||||
supportsPreview: false,
|
||||
stable: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Synchronous pass-through.
|
||||
*
|
||||
* The real compression is async-only (worker-thread model). The sync path
|
||||
* exists only so this engine is safe in sync stacked pipelines — it does
|
||||
* nothing and returns the body unchanged. `applyStackedCompressionAsync`
|
||||
* will call `applyAsync` instead.
|
||||
*/
|
||||
apply(body: Record<string, unknown>): CompressionResult {
|
||||
return { body, compressed: false, stats: null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Async compression path.
|
||||
*
|
||||
* For each non-system message, splits text into prose/preserved segments,
|
||||
* sends only prose to the backend, and re-stitches with preserved segments
|
||||
* (fenced code, inline code, math, etc.) untouched.
|
||||
*
|
||||
* Fail-open contract:
|
||||
* - Backend rejection/error per prose segment → segment kept as-is.
|
||||
* - Any unexpected outer error → original body returned, no throw.
|
||||
*/
|
||||
async applyAsync(
|
||||
body: Record<string, unknown>,
|
||||
options?: CompressionEngineApplyOptions
|
||||
): Promise<CompressionResult> {
|
||||
const stepConfig = options?.stepConfig ?? {};
|
||||
if (stepConfig["enabled"] === false) {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
const messages = body["messages"];
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const backend = resolveBackend();
|
||||
const start = performance.now();
|
||||
const { messages: newMessages, compressedCount } = await processMessages(
|
||||
messages as MessageLike[],
|
||||
backend
|
||||
);
|
||||
|
||||
if (compressedCount === 0) {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
const newBody: Record<string, unknown> = { ...body, messages: newMessages };
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const stats = createCompressionStats(
|
||||
body,
|
||||
newBody,
|
||||
"stacked",
|
||||
[ENGINE_ID],
|
||||
[`llmlingua-compressed-${compressedCount}-messages`],
|
||||
durationMs
|
||||
);
|
||||
|
||||
return { body: newBody, compressed: true, stats };
|
||||
} catch {
|
||||
// Outer fail-open: any unexpected error → return original body unchanged
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
},
|
||||
|
||||
compress(body: Record<string, unknown>, config?: Record<string, unknown>): CompressionResult {
|
||||
return this.apply(body, { stepConfig: config ?? {} });
|
||||
},
|
||||
|
||||
getConfigSchema(): EngineConfigField[] {
|
||||
return LLMLINGUA_SCHEMA;
|
||||
},
|
||||
|
||||
validateConfig(config: Record<string, unknown>): EngineValidationResult {
|
||||
return validateLlmlinguaConfig(config);
|
||||
},
|
||||
};
|
||||
48
open-sse/services/compression/engines/llmlingua/worker.ts
Normal file
48
open-sse/services/compression/engines/llmlingua/worker.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* LLMLingua-2 worker-thread stub (production path — NOT loaded in tests).
|
||||
*
|
||||
* PRODUCTION FOLLOW-UP (L1):
|
||||
* Replace the stub body below with the real MobileBERT ONNX inference via
|
||||
* the vendored/pinned `@atjsh/llmlingua-2` package running in a worker thread.
|
||||
* The package must be vendored (pinned) at a specific version and ONNX model
|
||||
* hash verified before loading. The worker MUST remain fail-open — any failure
|
||||
* to load the model, initialise the pipeline, or classify tokens MUST result
|
||||
* in returning the original text unchanged (not throwing to the caller).
|
||||
*
|
||||
* VPS validation (Hard Rule #18): before enabling the real model, deploy to
|
||||
* root@192.168.0.15 and run a documented live test confirming:
|
||||
* (a) prose text is shorter after compression,
|
||||
* (b) a message containing a fenced code block produces identical code bytes,
|
||||
* (c) OOM / missing model → fail-open (original text returned, no crash).
|
||||
*
|
||||
* NEVER apply to code blocks — the caller (index.ts) tombstones code blocks
|
||||
* before calling this backend; this worker sees prose-only segments.
|
||||
*
|
||||
* CURRENT STATE (stub):
|
||||
* This module exports a LlmlinguaBackend function that always fail-opens
|
||||
* (returns the original text unchanged), so the engine can be registered and
|
||||
* used in stacked pipelines without any ONNX dependency. It will produce
|
||||
* compressed:false for every call, which is the correct safe default until the
|
||||
* real model is wired up.
|
||||
*/
|
||||
|
||||
import type { LlmlinguaBackend } from "./index.ts";
|
||||
|
||||
/**
|
||||
* Production worker backend stub.
|
||||
*
|
||||
* Currently fail-opens unconditionally. When the real `@atjsh/llmlingua-2`
|
||||
* package is vendored and validated on the VPS, replace the body below with
|
||||
* the actual worker-thread dispatch:
|
||||
*
|
||||
* ```ts
|
||||
* // 1. Spawn / reuse a worker_threads.Worker running the ONNX pipeline.
|
||||
* // 2. Post the text to the worker via MessageChannel.
|
||||
* // 3. Await the reply with a per-call timeout (e.g. 5 000 ms).
|
||||
* // 4. On any error / timeout → return text (fail-open).
|
||||
* ```
|
||||
*/
|
||||
export const workerBackend: LlmlinguaBackend = async (text: string): Promise<string> => {
|
||||
// Stub: model not yet wired — fail-open by returning the original text.
|
||||
return text;
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import ts from "typescript";
|
||||
|
||||
export type CodeLanguage =
|
||||
| "javascript"
|
||||
| "typescript"
|
||||
@@ -50,6 +52,53 @@ export function detectCodeLanguage(text: string): CodeLanguage {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove JS/TS comments using the TypeScript parser (R1/N3). Using the parser —
|
||||
* not a regex or the raw scanner — means string, template and regex literals are
|
||||
* never mistaken for comments (the scanner alone cannot tell a regex from a
|
||||
* division without parser context). Bails out entirely when JSX is present so
|
||||
* JSX expression-container comments are never corrupted.
|
||||
*/
|
||||
function stripJsTsComments(text: string): string {
|
||||
const source = ts.createSourceFile(
|
||||
"snippet.tsx",
|
||||
text,
|
||||
ts.ScriptTarget.Latest,
|
||||
/* setParentNodes */ true,
|
||||
ts.ScriptKind.TSX
|
||||
);
|
||||
|
||||
let hasJsx = false;
|
||||
const detectJsx = (node: ts.Node): void => {
|
||||
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
|
||||
hasJsx = true;
|
||||
return;
|
||||
}
|
||||
if (!hasJsx) ts.forEachChild(node, detectJsx);
|
||||
};
|
||||
detectJsx(source);
|
||||
if (hasJsx) return text;
|
||||
|
||||
const ranges = new Map<number, ts.CommentRange>();
|
||||
const collect = (node: ts.Node): void => {
|
||||
for (const range of ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []) {
|
||||
ranges.set(range.pos, range);
|
||||
}
|
||||
for (const range of ts.getTrailingCommentRanges(text, node.getEnd()) ?? []) {
|
||||
ranges.set(range.pos, range);
|
||||
}
|
||||
ts.forEachChild(node, collect);
|
||||
};
|
||||
collect(source);
|
||||
|
||||
if (ranges.size === 0) return text;
|
||||
let result = text;
|
||||
for (const range of [...ranges.values()].sort((a, b) => b.pos - a.pos)) {
|
||||
result = result.slice(0, range.pos) + result.slice(range.end);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function stripCode(
|
||||
text: string,
|
||||
language: CodeLanguage = "unknown",
|
||||
@@ -61,7 +110,10 @@ export function stripCode(
|
||||
} {
|
||||
const resolvedLanguage = language === "unknown" ? detectCodeLanguage(text) : language;
|
||||
const opts: Required<CodeStripperOptions> = {
|
||||
removeComments: options.removeComments !== false,
|
||||
// Opt-in (default false): historically this flag was read but never applied,
|
||||
// so the effective behaviour was "preserve". Keeping the default at preserve
|
||||
// avoids a silent production change; callers opt in with removeComments:true.
|
||||
removeComments: options.removeComments === true,
|
||||
removeEmptyLines: options.removeEmptyLines !== false,
|
||||
collapseWhitespace: options.collapseWhitespace !== false,
|
||||
preserveDocstrings: options.preserveDocstrings === true,
|
||||
@@ -69,6 +121,13 @@ export function stripCode(
|
||||
const originalLines = text.split(/\r?\n/).length;
|
||||
let result = text;
|
||||
|
||||
if (
|
||||
opts.removeComments &&
|
||||
(resolvedLanguage === "javascript" || resolvedLanguage === "typescript")
|
||||
) {
|
||||
result = stripJsTsComments(result);
|
||||
}
|
||||
|
||||
if (opts.removeEmptyLines) result = result.replace(/^\s*$(?:\r?\n)?/gm, "");
|
||||
if (opts.collapseWhitespace) {
|
||||
result = result
|
||||
|
||||
150
open-sse/services/compression/engines/rtk/discover.ts
Normal file
150
open-sse/services/compression/engines/rtk/discover.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* RTK discover — R7 / N7
|
||||
*
|
||||
* Pure function, no I/O. DB wiring (reading from call_logs) is a follow-up task.
|
||||
* The function takes an in-memory array of CommandSample values so it can be
|
||||
* fully unit-tested with synthetic data.
|
||||
*
|
||||
* Finds line-templates that recur across many samples and are therefore good
|
||||
* DROP candidates (high-frequency noise) or PRESERVE candidates (errors/summaries).
|
||||
*/
|
||||
|
||||
import { normalizeLine } from "./grouper.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extended normalization for cross-sample mining
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extends grouper.ts's normalizeLine with additional substitutions relevant
|
||||
* for mining command output across many samples:
|
||||
*
|
||||
* 8. npm/pip package names + versions: `left-pad@1.2.3` → `<PKG>@<N>`
|
||||
* (the grouper replaces the version number but not the package name, so
|
||||
* different packages give different templates — this collapses them).
|
||||
* 9. Exit codes and error codes: `E404`, `ENOENT`, `E2BIG` → `<CODE>`
|
||||
* 10. Numeric suffixes (time/size): `5s`, `120ms`, `4.2kb` → `<N><SFX>`
|
||||
* collapsed to a single placeholder so "in 5s" and "in 2s" normalise alike.
|
||||
*
|
||||
* Applies AFTER grouper normalizeLine so all its substitutions are already done.
|
||||
*/
|
||||
export function discoverNormalizeLine(line: string): string {
|
||||
let s = normalizeLine(line);
|
||||
// npm/pip package identifiers with version: word@version → <PKG>@<N>
|
||||
// Handles both original (left-pad@1.2.3) and already-normalised (left-pad@<N>)
|
||||
// Bounded quantifiers ({0,N}) are mandatory: `[\w]` ⊂ `[\w.-]` followed by a
|
||||
// required `@` is the classic catastrophic-backtracking shape on a long
|
||||
// word-char run with no `@` (CLAUDE.md ReDoS rule). Real package names are short.
|
||||
s = s.replace(/[\w][\w.-]{0,128}@(?:<N>|\d[\w.-]{0,64})/g, "<PKG>@<N>");
|
||||
// Error/exit codes like E404, ENOENT, E2BIG, EACCES
|
||||
s = s.replace(/\bE[A-Z0-9]{2,}\b/g, "<CODE>");
|
||||
// Numeric values with attached units: 5s, 120ms, 4kb, 12MB, 0.5s, etc.
|
||||
s = s.replace(/\b\d+(?:\.\d+)?(?:ms|[smhd]|[kmg]b?)\b/gi, "<N>");
|
||||
// Also collapse the already-substituted <N> followed by a unit suffix leftover
|
||||
s = s.replace(/<N>(?:ms|[smhd]|[kmg]b?)\b/gi, "<N>");
|
||||
// Collapse repeated whitespace again after substitutions
|
||||
s = s.replace(/\s+/g, " ").trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A captured command invocation with its combined stdout/stderr text. */
|
||||
export interface CommandSample {
|
||||
/** The command string as typed by the user / agent, e.g. "npm install". */
|
||||
command: string;
|
||||
/** The full output (stdout + stderr) of that invocation. */
|
||||
output: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A recurring line-template surfaced by discoverRepeatedNoise.
|
||||
* The `pattern` is a regex-compatible string derived from the normalised line.
|
||||
*/
|
||||
export interface NoiseCandidate {
|
||||
/** A regex-compatible string matching the recurring line template. */
|
||||
pattern: string;
|
||||
/** Number of samples in which this normalised pattern appeared at least once. */
|
||||
hits: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a normalised line (with <N> placeholders) to a regex pattern that
|
||||
* is safe to use in `new RegExp(pattern, "i")`.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Escape all regex special chars in the normalised form.
|
||||
* 2. Replace the literal placeholder `<N>` (already normalised) with `[\S]+`
|
||||
* so the pattern matches the volatile fragments in real lines.
|
||||
* 3. Anchor loosely with a leading `^` so it matches from the start of a line
|
||||
* (RTK dropPatterns are matched line-by-line via a startsWith / ^-anchored regex).
|
||||
*/
|
||||
function normalizedToPattern(normalised: string): string {
|
||||
// Escape regex special chars (< and > are not special, so placeholders survive intact)
|
||||
const escaped = normalised.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
|
||||
// Replace placeholder tokens with wildcard regex fragments
|
||||
const withWildcards = escaped
|
||||
.replace(/<N>/g, "[\\S]+")
|
||||
.replace(/<PKG>/g, "[\\S]+")
|
||||
.replace(/<CODE>/g, "[A-Z][A-Z0-9]+");
|
||||
return `^${withWildcards}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scan a set of command output samples and return a ranked list of line-templates
|
||||
* that appear frequently enough to be useful DROP candidates.
|
||||
*
|
||||
* Only templates that appear in MORE THAN ONE sample are included (single-occurrence
|
||||
* lines are noise-specific, not structural noise). Results are sorted descending
|
||||
* by hit count.
|
||||
*
|
||||
* No I/O — pass samples from any source (DB, fixtures, user input).
|
||||
*/
|
||||
export function discoverRepeatedNoise(samples: CommandSample[]): NoiseCandidate[] {
|
||||
if (samples.length === 0) return [];
|
||||
|
||||
// Count how many samples contain each normalised line (at least once per sample).
|
||||
const hitsBySample = new Map<string, Set<number>>();
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const lines = samples[i].output.split(/\r?\n/);
|
||||
const seenInThisSample = new Set<string>();
|
||||
|
||||
for (const raw of lines) {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
const norm = discoverNormalizeLine(trimmed);
|
||||
if (norm.length === 0) continue;
|
||||
if (seenInThisSample.has(norm)) continue; // count each normalised form once per sample
|
||||
seenInThisSample.add(norm);
|
||||
|
||||
if (!hitsBySample.has(norm)) {
|
||||
hitsBySample.set(norm, new Set());
|
||||
}
|
||||
hitsBySample.get(norm)!.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
const candidates: NoiseCandidate[] = [];
|
||||
for (const [norm, sampleSet] of hitsBySample) {
|
||||
if (sampleSet.size <= 1) continue; // must appear in more than one sample
|
||||
candidates.push({
|
||||
pattern: normalizedToPattern(norm),
|
||||
hits: sampleSet.size,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort descending by hits, then alphabetically for deterministic output
|
||||
candidates.sort((a, b) => b.hits - a.hits || a.pattern.localeCompare(b.pattern));
|
||||
return candidates;
|
||||
}
|
||||
99
open-sse/services/compression/engines/rtk/grouper.ts
Normal file
99
open-sse/services/compression/engines/rtk/grouper.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* RTK Grouping strategy (R5).
|
||||
*
|
||||
* Collapses consecutive "near-equivalent" lines — lines that normalise to the
|
||||
* same canonical form after stripping volatile bits (digits, hex ids,
|
||||
* timestamps) — into a single representative line plus a count marker:
|
||||
*
|
||||
* <representative line> [rtk:grouped ×N]
|
||||
*
|
||||
* Only consecutive runs of length ≥ threshold are collapsed (default: 3).
|
||||
* Non-similar lines are passed through unchanged.
|
||||
*/
|
||||
|
||||
export interface GroupingOptions {
|
||||
/** Minimum run length to trigger grouping (default: 3). */
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export interface GroupingResult {
|
||||
text: string;
|
||||
/** Total number of lines that were removed by grouping. */
|
||||
grouped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a line so that volatile bits (numbers, hex ids, timestamps) are
|
||||
* replaced with a stable placeholder. Two lines that normalise to the same
|
||||
* string are considered "similar" and can be grouped.
|
||||
*
|
||||
* Normalisation steps (order matters — broadest patterns first):
|
||||
* 1. Strip ISO-8601-style timestamps: 2024-01-15T10:30:00Z
|
||||
* 2. Strip date-time in brackets: [2024-01-01 10:00:00]
|
||||
* 3. Replace hex strings (6-40 chars): a1b2c3d4e5f6
|
||||
* 4. Replace standalone integers: 42
|
||||
* 5. Replace semantic-version tokens: v1.2.3
|
||||
* 6. Collapse repeated whitespace.
|
||||
* 7. Trim.
|
||||
*
|
||||
* The placeholder token is the literal string `<N>` which is unlikely to
|
||||
* appear in real output.
|
||||
*/
|
||||
export function normalizeLine(line: string): string {
|
||||
let s = line;
|
||||
// ISO timestamps like 2024-01-15T10:30:00.123Z or 2024-01-15 10:30:00
|
||||
s = s.replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?/g, "<N>");
|
||||
// Bracketed date+time like [2024-01-01 10:00:00]
|
||||
s = s.replace(/\[\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\]/g, "[<N>]");
|
||||
// Hex ids: 6-40 hex chars (not preceded/followed by word char to avoid over-matching)
|
||||
s = s.replace(/\b[0-9a-fA-F]{6,40}\b/g, "<N>");
|
||||
// Semantic version tokens like v1.2.3 or 1.2.3
|
||||
s = s.replace(/\bv?\d+\.\d+\.\d+(?:\.\d+)*\b/g, "<N>");
|
||||
// Standalone integers (whole word)
|
||||
s = s.replace(/\b\d+\b/g, "<N>");
|
||||
// Collapse repeated whitespace
|
||||
s = s.replace(/\s+/g, " ");
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Group consecutive near-equivalent lines in `text`.
|
||||
*
|
||||
* Each group of ≥ threshold similar consecutive lines is replaced by:
|
||||
* <first line of the group> [rtk:grouped ×N]
|
||||
*
|
||||
* where N is the total count of lines in that group.
|
||||
*/
|
||||
export function groupSimilarLines(text: string, options: GroupingOptions = {}): GroupingResult {
|
||||
const threshold = Math.max(2, Math.floor(options.threshold ?? 3));
|
||||
const lines = text.split(/\r?\n/);
|
||||
const output: string[] = [];
|
||||
let grouped = 0;
|
||||
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
const normalised = normalizeLine(line);
|
||||
|
||||
// Count consecutive lines that normalise to the same form
|
||||
let runLength = 1;
|
||||
while (
|
||||
index + runLength < lines.length &&
|
||||
normalizeLine(lines[index + runLength]) === normalised
|
||||
) {
|
||||
runLength++;
|
||||
}
|
||||
|
||||
if (runLength >= threshold) {
|
||||
// Keep the first (representative) line + the count marker
|
||||
output.push(`${line} [rtk:grouped ×${runLength}]`);
|
||||
grouped += runLength - 1;
|
||||
index += runLength;
|
||||
} else {
|
||||
output.push(line);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return { text: output.join("\n"), grouped };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { DEFAULT_RTK_CONFIG, type CompressionResult, type RtkConfig } from "../.
|
||||
import type { CompressionEngine, EngineConfigField, EngineValidationResult } from "../types.ts";
|
||||
import { detectCommandType } from "./commandDetector.ts";
|
||||
import { deduplicateRepeatedLines } from "./deduplicator.ts";
|
||||
import { groupSimilarLines } from "./grouper.ts";
|
||||
import { matchRtkFilter } from "./filterLoader.ts";
|
||||
import { applyLineFilter } from "./lineFilter.ts";
|
||||
import { smartTruncate } from "./smartTruncate.ts";
|
||||
@@ -278,6 +279,7 @@ export function processRtkText(
|
||||
let result = text;
|
||||
|
||||
const detection = detectCommandType(text, options.command);
|
||||
let matchedFilterPatterns: string[] = [];
|
||||
if (!options.skipFilters) {
|
||||
const filter = matchRtkFilter(text, detection.command, {
|
||||
customFiltersEnabled: config.customFiltersEnabled,
|
||||
@@ -294,6 +296,7 @@ export function processRtkText(
|
||||
techniquesUsed.push("rtk-filter");
|
||||
rulesApplied.push(...filtered.appliedRules);
|
||||
}
|
||||
matchedFilterPatterns = filter.priorityPatterns;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,12 +326,32 @@ export function processRtkText(
|
||||
rulesApplied.push("rtk:dedup");
|
||||
}
|
||||
|
||||
// R5: grouping — opt-in via enableGrouping flag (default OFF)
|
||||
if (config.enableGrouping) {
|
||||
const grouped = groupSimilarLines(result, {
|
||||
threshold: config.groupingThreshold,
|
||||
});
|
||||
if (grouped.grouped > 0) {
|
||||
result = grouped.text;
|
||||
techniquesUsed.push("rtk-grouping");
|
||||
rulesApplied.push("rtk:grouping");
|
||||
}
|
||||
}
|
||||
|
||||
const defaultPriorityPatterns: RegExp[] = [/error|failed|exception|traceback|TS\d{4}|FAIL|✖/i];
|
||||
const filterPriorityPatterns: RegExp[] = matchedFilterPatterns.flatMap((pattern) => {
|
||||
try {
|
||||
return [new RegExp(pattern, "i")];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const truncated = smartTruncate(result, {
|
||||
maxLines: config.maxLinesPerResult,
|
||||
maxChars: config.maxCharsPerResult,
|
||||
preserveHead: config.intensity === "aggressive" ? 16 : 24,
|
||||
preserveTail: config.intensity === "aggressive" ? 16 : 24,
|
||||
priorityPatterns: [/error|failed|exception|traceback|TS\d{4}|FAIL|✖/i],
|
||||
priorityPatterns: [...defaultPriorityPatterns, ...filterPriorityPatterns],
|
||||
});
|
||||
if (truncated.truncated) {
|
||||
result = truncated.text;
|
||||
@@ -440,8 +463,7 @@ export function applyRtkCompression(
|
||||
? { enabled: true, ...options.stepConfig }
|
||||
: options.stepConfig;
|
||||
const explicitConfig = options.config && Object.keys(options.config).length > 0;
|
||||
const baseConfig =
|
||||
!explicitConfig && !stepConfig ? { enabled: true } : (options.config ?? {});
|
||||
const baseConfig = !explicitConfig && !stepConfig ? { enabled: true } : (options.config ?? {});
|
||||
const config = mergeRtkConfig(baseConfig, stepConfig);
|
||||
if (!config.enabled) return { body, compressed: false, stats: null };
|
||||
|
||||
|
||||
290
open-sse/services/compression/engines/rtk/learn.ts
Normal file
290
open-sse/services/compression/engines/rtk/learn.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* RTK learn — R6 / N7
|
||||
*
|
||||
* Pure function, no I/O. DB wiring (reading from call_logs) is a follow-up task.
|
||||
* Takes an in-memory array of CommandSample values → returns a suggested RTK
|
||||
* filter draft in the canonical RtkFilterPack shape (same JSON structure as
|
||||
* filters/pip.json, filters/make.json, etc.) so it can be reviewed and saved
|
||||
* as a real filter without conversion.
|
||||
*
|
||||
* Key design decisions
|
||||
* ───────────────────
|
||||
* 1. Drop threshold: a normalised line template is included in dropPatterns only if
|
||||
* it recurs in ≥ DROP_THRESHOLD_RATIO of samples (default 50 %). Single-sample
|
||||
* noise is too specific to be useful as a filter rule.
|
||||
*
|
||||
* 2. Preserve-vs-drop conflict guard: a candidate drop pattern is silently omitted
|
||||
* if it matches ANY line that also matches an error or summary preserve pattern.
|
||||
* This is conservative by design — it's safer to miss a drop than to drop an
|
||||
* important error/summary.
|
||||
*
|
||||
* 3. Error / summary heuristics (ACON-style):
|
||||
* - errorPatterns: lines whose normalised form contains "error", "err!", "fail",
|
||||
* "warning", "warn", "critical", "exception", "fatal", or "panic".
|
||||
* - summaryPatterns: lines whose normalised form contains "success", "done",
|
||||
* "complete", "built", "added", "installed", "finished", or "passed".
|
||||
*
|
||||
* 4. The output id/label is derived mechanically from the command string so the
|
||||
* caller can save it directly without rename.
|
||||
*/
|
||||
|
||||
import { discoverRepeatedNoise, discoverNormalizeLine, type CommandSample } from "./discover.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Suggested filter shape — mirrors the canonical RtkFilterPack JSON structure
|
||||
* (id / label / description / category / priority / match / rules / preserve).
|
||||
* Re-exported so callers don't need to import from filterSchema.ts.
|
||||
*/
|
||||
export interface SuggestedFilter {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: "generic";
|
||||
priority: number;
|
||||
match: {
|
||||
outputTypes: string[];
|
||||
commands: string[];
|
||||
patterns: string[];
|
||||
};
|
||||
rules: {
|
||||
stripAnsi: boolean;
|
||||
dropPatterns: string[];
|
||||
collapsePatterns: string[];
|
||||
includePatterns: string[];
|
||||
deduplicate: boolean;
|
||||
maxLines: number;
|
||||
headLines: number;
|
||||
tailLines: number;
|
||||
onEmpty: string;
|
||||
};
|
||||
preserve: {
|
||||
errorPatterns: string[];
|
||||
summaryPatterns: string[];
|
||||
};
|
||||
/** Metadata about the learning run — not part of the filter schema but useful for UI review. */
|
||||
_meta: {
|
||||
learnedFromSamples: number;
|
||||
dropThreshold: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal constants and helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A normalised line template must appear in at least this fraction of samples
|
||||
* to be included as a drop pattern. 50 % is conservative — it avoids flagging
|
||||
* lines that happen to appear in only a handful of runs.
|
||||
*/
|
||||
const DROP_THRESHOLD_RATIO = 0.5;
|
||||
|
||||
/**
|
||||
* Matched against the RAW (untrimmed) output line, case-insensitive.
|
||||
*
|
||||
* Error heuristic: lines that strongly signal a failure or warning worth
|
||||
* preserving. "WARN deprecated" (npm deprecation noise) is deliberately
|
||||
* excluded — it is structural noise, not an actionable error signal.
|
||||
* We match "ERR!" (npm error prefix), "error:" / "error " patterns, etc.
|
||||
*/
|
||||
const ERROR_PATTERN =
|
||||
/(?:\bERR!|\berror\s*[:/]|\bfailed?\b|\bfailure\b|\bcritical\b|\bexception\b|\bfatal\b|\bpanic\b)/i;
|
||||
|
||||
/**
|
||||
* Matched against the RAW output line, case-insensitive.
|
||||
* Summary heuristic: lines that indicate a successful outcome or final tally.
|
||||
*/
|
||||
const SUMMARY_PATTERN =
|
||||
/(?:\bsuccess(?:ful(?:ly)?)?\b|\bdone\b|\bcomplete(?:d)?\b|\bbuilt\b|\badded\b|\binstalled\b|\bfinished?\b|\bpassed?\b)/i;
|
||||
|
||||
/**
|
||||
* Derive a slug-friendly id from a command string, e.g.
|
||||
* "npm install" → "npm-install"
|
||||
* "pip install" → "pip-install"
|
||||
*/
|
||||
function commandToId(command: string): string {
|
||||
return command
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a regex anchor pattern for the command so the filter's match.commands
|
||||
* array targets this specific invocation, e.g. "^npm\\s+install\\b".
|
||||
*/
|
||||
function commandToMatchPattern(command: string): string {
|
||||
const parts = command.trim().split(/\s+/);
|
||||
const escaped = parts.map((p) => p.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&"));
|
||||
return `^${escaped.join("\\s+")}\\b`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a raw output line (from any sample) is matched by any of the
|
||||
* given regex patterns. Invalid patterns are silently skipped.
|
||||
*/
|
||||
function matchesAny(line: string, patterns: string[]): boolean {
|
||||
for (const p of patterns) {
|
||||
try {
|
||||
if (new RegExp(p, "i").test(line)) return true;
|
||||
} catch {
|
||||
// ignore invalid regex
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Suggest an RTK filter for `command` based on `samples`.
|
||||
*
|
||||
* The returned object is a valid RtkFilterPack-shaped draft that can be
|
||||
* saved to `open-sse/services/compression/engines/rtk/filters/<id>.json`
|
||||
* and loaded by the existing filter loader without modification.
|
||||
*
|
||||
* No I/O — DB reads are a follow-up concern handled by the caller.
|
||||
*/
|
||||
export function suggestFilter(command: string, samples: CommandSample[]): SuggestedFilter {
|
||||
const id = commandToId(command) || "unknown";
|
||||
const commandPattern = commandToMatchPattern(command);
|
||||
const totalSamples = samples.length;
|
||||
|
||||
if (totalSamples === 0) {
|
||||
return {
|
||||
id: `suggested-${id}`,
|
||||
label: command,
|
||||
description: `Auto-suggested filter for '${command}' (0 samples — no rules derived).`,
|
||||
category: "generic",
|
||||
priority: 50,
|
||||
match: { outputTypes: [], commands: [commandPattern], patterns: [] },
|
||||
rules: {
|
||||
stripAnsi: true,
|
||||
dropPatterns: [],
|
||||
collapsePatterns: [],
|
||||
includePatterns: [],
|
||||
deduplicate: true,
|
||||
maxLines: 200,
|
||||
headLines: 30,
|
||||
tailLines: 40,
|
||||
onEmpty: `${id}: ok`,
|
||||
},
|
||||
preserve: { errorPatterns: [], summaryPatterns: [] },
|
||||
_meta: { learnedFromSamples: 0, dropThreshold: DROP_THRESHOLD_RATIO },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step 1: discover recurring noise candidates ──────────────────────────
|
||||
const noiseCandidates = discoverRepeatedNoise(samples);
|
||||
const dropThresholdHits = Math.max(2, Math.ceil(totalSamples * DROP_THRESHOLD_RATIO));
|
||||
|
||||
// ── Step 2: build error/summary preserve patterns from ALL lines ─────────
|
||||
// We scan every line in every sample and collect normalised forms that look
|
||||
// like errors or summaries. Each unique normalised form becomes one pattern.
|
||||
const errorNorms = new Set<string>();
|
||||
const summaryNorms = new Set<string>();
|
||||
|
||||
for (const sample of samples) {
|
||||
for (const raw of sample.output.split(/\r?\n/)) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) continue;
|
||||
const norm = discoverNormalizeLine(trimmed);
|
||||
if (!norm) continue;
|
||||
|
||||
// Classify using the RAW line (before normalisation) so that textual
|
||||
// signals like "ERR!" and "added N packages" are not obscured by
|
||||
// placeholder substitutions like <CODE>.
|
||||
if (ERROR_PATTERN.test(trimmed)) {
|
||||
errorNorms.add(norm);
|
||||
} else if (SUMMARY_PATTERN.test(trimmed)) {
|
||||
summaryNorms.add(norm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a set of normalised lines into regex patterns (same escaping as
|
||||
* normalizedToPattern in discover.ts, but re-implemented here to avoid
|
||||
* coupling to the internal helper).
|
||||
*/
|
||||
function normsToPatterns(norms: Set<string>): string[] {
|
||||
return Array.from(norms).map((norm) => {
|
||||
// Escape regex special chars (< and > are not special, so placeholders survive)
|
||||
const escaped = norm.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
|
||||
const withWildcards = escaped
|
||||
.replace(/<N>/g, "[\\S]+")
|
||||
.replace(/<PKG>/g, "[\\S]+")
|
||||
.replace(/<CODE>/g, "[A-Z][A-Z0-9]+");
|
||||
return withWildcards; // no ^ anchor: preserve patterns are substring-matched
|
||||
});
|
||||
}
|
||||
|
||||
const errorPatterns = normsToPatterns(errorNorms);
|
||||
const summaryPatterns = normsToPatterns(summaryNorms);
|
||||
const allPreservePatterns = [...errorPatterns, ...summaryPatterns];
|
||||
|
||||
// ── Step 3: filter noise candidates into safe drop patterns ─────────────
|
||||
// A candidate is safe to drop only if:
|
||||
// (a) it recurs in >= dropThresholdHits samples, AND
|
||||
// (b) its pattern does NOT match any preserve (error/summary) line from
|
||||
// any sample (conflict guard).
|
||||
//
|
||||
// To apply the conflict guard we also collect all raw lines matched by
|
||||
// preserve patterns, then check each drop candidate against them.
|
||||
|
||||
// Collect every raw line that any preserve pattern would protect.
|
||||
const preservedRawLines: string[] = [];
|
||||
for (const sample of samples) {
|
||||
for (const raw of sample.output.split(/\r?\n/)) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) continue;
|
||||
if (matchesAny(trimmed, allPreservePatterns)) {
|
||||
preservedRawLines.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dropPatterns: string[] = [];
|
||||
for (const candidate of noiseCandidates) {
|
||||
if (candidate.hits < dropThresholdHits) continue; // below threshold
|
||||
// Conflict guard: skip if the drop pattern matches a preserved line
|
||||
const conflictsWithPreserve = preservedRawLines.some((line) => {
|
||||
try {
|
||||
return new RegExp(candidate.pattern, "i").test(line);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (conflictsWithPreserve) continue;
|
||||
dropPatterns.push(candidate.pattern);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `suggested-${id}`,
|
||||
label: command,
|
||||
description: `Auto-suggested filter for '${command}' learned from ${totalSamples} sample(s).`,
|
||||
category: "generic",
|
||||
priority: 50,
|
||||
match: { outputTypes: [], commands: [commandPattern], patterns: [] },
|
||||
rules: {
|
||||
stripAnsi: true,
|
||||
dropPatterns,
|
||||
collapsePatterns: [],
|
||||
includePatterns: [...errorPatterns, ...summaryPatterns],
|
||||
deduplicate: true,
|
||||
maxLines: 200,
|
||||
headLines: 30,
|
||||
tailLines: 40,
|
||||
onEmpty: `${id}: ok`,
|
||||
},
|
||||
preserve: { errorPatterns, summaryPatterns },
|
||||
_meta: { learnedFromSamples: totalSamples, dropThreshold: DROP_THRESHOLD_RATIO },
|
||||
};
|
||||
}
|
||||
443
open-sse/services/compression/engines/session-dedup/index.ts
Normal file
443
open-sse/services/compression/engines/session-dedup/index.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* session-dedup compression engine (R11 / N2 / TO1)
|
||||
*
|
||||
* Content-addressed cross-turn deduplication, inspired by the TokenMizer
|
||||
* session-graph + line-dedup blueprint (arXiv 2606.06337) and sqz prior-art.
|
||||
*
|
||||
* Algorithm (two-pass, suffix-block content-addressed):
|
||||
* Pass 1 — scan all non-system messages. For each message, enumerate suffix
|
||||
* line blocks (lines[start..end-of-message]) that meet minBlockChars
|
||||
* and minBlockLines. Hash each block. Record the first message that
|
||||
* owns each hash.
|
||||
* Pass 2 — for each non-system message (index i), find blocks whose hash was
|
||||
* first seen in a STRICTLY EARLIER message (index j < i). Replace the
|
||||
* LONGEST such block's text with `[dedup:ref sha=<8hex>]`.
|
||||
* First occurrence is always kept intact.
|
||||
*
|
||||
* Greedy, longest-first replacement: sort duplicate blocks by length descending;
|
||||
* replace the longest block first so shorter overlapping candidates are skipped.
|
||||
*
|
||||
* Conservative guards:
|
||||
* - Never touch `role: "system"`.
|
||||
* - Never touch multipart content parts other than `type: "text"`.
|
||||
* - Only dedup blocks ≥ minBlockChars (default 80 chars) AND ≥ MIN_BLOCK_LINES lines.
|
||||
* - First occurrence is ALWAYS kept intact; only later identical occurrences are replaced.
|
||||
*
|
||||
* Reconstruction:
|
||||
* Replace every `[dedup:ref sha=XXXXXXXX]` marker with the original block text
|
||||
* from the reverse map attached as `__sessionDedupMap__` on the body object.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { createCompressionStats } from "../../stats.ts";
|
||||
import type {
|
||||
CompressionEngine,
|
||||
CompressionEngineApplyOptions,
|
||||
EngineConfigField,
|
||||
EngineValidationResult,
|
||||
} from "../types.ts";
|
||||
import type { CompressionResult } from "../../types.ts";
|
||||
|
||||
// ─── constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ENGINE_ID = "session-dedup";
|
||||
/** Minimum block character count to be a dedup candidate. */
|
||||
const DEFAULT_MIN_BLOCK_CHARS = 80;
|
||||
/** Minimum number of lines a block must span to be a dedup candidate. */
|
||||
const MIN_BLOCK_LINES = 3;
|
||||
/** Marker pattern for reconstruction. */
|
||||
const MARKER_RE = /\[dedup:ref sha=([0-9a-f]{24})\]/g;
|
||||
/** Key used to store the reverse map in the body object. */
|
||||
const DEDUP_MAP_KEY = "__sessionDedupMap__";
|
||||
|
||||
// ─── hash helper (SHA-256 prefix, collision-resistant) ───────────────────────
|
||||
|
||||
function hashBlock(text: string): string {
|
||||
// 24 hex / 96 bits — collision-resistant (a 32-bit djb2 could collide and make
|
||||
// reconstruction restore the WRONG block). Pass 2 additionally verifies block
|
||||
// equality before substituting, so a collision can never cause corruption.
|
||||
return crypto.createHash("sha256").update(text).digest("hex").slice(0, 24);
|
||||
}
|
||||
|
||||
// ─── suffix-block extraction ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* For each starting line position, emit the suffix block `lines[start..end]`
|
||||
* (i.e. from `start` to the end of the line array). This ensures that any
|
||||
* multiline sub-content that appears verbatim in multiple messages is discoverable
|
||||
* regardless of what text precedes it in each message.
|
||||
*
|
||||
* Only emits blocks that meet minBlockChars AND have at least MIN_BLOCK_LINES lines.
|
||||
* Uses a seen-set to deduplicate identical suffix blocks.
|
||||
*/
|
||||
function findSuffixBlocks(
|
||||
lines: string[],
|
||||
minBlockChars: number
|
||||
): Array<{ block: string; startLine: number }> {
|
||||
const n = lines.length;
|
||||
const seen = new Set<string>();
|
||||
const results: Array<{ block: string; startLine: number }> = [];
|
||||
|
||||
for (let start = 0; start < n; start++) {
|
||||
const block = lines.slice(start).join("\n");
|
||||
const blockLines = n - start;
|
||||
if (blockLines >= MIN_BLOCK_LINES && block.length >= minBlockChars && !seen.has(block)) {
|
||||
seen.add(block);
|
||||
results.push({ block, startLine: start });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── two-pass dedup on message texts ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Runs two-pass dedup over an ordered list of (msgIdx, text) pairs.
|
||||
* Returns the replaced texts for duplicate messages, a reverse map, and a count.
|
||||
*/
|
||||
function dedupMessageTexts(
|
||||
msgTexts: Array<{ msgIdx: number; text: string }>,
|
||||
minBlockChars: number
|
||||
): {
|
||||
deduped: Map<number, string>;
|
||||
reverseMap: Map<string, string>;
|
||||
dedupCount: number;
|
||||
} {
|
||||
// Pass 1: for each message, extract suffix blocks and record first ownership.
|
||||
// `firstSeen`: sha → { ownerMsgIdx, block }
|
||||
const firstSeen = new Map<string, { ownerMsgIdx: number; block: string }>();
|
||||
|
||||
for (const { msgIdx, text } of msgTexts) {
|
||||
const lines = text.split("\n");
|
||||
const blocks = findSuffixBlocks(lines, minBlockChars);
|
||||
for (const { block } of blocks) {
|
||||
const sha = hashBlock(block);
|
||||
if (!firstSeen.has(sha)) {
|
||||
firstSeen.set(sha, { ownerMsgIdx: msgIdx, block });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build reverse map (sha → block) for all first-seen blocks.
|
||||
const reverseMap = new Map<string, string>();
|
||||
for (const [sha, { block }] of firstSeen) {
|
||||
reverseMap.set(sha, block);
|
||||
}
|
||||
|
||||
// Pass 2: for each message, find blocks that were FIRST seen in an earlier message.
|
||||
const deduped = new Map<number, string>();
|
||||
let dedupCount = 0;
|
||||
|
||||
for (const { msgIdx, text } of msgTexts) {
|
||||
const lines = text.split("\n");
|
||||
const blocks = findSuffixBlocks(lines, minBlockChars);
|
||||
|
||||
// Collect blocks that are duplicates (owned by an earlier message).
|
||||
const dupBlocks: Array<{ block: string; sha: string }> = [];
|
||||
for (const { block } of blocks) {
|
||||
const sha = hashBlock(block);
|
||||
const owner = firstSeen.get(sha);
|
||||
// owner.block === block guards against a (now astronomically unlikely) hash
|
||||
// collision substituting a marker that would reconstruct to the wrong text.
|
||||
if (owner && owner.ownerMsgIdx < msgIdx && owner.block === block) {
|
||||
dupBlocks.push({ block, sha });
|
||||
}
|
||||
}
|
||||
|
||||
if (dupBlocks.length === 0) continue;
|
||||
|
||||
// Sort longest-first to prefer replacing the longest matching block.
|
||||
dupBlocks.sort((a, b) => b.block.length - a.block.length);
|
||||
|
||||
let result = text;
|
||||
let changed = false;
|
||||
const replaced = new Set<string>(); // avoid double-replacing overlapping blocks
|
||||
|
||||
for (const { block, sha } of dupBlocks) {
|
||||
// Skip if this block is a suffix of a block already replaced (overlap guard).
|
||||
if ([...replaced].some((r) => r.includes(block))) continue;
|
||||
|
||||
const idx = result.indexOf(block);
|
||||
if (idx !== -1) {
|
||||
const marker = `[dedup:ref sha=${sha}]`;
|
||||
result = result.slice(0, idx) + marker + result.slice(idx + block.length);
|
||||
changed = true;
|
||||
replaced.add(block);
|
||||
// Only replace once per block per message pass.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
deduped.set(msgIdx, result);
|
||||
dedupCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { deduped, reverseMap, dedupCount };
|
||||
}
|
||||
|
||||
// ─── message array processing ─────────────────────────────────────────────────
|
||||
|
||||
type MessageLike = {
|
||||
role?: string;
|
||||
content?: string | Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Process messages: collect text content, run two-pass dedup, apply results.
|
||||
*/
|
||||
function processMessages(
|
||||
messages: MessageLike[],
|
||||
minBlockChars: number
|
||||
): { messages: MessageLike[]; reverseMap: Map<string, string>; dedupCount: number } {
|
||||
// Collect (msgIdx, text) for non-system string-content messages.
|
||||
// For multipart, index each text part separately.
|
||||
const msgTexts: Array<{ msgIdx: number; text: string }> = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === "system") continue;
|
||||
if (typeof msg.content === "string") {
|
||||
msgTexts.push({ msgIdx: i, text: msg.content });
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (let p = 0; p < msg.content.length; p++) {
|
||||
const part = msg.content[p];
|
||||
if (part["type"] === "text" && typeof part["text"] === "string") {
|
||||
// Composite key: i * 100000 + p + 1 (safe for reasonable message counts)
|
||||
msgTexts.push({ msgIdx: i * 100000 + p + 1, text: part["text"] as string });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (msgTexts.length < 2) {
|
||||
return { messages, reverseMap: new Map(), dedupCount: 0 };
|
||||
}
|
||||
|
||||
const { deduped, reverseMap, dedupCount } = dedupMessageTexts(msgTexts, minBlockChars);
|
||||
|
||||
if (dedupCount === 0) {
|
||||
return { messages, reverseMap, dedupCount: 0 };
|
||||
}
|
||||
|
||||
const result = messages.map((msg, i) => {
|
||||
if (msg.role === "system") return { ...msg };
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
const replacement = deduped.get(i);
|
||||
return replacement !== undefined ? { ...msg, content: replacement } : { ...msg };
|
||||
}
|
||||
|
||||
if (Array.isArray(msg.content)) {
|
||||
let changed = false;
|
||||
const newContent = msg.content.map((part, p) => {
|
||||
if (part["type"] !== "text" || typeof part["text"] !== "string") return part;
|
||||
const key = i * 100000 + p + 1;
|
||||
const replacement = deduped.get(key);
|
||||
if (replacement !== undefined) {
|
||||
changed = true;
|
||||
return { ...part, text: replacement };
|
||||
}
|
||||
return part;
|
||||
});
|
||||
return changed ? { ...msg, content: newContent } : { ...msg };
|
||||
}
|
||||
|
||||
return { ...msg };
|
||||
});
|
||||
|
||||
return { messages: result, reverseMap, dedupCount };
|
||||
}
|
||||
|
||||
// ─── schema & validation ──────────────────────────────────────────────────────
|
||||
|
||||
const SESSION_DEDUP_SCHEMA: EngineConfigField[] = [
|
||||
{
|
||||
key: "enabled",
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: "minBlockChars",
|
||||
type: "number",
|
||||
label: "Minimum block characters",
|
||||
description: "Minimum character count for a suffix block to be a dedup candidate.",
|
||||
defaultValue: DEFAULT_MIN_BLOCK_CHARS,
|
||||
min: 1,
|
||||
max: 100000,
|
||||
},
|
||||
];
|
||||
|
||||
function validateSessionDedupConfig(config: Record<string, unknown>): EngineValidationResult {
|
||||
const errors: string[] = [];
|
||||
if (config["enabled"] !== undefined && typeof config["enabled"] !== "boolean") {
|
||||
errors.push("enabled must be a boolean");
|
||||
}
|
||||
if (config["minBlockChars"] !== undefined) {
|
||||
const v = config["minBlockChars"];
|
||||
if (typeof v !== "number" || !Number.isFinite(v) || v < 1) {
|
||||
errors.push("minBlockChars must be a positive number");
|
||||
}
|
||||
}
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ─── engine export ────────────────────────────────────────────────────────────
|
||||
|
||||
export const sessionDedupEngine: CompressionEngine = {
|
||||
id: ENGINE_ID,
|
||||
name: "Session Dedup",
|
||||
description:
|
||||
"Content-addressed cross-turn deduplication: replaces repeated multi-line blocks " +
|
||||
"with short reference markers (R11/N2/TO1, TokenMizer blueprint).",
|
||||
icon: "content_copy",
|
||||
targets: ["messages"],
|
||||
stackable: true,
|
||||
// stackPriority 3 = runs BEFORE lite (5), caveman (20), aggressive (30), ultra (40).
|
||||
// Dedup first so downstream engines operate on already-deduplicated content.
|
||||
stackPriority: 3,
|
||||
metadata: {
|
||||
id: ENGINE_ID,
|
||||
name: "Session Dedup",
|
||||
description:
|
||||
"Content-addressed cross-turn deduplication: replaces repeated multi-line blocks with short reference markers.",
|
||||
inputScope: "messages",
|
||||
targetLatencyMs: 1,
|
||||
supportsPreview: true,
|
||||
stable: true,
|
||||
},
|
||||
|
||||
apply(body: Record<string, unknown>, options?: CompressionEngineApplyOptions): CompressionResult {
|
||||
const stepConfig = options?.stepConfig ?? {};
|
||||
|
||||
if (stepConfig["enabled"] === false) {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
const minBlockChars =
|
||||
typeof stepConfig["minBlockChars"] === "number"
|
||||
? (stepConfig["minBlockChars"] as number)
|
||||
: DEFAULT_MIN_BLOCK_CHARS;
|
||||
|
||||
const messages = body["messages"];
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const {
|
||||
messages: dedupedMessages,
|
||||
reverseMap,
|
||||
dedupCount,
|
||||
} = processMessages(messages as MessageLike[], minBlockChars);
|
||||
|
||||
if (dedupCount === 0) {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
const newBody: Record<string, unknown> = {
|
||||
...body,
|
||||
messages: dedupedMessages,
|
||||
};
|
||||
|
||||
// Store the reverse map as a NON-ENUMERABLE property so JSON.stringify does not
|
||||
// include it in the serialized output (which goes to the upstream provider).
|
||||
// reconstructSessionDedup reads it back via getOwnPropertyDescriptor.
|
||||
Object.defineProperty(newBody, DEDUP_MAP_KEY, {
|
||||
value: Object.fromEntries(reverseMap),
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const stats = createCompressionStats(
|
||||
body,
|
||||
newBody,
|
||||
"stacked",
|
||||
["session-dedup"],
|
||||
[`deduplicated-${dedupCount}-blocks`],
|
||||
durationMs
|
||||
);
|
||||
|
||||
return { body: newBody, compressed: true, stats };
|
||||
},
|
||||
|
||||
compress(body: Record<string, unknown>, config?: Record<string, unknown>): CompressionResult {
|
||||
return this.apply(body, { stepConfig: config ?? {} });
|
||||
},
|
||||
|
||||
getConfigSchema(): EngineConfigField[] {
|
||||
return SESSION_DEDUP_SCHEMA;
|
||||
},
|
||||
|
||||
validateConfig(config: Record<string, unknown>): EngineValidationResult {
|
||||
return validateSessionDedupConfig(config);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── reconstruction helper ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reverse the dedup: replace every `[dedup:ref sha=XXXXXXXX]` marker with the
|
||||
* original block text stored in the reverse map attached to the body by
|
||||
* `sessionDedupEngine.apply`.
|
||||
*
|
||||
* Returns a new body object without the internal `__sessionDedupMap__` key.
|
||||
*/
|
||||
export function reconstructSessionDedup(body: Record<string, unknown>): Record<string, unknown> {
|
||||
// The reverse map is stored as a non-enumerable property so it doesn't appear
|
||||
// in JSON.stringify. Access it via getOwnPropertyDescriptor.
|
||||
const mapDescriptor = Object.getOwnPropertyDescriptor(body, DEDUP_MAP_KEY);
|
||||
const rawMap = mapDescriptor?.value ?? body[DEDUP_MAP_KEY];
|
||||
if (!rawMap || typeof rawMap !== "object" || Array.isArray(rawMap)) return body;
|
||||
|
||||
const reverseMap = new Map<string, string>(Object.entries(rawMap as Record<string, string>));
|
||||
|
||||
const messages = body["messages"];
|
||||
if (!Array.isArray(messages)) return body;
|
||||
|
||||
type MsgLike = {
|
||||
role?: string;
|
||||
content?: string | Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const restored = (messages as MsgLike[]).map((msg) => {
|
||||
const content = msg["content"];
|
||||
|
||||
if (typeof content === "string") {
|
||||
const reconstructed = content.replace(
|
||||
MARKER_RE,
|
||||
(_m, sha: string) => reverseMap.get(sha) ?? _m
|
||||
);
|
||||
return reconstructed !== content ? { ...msg, content: reconstructed } : { ...msg };
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
let changed = false;
|
||||
const newContent = content.map((part) => {
|
||||
if (part["type"] !== "text" || typeof part["text"] !== "string") return part;
|
||||
const reconstructed = (part["text"] as string).replace(
|
||||
MARKER_RE,
|
||||
(_m, sha: string) => reverseMap.get(sha) ?? _m
|
||||
);
|
||||
if (reconstructed !== part["text"]) {
|
||||
changed = true;
|
||||
return { ...part, text: reconstructed };
|
||||
}
|
||||
return part;
|
||||
});
|
||||
return changed ? { ...msg, content: newContent } : { ...msg };
|
||||
}
|
||||
|
||||
return { ...msg };
|
||||
});
|
||||
|
||||
const { [DEDUP_MAP_KEY]: _dropped, ...restBody } = body;
|
||||
void _dropped;
|
||||
return { ...restBody, messages: restored };
|
||||
}
|
||||
@@ -47,6 +47,17 @@ export interface CompressionEngine {
|
||||
stackPriority: number;
|
||||
metadata: CompressionEngineMetadata;
|
||||
apply(body: Record<string, unknown>, options?: CompressionEngineApplyOptions): CompressionResult;
|
||||
/**
|
||||
* Optional async variant (H10). Engines whose real work is asynchronous
|
||||
* (e.g. a worker-thread model like LLMLingua-2) implement this. The stacked
|
||||
* pipeline awaits `applyAsync` when present and falls back to the synchronous
|
||||
* `apply` otherwise, so async-only engines MUST keep `apply` as a safe
|
||||
* synchronous pass-through. Sync engines never need to implement this.
|
||||
*/
|
||||
applyAsync?(
|
||||
body: Record<string, unknown>,
|
||||
options?: CompressionEngineApplyOptions
|
||||
): Promise<CompressionResult>;
|
||||
compress(body: Record<string, unknown>, config?: Record<string, unknown>): CompressionResult;
|
||||
getConfigSchema(): EngineConfigField[];
|
||||
validateConfig(config: Record<string, unknown>): EngineValidationResult;
|
||||
|
||||
286
open-sse/services/compression/harness/benchmark.ts
Normal file
286
open-sse/services/compression/harness/benchmark.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* A/B benchmark for compression engines (F2.4 / L2).
|
||||
*
|
||||
* Runs the deterministic compression engines through the C1 harness and applies
|
||||
* the N4 tokens-per-task gate so a human or CI job can choose the default engine
|
||||
* from real numbers rather than intuition.
|
||||
*
|
||||
* Design notes
|
||||
* ─────────────
|
||||
* - Engine → compressFn adapter: wraps a plain string into the minimal
|
||||
* `{messages:[{role:"user",content:text}]}` body that every engine expects,
|
||||
* calls `applyAsync` when the engine declares one (H10-ready), else `apply`,
|
||||
* then extracts `messages[0].content` back out. Engines that return
|
||||
* non-string content fall back to the original string (fail-open).
|
||||
*
|
||||
* - Deterministic: no `Date.now()` / `Math.random()` calls here; the harness
|
||||
* runner is already deterministic. Running `benchmarkEngines` twice on the
|
||||
* same corpus MUST yield identical numbers.
|
||||
*
|
||||
* - `llmlingua` real-model A/B is a VPS-validated follow-up (Hard Rule #18 /
|
||||
* L3). The sandbox benchmark covers the deterministic engines (rtk, caveman,
|
||||
* lite, aggressive, ultra, session-dedup, headroom, ccr). The framework is
|
||||
* engine-agnostic: adding llmlingua later is just another id in the engines
|
||||
* array once the MobileBERT ONNX model file is available at runtime. The
|
||||
* `llmlingua` engine registered here is already present in the registry but
|
||||
* its `applyAsync` requires the backend service; without it the sync `apply`
|
||||
* is a pass-through (fail-open) — reflected in 0% savings / 100% retention
|
||||
* results that make the A/B table honest about the sandbox limitation.
|
||||
*/
|
||||
|
||||
import { registerBuiltinCompressionEngines } from "../engines/index.ts";
|
||||
import { getCompressionEngine } from "../engines/registry.ts";
|
||||
import { runCompressionEval, type EvalCase, type CompressFn, type EvalReport } from "./runner.ts";
|
||||
import {
|
||||
checkTokensPerTaskGate,
|
||||
type BudgetBaseline,
|
||||
type BudgetGateResult,
|
||||
} from "./budgetGate.ts";
|
||||
|
||||
// Register all built-in engines once (idempotent).
|
||||
registerBuiltinCompressionEngines();
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EngineSummaryRow {
|
||||
engine: string;
|
||||
/** Mean token savings % across the corpus (higher = better). */
|
||||
meanSavingsPercent: number;
|
||||
/** Mean technical-entity retention score 0..1 (higher = better). */
|
||||
meanRetention: number;
|
||||
/** Total compressed tokens across all corpus items. */
|
||||
totalCompressedTokens: number;
|
||||
}
|
||||
|
||||
export interface EngineBenchmarkGateRow {
|
||||
engine: string;
|
||||
gate: BudgetGateResult;
|
||||
}
|
||||
|
||||
// ── Fixture corpus (BENCHMARK_CORPUS) ────────────────────────────────────────
|
||||
// Representative samples for reproducible A/B runs in CI and local dev.
|
||||
// Three task groups mirror real OmniRoute workloads:
|
||||
// "prose" — conversational / documentation turns
|
||||
// "tool-output" — bash/CLI raw output with repeated structural noise
|
||||
// "json" — structured tool results / API responses
|
||||
|
||||
export const BENCHMARK_CORPUS: EvalCase[] = [
|
||||
// ── Prose ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "prose-1",
|
||||
task: "prose",
|
||||
input: [
|
||||
"Actually, I think what you basically want to do is essentially iterate over the list",
|
||||
"of files and then kind of process each one in turn. So basically what I mean is that",
|
||||
"you should loop through them one by one. In other words, just go through each file",
|
||||
"in the directory and perform the operation. Does that make sense? Let me know if you",
|
||||
"need any clarification on this matter whatsoever.",
|
||||
"",
|
||||
"So anyway, the main takeaway here is that you need to call processFile() for each",
|
||||
"item in the files array. That is essentially the core of what I am trying to convey.",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
id: "prose-2",
|
||||
task: "prose",
|
||||
input: [
|
||||
"I would like to add that, at the end of the day, the solution involves calling",
|
||||
"https://api.example.com/v2/process with the API_KEY environment variable set.",
|
||||
"The endpoint version is v2.3.1 and you must pass X-Request-Id in the headers.",
|
||||
"",
|
||||
"Please note that basically every request needs authentication via the Bearer token.",
|
||||
"So in other words you should include Authorization: Bearer <token> in every call.",
|
||||
"The error.message field will contain details when a 401 or 403 occurs.",
|
||||
].join("\n"),
|
||||
},
|
||||
// ── Tool output ──────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "tool-output-1",
|
||||
task: "tool-output",
|
||||
input: [
|
||||
"$ npm install",
|
||||
"npm warn deprecated inflight@1.0.6: This module is not supported",
|
||||
"npm warn deprecated inflight@1.0.6: This module is not supported",
|
||||
"npm warn deprecated inflight@1.0.6: This module is not supported",
|
||||
"npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported",
|
||||
"npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported",
|
||||
"npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported",
|
||||
"npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported",
|
||||
"",
|
||||
"added 1234 packages, and audited 1234 packages in 42s",
|
||||
"",
|
||||
"found 0 vulnerabilities",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
id: "tool-output-2",
|
||||
task: "tool-output",
|
||||
input: [
|
||||
"tests/unit/foo.test.ts ..........",
|
||||
"tests/unit/bar.test.ts ..........",
|
||||
"tests/unit/baz.test.ts ..........",
|
||||
"tests/unit/qux.test.ts ..........",
|
||||
"tests/unit/foo.test.ts ..........",
|
||||
"tests/unit/bar.test.ts ..........",
|
||||
"tests/unit/baz.test.ts ..........",
|
||||
"tests/unit/qux.test.ts ..........",
|
||||
" ✓ all 80 tests passed",
|
||||
" coverage: 87.3% statements",
|
||||
"Error: ENOENT: no such file or directory '/tmp/cache/build.lock'",
|
||||
].join("\n"),
|
||||
},
|
||||
// ── JSON / structured ────────────────────────────────────────────────────
|
||||
{
|
||||
id: "json-1",
|
||||
task: "json",
|
||||
input: JSON.stringify(
|
||||
{
|
||||
model: "gpt-4o",
|
||||
usage: { prompt_tokens: 1200, completion_tokens: 350, total_tokens: 1550 },
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content:
|
||||
"The file src/lib/db/core.ts exports getDbInstance() which returns the WAL-mode SQLite singleton.",
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finish_reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Call getDbInstance() from src/lib/db/core.ts to obtain the DB handle.",
|
||||
},
|
||||
},
|
||||
],
|
||||
id: "chatcmpl-abc123",
|
||||
created: 1718000000,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Core API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adapt a registered compression engine into the `CompressFn` interface that
|
||||
* `runCompressionEval` expects.
|
||||
*
|
||||
* Wraps the input string as `{messages:[{role:"user",content:text}]}`, calls
|
||||
* `applyAsync` when available or `apply` otherwise, then extracts the resulting
|
||||
* `messages[0].content` string. Falls back to the original text on any error
|
||||
* or if the engine returns a non-string content value (fail-open).
|
||||
*
|
||||
* Registers builtin engines on first call (idempotent).
|
||||
*/
|
||||
export function engineToCompressFn(engineId: string): CompressFn {
|
||||
const engine = getCompressionEngine(engineId);
|
||||
if (!engine) {
|
||||
throw new Error(`Unknown compression engine: "${engineId}"`);
|
||||
}
|
||||
|
||||
return async (text: string): Promise<string> => {
|
||||
const body: Record<string, unknown> = {
|
||||
messages: [{ role: "user", content: text }],
|
||||
};
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (typeof engine.applyAsync === "function") {
|
||||
result = await engine.applyAsync(body);
|
||||
} else {
|
||||
result = engine.apply(body);
|
||||
}
|
||||
|
||||
const messages = result.body["messages"];
|
||||
if (Array.isArray(messages) && messages.length > 0) {
|
||||
const content = (messages[0] as Record<string, unknown>)["content"];
|
||||
if (typeof content === "string") return content;
|
||||
}
|
||||
// Fallback: content was an array of blocks or engine left body unchanged
|
||||
return text;
|
||||
} catch {
|
||||
// Fail-open: engine error → return original text unchanged
|
||||
return text;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the C1 harness on `corpus` for each engine id in `engineIds`.
|
||||
*
|
||||
* Returns a map of `engineId → EvalReport`. All engines run sequentially to
|
||||
* keep determinism; the corpus order is preserved.
|
||||
*
|
||||
* No `Date.now()` or `Math.random()` calls — deterministic by construction.
|
||||
*/
|
||||
export async function benchmarkEngines(
|
||||
corpus: EvalCase[],
|
||||
engineIds: string[]
|
||||
): Promise<Record<string, EvalReport>> {
|
||||
const reports: Record<string, EvalReport> = {};
|
||||
for (const id of engineIds) {
|
||||
const compress = engineToCompressFn(id);
|
||||
reports[id] = await runCompressionEval(corpus, compress);
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a flat, sortable A/B summary table from an engine→report map.
|
||||
*
|
||||
* Sort order: `meanSavingsPercent` descending (best token saver first).
|
||||
* Ties are broken by `meanRetention` descending (better entity preservation).
|
||||
* The sort is deterministic and documented here so the decision rationale is
|
||||
* reproducible: "most savings, then most retention when savings are equal".
|
||||
*/
|
||||
export function compareReports(reports: Record<string, EvalReport>): EngineSummaryRow[] {
|
||||
const rows: EngineSummaryRow[] = Object.entries(reports).map(([engine, report]) => ({
|
||||
engine,
|
||||
meanSavingsPercent: report.meanSavingsPercent,
|
||||
meanRetention: report.meanRetention,
|
||||
totalCompressedTokens: report.totalCompressedTokens,
|
||||
}));
|
||||
|
||||
rows.sort((a, b) => {
|
||||
if (b.meanSavingsPercent !== a.meanSavingsPercent) {
|
||||
return b.meanSavingsPercent - a.meanSavingsPercent;
|
||||
}
|
||||
return b.meanRetention - a.meanRetention;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the N4 tokens-per-task gate to each engine's report against provided
|
||||
* per-engine baselines.
|
||||
*
|
||||
* `baselines` is keyed by engineId. Engines absent from `baselines` are
|
||||
* reported as passed (no baseline to regress against).
|
||||
*
|
||||
* `tolerancePercent` defaults to 2 (same default as `checkTokensPerTaskGate`).
|
||||
*/
|
||||
export function runBenchmarkGate(
|
||||
reports: Record<string, EvalReport>,
|
||||
baselines: Record<string, BudgetBaseline>,
|
||||
tolerancePercent = 2
|
||||
): EngineBenchmarkGateRow[] {
|
||||
return Object.entries(reports).map(([engine, report]) => {
|
||||
const baseline = baselines[engine];
|
||||
if (!baseline) {
|
||||
// No baseline for this engine → unconditional pass (nothing to regress against)
|
||||
return {
|
||||
engine,
|
||||
gate: { passed: true, regressions: [], tolerancePercent },
|
||||
};
|
||||
}
|
||||
const gate = checkTokensPerTaskGate(report, baseline, tolerancePercent);
|
||||
return { engine, gate };
|
||||
});
|
||||
}
|
||||
62
open-sse/services/compression/harness/budgetGate.ts
Normal file
62
open-sse/services/compression/harness/budgetGate.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { EvalReport } from "./runner.ts";
|
||||
|
||||
/**
|
||||
* Tokens-per-task gate (N4). The harness measures the average compressed token
|
||||
* cost per task group; this gate fails when that cost *rises* versus a frozen
|
||||
* baseline beyond a tolerance — i.e. a change made the pipeline compress worse.
|
||||
* Falling cost (better compression) always passes; the baseline is updated
|
||||
* deliberately, like the project's other ratchets.
|
||||
*/
|
||||
|
||||
export interface BudgetBaseline {
|
||||
/** task -> baseline mean compressed tokens. */
|
||||
tasks: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface BudgetRegression {
|
||||
task: string;
|
||||
baseline: number;
|
||||
current: number;
|
||||
/** Percentage increase vs baseline (positive = worse). */
|
||||
deltaPercent: number;
|
||||
}
|
||||
|
||||
export interface BudgetGateResult {
|
||||
passed: boolean;
|
||||
regressions: BudgetRegression[];
|
||||
tolerancePercent: number;
|
||||
}
|
||||
|
||||
/** Mean compressed tokens per task group in a report. */
|
||||
export function tokensPerTask(report: EvalReport): Record<string, number> {
|
||||
const byTask = new Map<string, { tokens: number; count: number }>();
|
||||
for (const r of report.results) {
|
||||
const entry = byTask.get(r.task) ?? { tokens: 0, count: 0 };
|
||||
entry.tokens += r.compressedTokens;
|
||||
entry.count += 1;
|
||||
byTask.set(r.task, entry);
|
||||
}
|
||||
const out: Record<string, number> = {};
|
||||
for (const [task, { tokens, count }] of byTask) {
|
||||
out[task] = Math.round(tokens / count);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function checkTokensPerTaskGate(
|
||||
report: EvalReport,
|
||||
baseline: BudgetBaseline,
|
||||
tolerancePercent = 2
|
||||
): BudgetGateResult {
|
||||
const current = tokensPerTask(report);
|
||||
const regressions: BudgetRegression[] = [];
|
||||
for (const [task, base] of Object.entries(baseline.tasks)) {
|
||||
const cur = current[task];
|
||||
if (cur === undefined || base <= 0) continue;
|
||||
const deltaPercent = Math.round(((cur - base) / base) * 1000) / 10;
|
||||
if (deltaPercent > tolerancePercent) {
|
||||
regressions.push({ task, baseline: base, current: cur, deltaPercent });
|
||||
}
|
||||
}
|
||||
return { passed: regressions.length === 0, regressions, tolerancePercent };
|
||||
}
|
||||
37
open-sse/services/compression/harness/index.ts
Normal file
37
open-sse/services/compression/harness/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Compression eval/benchmark harness (F0.3 — C1 + N4 gate + TV3 replay).
|
||||
*
|
||||
* API-free, CI-safe primitives to answer "did meaning survive?" (retention) and
|
||||
* "did cost/task get worse?" (budget gate), plus a replay path over real
|
||||
* transcripts. Engines and the studios consume these to keep compression honest.
|
||||
*/
|
||||
export {
|
||||
extractEntities,
|
||||
computeRetention,
|
||||
measureCompression,
|
||||
type RetentionScore,
|
||||
type CompressionMeasurement,
|
||||
} from "./measure.ts";
|
||||
|
||||
export {
|
||||
runCompressionEval,
|
||||
type EvalCase,
|
||||
type CompressFn,
|
||||
type EvalResult,
|
||||
type EvalReport,
|
||||
} from "./runner.ts";
|
||||
|
||||
export {
|
||||
tokensPerTask,
|
||||
checkTokensPerTaskGate,
|
||||
type BudgetBaseline,
|
||||
type BudgetRegression,
|
||||
type BudgetGateResult,
|
||||
} from "./budgetGate.ts";
|
||||
|
||||
export {
|
||||
transcriptsToCorpus,
|
||||
replayTranscripts,
|
||||
type Transcript,
|
||||
type TranscriptTurn,
|
||||
} from "./replay.ts";
|
||||
95
open-sse/services/compression/harness/measure.ts
Normal file
95
open-sse/services/compression/harness/measure.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { estimateCompressionTokens } from "../stats.ts";
|
||||
import { extractPreservedBlocks } from "../preservation.ts";
|
||||
|
||||
/**
|
||||
* Compression eval harness — measurement primitives (C1).
|
||||
*
|
||||
* Two cheap, API-free signals per compression:
|
||||
* - **ratio**: token savings via the same estimator the pipeline uses for stats.
|
||||
* - **retention**: how many technical entities (URLs, identifiers, env vars,
|
||||
* versions, file paths, error messages, …) survive the compression. We reuse
|
||||
* the canonical preservation extractor so "what counts as important" stays in
|
||||
* one place and tracks the real preservation rules.
|
||||
*/
|
||||
|
||||
/** Entity kinds (from preservation.ts) that carry technical meaning worth keeping. */
|
||||
const RETENTION_KINDS = new Set<string>([
|
||||
"url",
|
||||
"markdown_link",
|
||||
"const_case",
|
||||
"env_var",
|
||||
"version",
|
||||
"dotted_identifier",
|
||||
"function_call",
|
||||
"file_path",
|
||||
"error_message",
|
||||
"inline_code",
|
||||
]);
|
||||
|
||||
export interface RetentionScore {
|
||||
/** Distinct technical entities found in the original. */
|
||||
total: number;
|
||||
/** How many of those appear verbatim in the compressed text. */
|
||||
survived: number;
|
||||
/** survived / total — 1 when the original had no technical entities. */
|
||||
score: number;
|
||||
/** Entities present in the original but missing from the compressed text. */
|
||||
lost: string[];
|
||||
}
|
||||
|
||||
export interface CompressionMeasurement {
|
||||
originalTokens: number;
|
||||
compressedTokens: number;
|
||||
/** Percentage of tokens saved (0 when nothing was saved, negative if it grew). */
|
||||
savingsPercent: number;
|
||||
retention: RetentionScore;
|
||||
}
|
||||
|
||||
/** Distinct technical entities in `text`, using the canonical preservation rules. */
|
||||
export function extractEntities(text: string): string[] {
|
||||
const { blocks } = extractPreservedBlocks(text);
|
||||
const set = new Set<string>();
|
||||
for (const block of blocks) {
|
||||
if (RETENTION_KINDS.has(block.kind)) {
|
||||
const content = block.content.trim();
|
||||
if (content) set.add(content);
|
||||
}
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
/** Fraction of the original's technical entities that survive in `compressed`. */
|
||||
export function computeRetention(original: string, compressed: string): RetentionScore {
|
||||
const entities = extractEntities(original);
|
||||
if (entities.length === 0) {
|
||||
return { total: 0, survived: 0, score: 1, lost: [] };
|
||||
}
|
||||
const lost: string[] = [];
|
||||
let survived = 0;
|
||||
for (const entity of entities) {
|
||||
if (compressed.includes(entity)) survived++;
|
||||
else lost.push(entity);
|
||||
}
|
||||
return {
|
||||
total: entities.length,
|
||||
survived,
|
||||
score: survived / entities.length,
|
||||
lost,
|
||||
};
|
||||
}
|
||||
|
||||
/** Measure ratio + retention for one (original, compressed) pair. */
|
||||
export function measureCompression(original: string, compressed: string): CompressionMeasurement {
|
||||
const originalTokens = estimateCompressionTokens(original);
|
||||
const compressedTokens = estimateCompressionTokens(compressed);
|
||||
const savingsPercent =
|
||||
originalTokens > 0
|
||||
? Math.round(((originalTokens - compressedTokens) / originalTokens) * 1000) / 10
|
||||
: 0;
|
||||
return {
|
||||
originalTokens,
|
||||
compressedTokens,
|
||||
savingsPercent,
|
||||
retention: computeRetention(original, compressed),
|
||||
};
|
||||
}
|
||||
38
open-sse/services/compression/harness/replay.ts
Normal file
38
open-sse/services/compression/harness/replay.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { runCompressionEval, type CompressFn, type EvalCase, type EvalReport } from "./runner.ts";
|
||||
|
||||
/**
|
||||
* Replay-bench over real transcripts (TV3). Instead of synthetic prompts, feed
|
||||
* captured conversation turns through a compression function and measure ratio +
|
||||
* retention per turn, grouped by transcript. This catches regressions that only
|
||||
* show up on real-world inputs (tool dumps, long histories, mixed content).
|
||||
*/
|
||||
|
||||
export interface TranscriptTurn {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Transcript {
|
||||
id: string;
|
||||
turns: TranscriptTurn[];
|
||||
}
|
||||
|
||||
/** Flatten transcripts into eval cases — one per non-empty turn, grouped by transcript id. */
|
||||
export function transcriptsToCorpus(transcripts: Transcript[]): EvalCase[] {
|
||||
const corpus: EvalCase[] = [];
|
||||
for (const transcript of transcripts) {
|
||||
transcript.turns.forEach((turn, index) => {
|
||||
if (turn.content?.trim()) {
|
||||
corpus.push({ id: `${transcript.id}#${index}`, input: turn.content, task: transcript.id });
|
||||
}
|
||||
});
|
||||
}
|
||||
return corpus;
|
||||
}
|
||||
|
||||
export function replayTranscripts(
|
||||
transcripts: Transcript[],
|
||||
compress: CompressFn
|
||||
): Promise<EvalReport> {
|
||||
return runCompressionEval(transcriptsToCorpus(transcripts), compress);
|
||||
}
|
||||
58
open-sse/services/compression/harness/runner.ts
Normal file
58
open-sse/services/compression/harness/runner.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { measureCompression, type CompressionMeasurement } from "./measure.ts";
|
||||
|
||||
/**
|
||||
* Offline eval runner (C1). Feeds a corpus of known inputs through a compression
|
||||
* function and aggregates ratio + retention. The compression function may be
|
||||
* sync or async (H10-friendly), so the same harness benchmarks worker-thread
|
||||
* engines once they exist. No network/API calls — CI-safe.
|
||||
*/
|
||||
|
||||
export interface EvalCase {
|
||||
id: string;
|
||||
/** The original text to compress. */
|
||||
input: string;
|
||||
/** Grouping label for the tokens-per-task gate (defaults to `id`). */
|
||||
task?: string;
|
||||
}
|
||||
|
||||
export type CompressFn = (input: string) => string | Promise<string>;
|
||||
|
||||
export interface EvalResult extends CompressionMeasurement {
|
||||
id: string;
|
||||
task: string;
|
||||
}
|
||||
|
||||
export interface EvalReport {
|
||||
results: EvalResult[];
|
||||
meanSavingsPercent: number;
|
||||
meanRetention: number;
|
||||
totalOriginalTokens: number;
|
||||
totalCompressedTokens: number;
|
||||
}
|
||||
|
||||
export async function runCompressionEval(
|
||||
corpus: EvalCase[],
|
||||
compress: CompressFn
|
||||
): Promise<EvalReport> {
|
||||
const results: EvalResult[] = [];
|
||||
for (const item of corpus) {
|
||||
const compressed = await compress(item.input);
|
||||
results.push({
|
||||
id: item.id,
|
||||
task: item.task ?? item.id,
|
||||
...measureCompression(item.input, compressed),
|
||||
});
|
||||
}
|
||||
|
||||
const n = results.length || 1;
|
||||
const meanSavingsPercent = results.reduce((s, r) => s + r.savingsPercent, 0) / n;
|
||||
const meanRetention = results.reduce((s, r) => s + r.retention.score, 0) / n;
|
||||
|
||||
return {
|
||||
results,
|
||||
meanSavingsPercent: Math.round(meanSavingsPercent * 10) / 10,
|
||||
meanRetention: Math.round(meanRetention * 1000) / 1000,
|
||||
totalOriginalTokens: results.reduce((s, r) => s + r.originalTokens, 0),
|
||||
totalCompressedTokens: results.reduce((s, r) => s + r.compressedTokens, 0),
|
||||
};
|
||||
}
|
||||
@@ -81,9 +81,11 @@ export {
|
||||
selectCompressionStrategy,
|
||||
getEffectiveMode,
|
||||
applyCompression,
|
||||
applyCompressionAsync,
|
||||
checkComboOverride,
|
||||
shouldAutoTrigger,
|
||||
applyStackedCompression,
|
||||
applyStackedCompressionAsync,
|
||||
} from "./strategySelector.ts";
|
||||
|
||||
export type {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CompressionResult,
|
||||
CompressionStats,
|
||||
} from "./types.ts";
|
||||
import type { CompressionEngineApplyOptions } from "./engines/types.ts";
|
||||
import { applyLiteCompression } from "./lite.ts";
|
||||
import { cavemanCompress } from "./caveman.ts";
|
||||
import { compressAggressive } from "./aggressive.ts";
|
||||
@@ -179,6 +180,30 @@ export function applyCompression(
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Async entry point mirroring {@link applyCompression}. Only the stacked mode
|
||||
* can host async engines, so it routes through {@link applyStackedCompressionAsync};
|
||||
* every other mode delegates to the synchronous path unchanged. Call sites that
|
||||
* already run in an async context (e.g. chatCore) await this so a future
|
||||
* worker-thread engine can await without changing the surrounding code.
|
||||
*/
|
||||
export async function applyCompressionAsync(
|
||||
body: Record<string, unknown>,
|
||||
mode: CompressionMode,
|
||||
options?: { model?: string; supportsVision?: boolean | null; config?: CompressionConfig }
|
||||
): Promise<CompressionResult> {
|
||||
if (mode === "stacked") {
|
||||
const adapter = adaptBodyForCompression(body);
|
||||
const result = await applyStackedCompressionAsync(
|
||||
adapter.body,
|
||||
options?.config?.stackedPipeline,
|
||||
options
|
||||
);
|
||||
return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result;
|
||||
}
|
||||
return applyCompression(body, mode, options);
|
||||
}
|
||||
|
||||
function normalizePipelineStep(step: CompressionPipelineStep | string): CompressionPipelineStep {
|
||||
if (typeof step !== "string") return step;
|
||||
if (step === "standard") return { engine: "caveman" };
|
||||
@@ -187,105 +212,275 @@ function normalizePipelineStep(step: CompressionPipelineStep | string): Compress
|
||||
return { engine: "caveman" };
|
||||
}
|
||||
|
||||
export function applyStackedCompression(
|
||||
body: Record<string, unknown>,
|
||||
pipeline?: Array<CompressionPipelineStep | string>,
|
||||
options?: {
|
||||
model?: string;
|
||||
supportsVision?: boolean | null;
|
||||
config?: CompressionConfig;
|
||||
compressionComboId?: string | null;
|
||||
}
|
||||
/**
|
||||
* TV1 — Opt-in bail-out configuration for the stacked pipeline.
|
||||
* When enabled: a step that throws is silently skipped (verbatim kept);
|
||||
* a step whose gain is below minGainPercent is also skipped.
|
||||
* DEFAULT = disabled — behaviour is byte-identical to pre-TV1 when absent.
|
||||
*/
|
||||
interface BailoutConfig {
|
||||
enabled: boolean;
|
||||
/** Minimum savings percent required to advance currentBody. Default: 10. */
|
||||
minGainPercent?: number;
|
||||
}
|
||||
|
||||
interface StackOptions {
|
||||
model?: string;
|
||||
supportsVision?: boolean | null;
|
||||
config?: CompressionConfig;
|
||||
compressionComboId?: string | null;
|
||||
/** TV1 bail-out discipline (opt-in, default disabled). */
|
||||
bailout?: BailoutConfig;
|
||||
}
|
||||
|
||||
/** Accumulates per-step telemetry across a stacked run (shared sync/async). */
|
||||
interface StackAccumulator {
|
||||
techniques: Set<string>;
|
||||
rules: Set<string>;
|
||||
breakdown: NonNullable<CompressionStats["engineBreakdown"]>;
|
||||
rtkRawOutputPointers: NonNullable<CompressionStats["rtkRawOutputPointers"]>;
|
||||
validationWarnings: Set<string>;
|
||||
validationErrors: Set<string>;
|
||||
fallbackApplied: boolean;
|
||||
}
|
||||
|
||||
function createStackAccumulator(): StackAccumulator {
|
||||
return {
|
||||
techniques: new Set<string>(),
|
||||
rules: new Set<string>(),
|
||||
breakdown: [],
|
||||
rtkRawOutputPointers: [],
|
||||
validationWarnings: new Set<string>(),
|
||||
validationErrors: new Set<string>(),
|
||||
fallbackApplied: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStackSteps(
|
||||
pipeline?: Array<CompressionPipelineStep | string>
|
||||
): CompressionPipelineStep[] {
|
||||
return pipeline && pipeline.length > 0
|
||||
? pipeline.map(normalizePipelineStep)
|
||||
: [
|
||||
{ engine: "rtk", intensity: "standard" },
|
||||
{ engine: "caveman", intensity: "full" },
|
||||
];
|
||||
}
|
||||
|
||||
function buildStepOptions(
|
||||
step: CompressionPipelineStep,
|
||||
options?: StackOptions
|
||||
): CompressionEngineApplyOptions {
|
||||
return {
|
||||
...options,
|
||||
compressionComboId: options?.compressionComboId ?? options?.config?.compressionComboId,
|
||||
stepConfig: {
|
||||
...(step.config ?? {}),
|
||||
...(step.intensity ? { intensity: step.intensity } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TV1 — Pure helper that decides whether a completed step should advance
|
||||
* `currentBody`. Called only when bailout is ENABLED; the sync/async loops
|
||||
* bypass this entirely on the default-off path (zero cost, zero behaviour change).
|
||||
*
|
||||
* Returns `{ advance: true }` when the step should be accepted, or
|
||||
* `{ advance: false }` when it should be skipped (verbatim kept).
|
||||
*/
|
||||
function decideStep(result: CompressionResult, bailout: BailoutConfig): { advance: boolean } {
|
||||
if (!result.compressed) return { advance: false };
|
||||
// Clamp: a negative minGainPercent would mean "always advance" (invalid state).
|
||||
const minGain = Math.max(0, bailout.minGainPercent ?? 10);
|
||||
const gain = result.stats?.savingsPercent ?? 0;
|
||||
if (gain < minGain) return { advance: false };
|
||||
return { advance: true };
|
||||
}
|
||||
|
||||
/** Folds one engine result into the accumulator (telemetry + breakdown entry). */
|
||||
function mergeStackStep(acc: StackAccumulator, engineId: string, result: CompressionResult): void {
|
||||
if (!result.stats) return;
|
||||
result.stats.techniquesUsed.forEach((technique) => acc.techniques.add(technique));
|
||||
result.stats.rulesApplied?.forEach((rule) => acc.rules.add(rule));
|
||||
result.stats.rtkRawOutputPointers?.forEach((pointer) => acc.rtkRawOutputPointers.push(pointer));
|
||||
result.stats.validationWarnings?.forEach((warning) => acc.validationWarnings.add(warning));
|
||||
result.stats.validationErrors?.forEach((error) => acc.validationErrors.add(error));
|
||||
acc.fallbackApplied = acc.fallbackApplied || result.stats.fallbackApplied === true;
|
||||
acc.breakdown.push({
|
||||
engine: engineId,
|
||||
originalTokens: result.stats.originalTokens,
|
||||
compressedTokens: result.stats.compressedTokens,
|
||||
savingsPercent: result.stats.savingsPercent,
|
||||
techniquesUsed: result.stats.techniquesUsed,
|
||||
...(result.stats.rulesApplied ? { rulesApplied: result.stats.rulesApplied } : {}),
|
||||
...(result.stats.durationMs !== undefined ? { durationMs: result.stats.durationMs } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeStackedResult(
|
||||
originalBody: Record<string, unknown>,
|
||||
currentBody: Record<string, unknown>,
|
||||
compressed: boolean,
|
||||
acc: StackAccumulator,
|
||||
start: number,
|
||||
compressionComboId: string | null | undefined
|
||||
): CompressionResult {
|
||||
const steps =
|
||||
pipeline && pipeline.length > 0
|
||||
? pipeline.map(normalizePipelineStep)
|
||||
: [
|
||||
{ engine: "rtk" as const, intensity: "standard" as const },
|
||||
{ engine: "caveman" as const, intensity: "full" as const },
|
||||
];
|
||||
registerBuiltinCompressionEngines();
|
||||
|
||||
let currentBody = body;
|
||||
let compressed = false;
|
||||
const techniques = new Set<string>();
|
||||
const rules = new Set<string>();
|
||||
const breakdown: NonNullable<CompressionStats["engineBreakdown"]> = [];
|
||||
const rtkRawOutputPointers: NonNullable<CompressionStats["rtkRawOutputPointers"]> = [];
|
||||
const validationWarnings = new Set<string>();
|
||||
const validationErrors = new Set<string>();
|
||||
let fallbackApplied = false;
|
||||
const start = performance.now();
|
||||
|
||||
for (const step of steps) {
|
||||
const engine = getCompressionEngine(step.engine);
|
||||
if (!engine) continue;
|
||||
const result = engine.apply(currentBody, {
|
||||
...options,
|
||||
compressionComboId: options?.compressionComboId ?? options?.config?.compressionComboId,
|
||||
stepConfig: {
|
||||
...(step.config ?? {}),
|
||||
...(step.intensity ? { intensity: step.intensity } : {}),
|
||||
},
|
||||
});
|
||||
if (result.stats) {
|
||||
result.stats.techniquesUsed.forEach((technique) => techniques.add(technique));
|
||||
result.stats.rulesApplied?.forEach((rule) => rules.add(rule));
|
||||
result.stats.rtkRawOutputPointers?.forEach((pointer) => {
|
||||
rtkRawOutputPointers.push(pointer);
|
||||
});
|
||||
result.stats.validationWarnings?.forEach((warning) => validationWarnings.add(warning));
|
||||
result.stats.validationErrors?.forEach((error) => validationErrors.add(error));
|
||||
fallbackApplied = fallbackApplied || result.stats.fallbackApplied === true;
|
||||
breakdown.push({
|
||||
engine: step.engine,
|
||||
originalTokens: result.stats.originalTokens,
|
||||
compressedTokens: result.stats.compressedTokens,
|
||||
savingsPercent: result.stats.savingsPercent,
|
||||
techniquesUsed: result.stats.techniquesUsed,
|
||||
...(result.stats.rulesApplied ? { rulesApplied: result.stats.rulesApplied } : {}),
|
||||
...(result.stats.durationMs !== undefined ? { durationMs: result.stats.durationMs } : {}),
|
||||
});
|
||||
}
|
||||
if (result.compressed) {
|
||||
currentBody = result.body;
|
||||
compressed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const stats = createCompressionStats(
|
||||
body,
|
||||
originalBody,
|
||||
currentBody,
|
||||
"stacked",
|
||||
Array.from(techniques),
|
||||
rules.size > 0 ? Array.from(rules) : undefined,
|
||||
Array.from(acc.techniques),
|
||||
acc.rules.size > 0 ? Array.from(acc.rules) : undefined,
|
||||
Math.round((performance.now() - start) * 100) / 100
|
||||
);
|
||||
stats.engine = "stacked";
|
||||
stats.compressionComboId =
|
||||
options?.compressionComboId ?? options?.config?.compressionComboId ?? null;
|
||||
stats.engineBreakdown = breakdown;
|
||||
if (validationWarnings.size > 0) {
|
||||
stats.validationWarnings = Array.from(validationWarnings);
|
||||
stats.compressionComboId = compressionComboId ?? null;
|
||||
stats.engineBreakdown = acc.breakdown;
|
||||
if (acc.validationWarnings.size > 0) {
|
||||
stats.validationWarnings = Array.from(acc.validationWarnings);
|
||||
}
|
||||
if (validationErrors.size > 0) {
|
||||
stats.validationErrors = Array.from(validationErrors);
|
||||
if (acc.validationErrors.size > 0) {
|
||||
stats.validationErrors = Array.from(acc.validationErrors);
|
||||
}
|
||||
if (fallbackApplied) {
|
||||
if (acc.fallbackApplied) {
|
||||
stats.fallbackApplied = true;
|
||||
}
|
||||
if (rtkRawOutputPointers.length > 0) {
|
||||
if (acc.rtkRawOutputPointers.length > 0) {
|
||||
const seenPointers = new Set<string>();
|
||||
stats.rtkRawOutputPointers = rtkRawOutputPointers.filter((pointer) => {
|
||||
stats.rtkRawOutputPointers = acc.rtkRawOutputPointers.filter((pointer) => {
|
||||
if (seenPointers.has(pointer.id)) return false;
|
||||
seenPointers.add(pointer.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return { body: currentBody, compressed, stats };
|
||||
}
|
||||
|
||||
return {
|
||||
body: currentBody,
|
||||
export function applyStackedCompression(
|
||||
body: Record<string, unknown>,
|
||||
pipeline?: Array<CompressionPipelineStep | string>,
|
||||
options?: StackOptions
|
||||
): CompressionResult {
|
||||
const steps = resolveStackSteps(pipeline);
|
||||
registerBuiltinCompressionEngines();
|
||||
|
||||
let currentBody = body;
|
||||
let compressed = false;
|
||||
const acc = createStackAccumulator();
|
||||
const start = performance.now();
|
||||
|
||||
const bailout = options?.bailout;
|
||||
|
||||
for (const step of steps) {
|
||||
const engine = getCompressionEngine(step.engine);
|
||||
if (!engine) continue;
|
||||
|
||||
// TV1: when bail-out is ENABLED, wrap apply() and apply skip rules.
|
||||
// When DISABLED (default), the code path below is identical to pre-TV1.
|
||||
if (bailout?.enabled) {
|
||||
let result: CompressionResult;
|
||||
try {
|
||||
result = engine.apply(currentBody, buildStepOptions(step, options));
|
||||
} catch (err) {
|
||||
// Failure bail-out: keep the verbatim body for this step, but RECORD the
|
||||
// failure so a crashing engine is visible in telemetry (not silently gone).
|
||||
acc.validationErrors.add(
|
||||
`${step.engine}: bailed out — ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
acc.fallbackApplied = true;
|
||||
continue;
|
||||
}
|
||||
mergeStackStep(acc, step.engine, result);
|
||||
if (decideStep(result, bailout).advance) {
|
||||
currentBody = result.body;
|
||||
compressed = true;
|
||||
}
|
||||
} else {
|
||||
const result = engine.apply(currentBody, buildStepOptions(step, options));
|
||||
mergeStackStep(acc, step.engine, result);
|
||||
if (result.compressed) {
|
||||
currentBody = result.body;
|
||||
compressed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeStackedResult(
|
||||
body,
|
||||
currentBody,
|
||||
compressed,
|
||||
acc,
|
||||
start,
|
||||
options?.compressionComboId ?? options?.config?.compressionComboId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async sibling of {@link applyStackedCompression} (H10). Awaits engines that
|
||||
* expose `applyAsync` (e.g. worker-thread models) and runs synchronous engines
|
||||
* inline. Behaviour is otherwise identical: same step order, same accumulated
|
||||
* telemetry, same final stats — so sync-only pipelines yield the same result.
|
||||
*/
|
||||
export async function applyStackedCompressionAsync(
|
||||
body: Record<string, unknown>,
|
||||
pipeline?: Array<CompressionPipelineStep | string>,
|
||||
options?: StackOptions
|
||||
): Promise<CompressionResult> {
|
||||
const steps = resolveStackSteps(pipeline);
|
||||
registerBuiltinCompressionEngines();
|
||||
|
||||
let currentBody = body;
|
||||
let compressed = false;
|
||||
const acc = createStackAccumulator();
|
||||
const start = performance.now();
|
||||
|
||||
const bailout = options?.bailout;
|
||||
|
||||
for (const step of steps) {
|
||||
const engine = getCompressionEngine(step.engine);
|
||||
if (!engine) continue;
|
||||
const stepOptions = buildStepOptions(step, options);
|
||||
|
||||
// TV1: same bail-out discipline as the sync loop (opt-in, default off).
|
||||
if (bailout?.enabled) {
|
||||
let result: CompressionResult;
|
||||
try {
|
||||
result = engine.applyAsync
|
||||
? await engine.applyAsync(currentBody, stepOptions)
|
||||
: engine.apply(currentBody, stepOptions);
|
||||
} catch (err) {
|
||||
// Failure bail-out: keep the verbatim body, but RECORD the failure so a
|
||||
// crashing engine is visible in telemetry (not silently gone).
|
||||
acc.validationErrors.add(
|
||||
`${step.engine}: bailed out — ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
acc.fallbackApplied = true;
|
||||
continue;
|
||||
}
|
||||
mergeStackStep(acc, step.engine, result);
|
||||
if (decideStep(result, bailout).advance) {
|
||||
currentBody = result.body;
|
||||
compressed = true;
|
||||
}
|
||||
} else {
|
||||
const result = engine.applyAsync
|
||||
? await engine.applyAsync(currentBody, stepOptions)
|
||||
: engine.apply(currentBody, stepOptions);
|
||||
mergeStackStep(acc, step.engine, result);
|
||||
if (result.compressed) {
|
||||
currentBody = result.body;
|
||||
compressed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeStackedResult(
|
||||
body,
|
||||
currentBody,
|
||||
compressed,
|
||||
stats,
|
||||
};
|
||||
acc,
|
||||
start,
|
||||
options?.compressionComboId ?? options?.config?.compressionComboId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,16 @@ export type CompressionMode =
|
||||
export type CavemanIntensity = "lite" | "full" | "ultra";
|
||||
export type RtkIntensity = "minimal" | "standard" | "aggressive";
|
||||
export type RtkRawOutputRetention = "never" | "failures" | "always";
|
||||
export type CompressionEngineId = "lite" | "caveman" | "aggressive" | "ultra" | "rtk";
|
||||
export type CompressionEngineId =
|
||||
| "lite"
|
||||
| "caveman"
|
||||
| "aggressive"
|
||||
| "ultra"
|
||||
| "rtk"
|
||||
| "session-dedup"
|
||||
| "headroom"
|
||||
| "ccr"
|
||||
| "llmlingua";
|
||||
|
||||
export interface CavemanRule {
|
||||
name: string;
|
||||
@@ -66,6 +75,10 @@ export interface RtkConfig {
|
||||
trustProjectFilters: boolean;
|
||||
rawOutputRetention: RtkRawOutputRetention;
|
||||
rawOutputMaxBytes: number;
|
||||
/** R5: enable grouping of near-equivalent consecutive lines. Default: false. */
|
||||
enableGrouping?: boolean;
|
||||
/** R5: minimum consecutive similar-line run to trigger grouping. Default: 3. */
|
||||
groupingThreshold?: number;
|
||||
}
|
||||
|
||||
export interface CompressionLanguageConfig {
|
||||
|
||||
@@ -28,10 +28,7 @@ const cwd = process.cwd();
|
||||
// Anchor files (providerRegistry.ts, oauth.ts) are the canonical credential config;
|
||||
// the broader scan covers new additions in open-sse/ and src/lib/oauth/.
|
||||
// Exclusions: test files, node_modules, .next.
|
||||
const SCAN_ROOTS = [
|
||||
path.join(cwd, "open-sse"),
|
||||
path.join(cwd, "src", "lib", "oauth"),
|
||||
];
|
||||
const SCAN_ROOTS = [path.join(cwd, "open-sse"), path.join(cwd, "src", "lib", "oauth")];
|
||||
|
||||
function walkTs(dir, acc = []) {
|
||||
if (!fs.existsSync(dir)) return acc;
|
||||
@@ -84,15 +81,15 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/;
|
||||
//
|
||||
// 6A.8: Expanded scope to open-sse/** + src/lib/oauth/**. Newly discovered FPs:
|
||||
//
|
||||
// open-sse/services/usage.ts L543: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")`
|
||||
// open-sse/services/usage.ts L546: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")`
|
||||
// The CRED_KEY_RE matches `apiKey:` in the TypeScript function-parameter type annotation.
|
||||
// "minimax" and "minimax-cn" are provider-name strings in the type annotation, NOT credentials.
|
||||
// This is a false positive (the gate was designed for object-literal assignments, not fn params).
|
||||
// TODO(6A.8): Consider tightening CRED_KEY_RE to exclude function-signature contexts — but
|
||||
// that adds complexity; the FP rate is low (1 file). Frozen by file:line:value key.
|
||||
export const KNOWN_LITERAL_CREDS = new Set([
|
||||
"open-sse/services/usage.ts:543:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential
|
||||
"open-sse/services/usage.ts:543:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential
|
||||
"open-sse/services/usage.ts:546:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 543→546 by #3838 usage.ts comment)
|
||||
"open-sse/services/usage.ts:546:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 543→546 by #3838 usage.ts comment)
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
351
src/app/(dashboard)/dashboard/combos/live/ComboLiveStudio.tsx
Normal file
351
src/app/(dashboard)/dashboard/combos/live/ComboLiveStudio.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { NodeTypes } from "@xyflow/react";
|
||||
import { FlowCanvas } from "@/shared/components/flow/FlowCanvas";
|
||||
import {
|
||||
comboRunToFlow,
|
||||
reduceComboEvent,
|
||||
type ComboRunModel,
|
||||
type ComboEventInput,
|
||||
} from "./comboFlowModel";
|
||||
import { aggregateComboEventsToSets } from "./fleetAggregation";
|
||||
import { StrategyNode } from "./nodes/StrategyNode";
|
||||
import { ProviderCascadeNode } from "./nodes/ProviderCascadeNode";
|
||||
import { ResponseNode } from "./nodes/ResponseNode";
|
||||
import { RequestNode } from "./nodes/RequestNode";
|
||||
|
||||
// ── Node type map ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// comboRunToFlow (comboFlowModel.ts) emits exactly these `type` strings:
|
||||
// "request" → entry node (RequestNode)
|
||||
// "strategy" → routing strategy pill (StrategyNode)
|
||||
// "target" → one per combo target (ProviderCascadeNode)
|
||||
// "response" → terminal outcome node (ResponseNode)
|
||||
|
||||
const NODE_TYPES: NodeTypes = {
|
||||
request: RequestNode as unknown as NodeTypes["request"],
|
||||
strategy: StrategyNode as unknown as NodeTypes["strategy"],
|
||||
target: ProviderCascadeNode as unknown as NodeTypes["target"],
|
||||
response: ResponseNode as unknown as NodeTypes["response"],
|
||||
};
|
||||
|
||||
// ── Fleet overview ────────────────────────────────────────────────────────
|
||||
|
||||
const FLEET_WINDOW_MS = 60_000; // 1-minute rolling window
|
||||
|
||||
interface FleetOverviewProps {
|
||||
comboEvents: ComboEventInput[];
|
||||
}
|
||||
|
||||
function FleetOverview({ comboEvents }: FleetOverviewProps) {
|
||||
// `now` must advance, or the 60s rolling window freezes at mount and aging
|
||||
// events are never re-classified out of "active". A low-frequency tick rolls it.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 5_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const sets = useMemo(
|
||||
() => aggregateComboEventsToSets(comboEvents, FLEET_WINDOW_MS, now),
|
||||
[comboEvents, now]
|
||||
);
|
||||
|
||||
const total = sets.active.size + sets.error.size + sets.last.size;
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted">
|
||||
<span className="text-2xl opacity-40">⌁</span>
|
||||
<p className="text-sm">No providers observed yet.</p>
|
||||
<p className="text-xs opacity-60">Fleet data arrives via live combo events.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4 h-full overflow-auto" data-testid="fleet-overview">
|
||||
{sets.active.size > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted mb-1.5">
|
||||
Active ({sets.active.size})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[...sets.active].map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-[11px] px-2 py-0.5 rounded-full font-medium"
|
||||
style={{ backgroundColor: "#22c55e20", color: "#22c55e" }}
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sets.error.size > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted mb-1.5">
|
||||
Errors ({sets.error.size})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[...sets.error].map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-[11px] px-2 py-0.5 rounded-full font-medium"
|
||||
style={{ backgroundColor: "#ef444420", color: "#ef4444" }}
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sets.last.size > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted mb-1.5">
|
||||
Inactive ({sets.last.size})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[...sets.last].map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-[11px] px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: "var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-3 text-muted"
|
||||
data-testid="combo-live-studio-empty"
|
||||
>
|
||||
<span className="text-3xl opacity-40">⌁</span>
|
||||
<p className="text-sm">No combo run available.</p>
|
||||
<p className="text-xs opacity-60">Live data arrives via the WS combo channel.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Disconnected banner ───────────────────────────────────────────────────
|
||||
|
||||
function DisconnectedBanner() {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-border bg-bg/80 text-xs text-muted shrink-0"
|
||||
data-testid="combo-disconnected-banner"
|
||||
>
|
||||
<span className="text-amber-500 font-semibold">●</span>
|
||||
<span>Live disabled — WebSocket disconnected. Showing last known state.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ComboLiveStudioProps {
|
||||
/**
|
||||
* If provided, renders this run directly (static / test mode — no WS dependency).
|
||||
* Mirror of how CompressionCockpit accepts `run?`.
|
||||
*/
|
||||
run?: ComboRunModel | null;
|
||||
/**
|
||||
* All raw combo events used to (a) populate the combo selector and
|
||||
* (b) fold into a run for the selected combo.
|
||||
* In live mode, callers supply events from `useLiveComboStatus().comboEvents`.
|
||||
*/
|
||||
comboEvents?: ComboEventInput[];
|
||||
/**
|
||||
* Available combo names for the combo selector.
|
||||
* Defaults to the unique comboNames found in `comboEvents`.
|
||||
*/
|
||||
combos?: string[];
|
||||
/**
|
||||
* Whether the WebSocket connection is live. Defaults to true.
|
||||
* When false, shows the "Live disabled" banner.
|
||||
*/
|
||||
isConnected?: boolean;
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ComboLiveStudio — Tela B: Combo/Routing Studio.
|
||||
*
|
||||
* Renders the combo routing cascade as a ReactFlow canvas:
|
||||
* Request → StrategyNode → [ProviderCascadeNode × N] → ResponseNode
|
||||
*
|
||||
* Features:
|
||||
* - Combo selector: picks which combo's latest run to display.
|
||||
* - Single ⇄ Fleet toggle: Single = cascade flow; Fleet = radial provider
|
||||
* aggregation over all `comboEvents` (last 60 s).
|
||||
* - Graceful empty state when no run is available.
|
||||
* - "Live disabled" banner when `isConnected` is false.
|
||||
*
|
||||
* Testable with a static `run` prop — no WS required.
|
||||
*/
|
||||
export function ComboLiveStudio({
|
||||
run: runProp,
|
||||
comboEvents = [],
|
||||
combos: combosProp,
|
||||
isConnected = true,
|
||||
}: ComboLiveStudioProps) {
|
||||
const [mode, setMode] = useState<"single" | "fleet">("single");
|
||||
const [selectedCombo, setSelectedCombo] = useState<string>("");
|
||||
|
||||
// Derive combo list from events when not supplied explicitly
|
||||
const comboOptions = useMemo<string[]>(() => {
|
||||
if (combosProp) return combosProp;
|
||||
const seen = new Set<string>();
|
||||
for (const ev of comboEvents) seen.add(ev.comboName);
|
||||
return [...seen];
|
||||
}, [combosProp, comboEvents]);
|
||||
|
||||
// Build the displayed run: static prop wins; otherwise fold live events
|
||||
const displayRun = useMemo<ComboRunModel | null>(() => {
|
||||
if (runProp !== undefined) return runProp ?? null;
|
||||
|
||||
if (!selectedCombo) return null;
|
||||
|
||||
const eventsForCombo = comboEvents
|
||||
.filter((e) => e.comboName === selectedCombo)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
if (eventsForCombo.length === 0) return null;
|
||||
|
||||
return eventsForCombo.reduce<ComboRunModel | null>(
|
||||
(acc, ev) => reduceComboEvent(acc, ev),
|
||||
null
|
||||
);
|
||||
}, [runProp, selectedCombo, comboEvents]);
|
||||
|
||||
// Build ReactFlow graph from the current run
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
if (!displayRun) return { nodes: [], edges: [] };
|
||||
return comboRunToFlow(displayRun);
|
||||
}, [displayRun]);
|
||||
|
||||
const fitKey = displayRun
|
||||
? `${displayRun.comboName}-${displayRun.targets.length}-${displayRun.outcome}`
|
||||
: "empty";
|
||||
|
||||
// Whether to show the combo selector (hidden when a static run prop is given)
|
||||
const showSelector = runProp === undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-2" data-testid="combo-live-studio">
|
||||
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-bg/60 shrink-0 flex-wrap">
|
||||
{showSelector && (
|
||||
<select
|
||||
className="text-xs border border-border rounded px-2 py-1 bg-bg text-muted"
|
||||
value={selectedCombo}
|
||||
onChange={(e) => setSelectedCombo(e.target.value)}
|
||||
aria-label="Select combo"
|
||||
data-testid="combo-selector"
|
||||
>
|
||||
<option value="">— select combo —</option>
|
||||
{comboOptions.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{displayRun && (
|
||||
<>
|
||||
<span className="text-xs font-mono text-muted truncate">{displayRun.comboName}</span>
|
||||
{displayRun.strategy && (
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded-full font-semibold"
|
||||
style={{ backgroundColor: "#6366f120", color: "#6366f1" }}
|
||||
>
|
||||
{displayRun.strategy}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted">
|
||||
{displayRun.targets.length} target{displayRun.targets.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-bold"
|
||||
style={{
|
||||
color:
|
||||
displayRun.outcome === "succeeded"
|
||||
? "#22c55e"
|
||||
: displayRun.outcome === "exhausted"
|
||||
? "#ef4444"
|
||||
: "#f59e0b",
|
||||
}}
|
||||
data-testid="run-outcome"
|
||||
>
|
||||
{displayRun.outcome}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Single ⇄ Fleet toggle */}
|
||||
<div className="ml-auto flex items-center border border-border rounded overflow-hidden text-xs">
|
||||
<button
|
||||
className="px-2.5 py-1 transition-colors"
|
||||
style={{
|
||||
background: mode === "single" ? "var(--color-primary)" : "transparent",
|
||||
color: mode === "single" ? "#fff" : "var(--color-text-muted)",
|
||||
}}
|
||||
onClick={() => setMode("single")}
|
||||
data-testid="mode-single"
|
||||
>
|
||||
Single
|
||||
</button>
|
||||
<button
|
||||
className="px-2.5 py-1 transition-colors"
|
||||
style={{
|
||||
background: mode === "fleet" ? "var(--color-primary)" : "transparent",
|
||||
color: mode === "fleet" ? "#fff" : "var(--color-text-muted)",
|
||||
}}
|
||||
onClick={() => setMode("fleet")}
|
||||
data-testid="mode-fleet"
|
||||
>
|
||||
Fleet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Disconnected banner ───────────────────────────────────────────── */}
|
||||
{!isConnected && <DisconnectedBanner />}
|
||||
|
||||
{/* ── Main canvas area ─────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-h-0 rounded-lg overflow-hidden border border-border">
|
||||
{mode === "fleet" ? (
|
||||
<FleetOverview comboEvents={comboEvents} />
|
||||
) : displayRun ? (
|
||||
<FlowCanvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
fitKey={fitKey}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ComboLiveStudio;
|
||||
343
src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts
Normal file
343
src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* comboFlowModel — pure reducer and flow builder for Combo/Routing Studio (Tela B).
|
||||
*
|
||||
* Pure functions: no React, no side effects, no Date.now() calls.
|
||||
* Converts a stream of `combo.target.*` WS events into:
|
||||
* 1. A `ComboRunModel` (typed, serialisable model for UI).
|
||||
* 2. A ReactFlow `{ nodes, edges }` graph (left→right cascade view).
|
||||
*
|
||||
* Design choices:
|
||||
* - `ComboEventInput` is a minimal structural type covering the fields we use
|
||||
* (kind/type, provider, model, targetIndex, strategy, latencyMs, error). It is
|
||||
* intentionally compatible with `LiveComboEvent` from `useLiveDashboard.ts`, but
|
||||
* adds the optional `strategy` field that the actual WS payload (`ComboTargetAttemptPayload`)
|
||||
* carries. This avoids importing a "use client" hook file into a pure module.
|
||||
* - `classifyFailKind` checks `/circuit/i` first so a combined message like
|
||||
* "circuit open after 429" maps to `circuit-open`, not `rate-limit`.
|
||||
* - Targets are kept sorted by `targetIndex`. A `Map<number, TargetNodeModel>` is
|
||||
* used internally and converted to an array on each return — cheap at this scale.
|
||||
* - When an `attempt` event arrives for a `targetIndex` that already exists in the run
|
||||
* (rare but possible), it idempotently sets the state back to `attempting`.
|
||||
* - Events whose `comboName` does not match the run's are silently ignored so the
|
||||
* caller can safely fan-in all events to a single reducer without filtering.
|
||||
* - `comboRunToFlow` produces N+3 nodes: request → strategy → [target×N] → response.
|
||||
* Edge style is derived via `edgeStyle` from the shared flow palette; the edge
|
||||
* *into* each target node carries the target's state colour.
|
||||
*/
|
||||
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import { edgeStyle } from "@/shared/components/flow/edgeStyles";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TargetState = "idle" | "attempting" | "failed" | "succeeded" | "skipped";
|
||||
|
||||
export type FailKind = "rate-limit" | "circuit-open" | "cooldown" | "other";
|
||||
|
||||
export interface TargetNodeModel {
|
||||
targetIndex: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
state: TargetState;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
failKind?: FailKind;
|
||||
}
|
||||
|
||||
export interface ComboRunModel {
|
||||
comboName: string;
|
||||
strategy: string;
|
||||
/** Ordered by targetIndex ascending. */
|
||||
targets: TargetNodeModel[];
|
||||
outcome: "running" | "succeeded" | "exhausted";
|
||||
startedAt: number;
|
||||
finishedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal structural type for combo WS events. Compatible with `LiveComboEvent`
|
||||
* from `useLiveDashboard.ts` but adds the optional `strategy` field that the
|
||||
* `combo.target.attempt` payload carries (not surfaced in LiveComboEvent itself).
|
||||
*/
|
||||
export interface ComboEventInput {
|
||||
comboName: string;
|
||||
targetIndex: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
type: "attempt" | "succeeded" | "failed";
|
||||
strategy?: string;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ── classifyFailKind ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Heuristic classification of the failure reason from an error string.
|
||||
*
|
||||
* Precedence (first match wins):
|
||||
* /circuit/i → circuit-open
|
||||
* /429|rate/i → rate-limit
|
||||
* /cooldown/i → cooldown
|
||||
* any other → other
|
||||
* absent/empty → undefined
|
||||
*/
|
||||
export function classifyFailKind(error?: string): FailKind | undefined {
|
||||
if (!error) return undefined;
|
||||
if (/circuit/i.test(error)) return "circuit-open";
|
||||
if (/429|rate/i.test(error)) return "rate-limit";
|
||||
if (/cooldown/i.test(error)) return "cooldown";
|
||||
return "other";
|
||||
}
|
||||
|
||||
// ── reduceComboEvent ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pure reducer: apply one `ComboEventInput` to the current `ComboRunModel`.
|
||||
*
|
||||
* - `attempt` → creates a new run (if null) or adds/updates the target.
|
||||
* Strategy is stored from the first attempt event that carries it.
|
||||
* - `failed` → marks that target failed with failKind inferred from error.
|
||||
* - `succeeded` → marks that target succeeded, closes the run
|
||||
* (outcome "succeeded", finishedAt set).
|
||||
* - Events for a different `comboName` than the existing run are ignored.
|
||||
*
|
||||
* Targets are always returned sorted by targetIndex.
|
||||
*/
|
||||
export function reduceComboEvent(run: ComboRunModel | null, ev: ComboEventInput): ComboRunModel {
|
||||
// Ignore mismatched combo events
|
||||
if (run !== null && ev.comboName !== run.comboName) {
|
||||
return run;
|
||||
}
|
||||
|
||||
switch (ev.type) {
|
||||
case "attempt": {
|
||||
const existingTargets = run
|
||||
? new Map(run.targets.map((t) => [t.targetIndex, t]))
|
||||
: new Map<number, TargetNodeModel>();
|
||||
|
||||
const updatedTarget: TargetNodeModel = {
|
||||
targetIndex: ev.targetIndex,
|
||||
provider: ev.provider,
|
||||
model: ev.model,
|
||||
state: "attempting",
|
||||
};
|
||||
|
||||
existingTargets.set(ev.targetIndex, updatedTarget);
|
||||
|
||||
const sortedTargets = [...existingTargets.values()].sort(
|
||||
(a, b) => a.targetIndex - b.targetIndex
|
||||
);
|
||||
|
||||
return {
|
||||
comboName: ev.comboName,
|
||||
strategy: run?.strategy || ev.strategy || "",
|
||||
targets: sortedTargets,
|
||||
outcome: run?.outcome === "succeeded" ? "succeeded" : "running",
|
||||
startedAt: run?.startedAt ?? ev.timestamp,
|
||||
finishedAt: run?.finishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
case "failed": {
|
||||
if (!run) {
|
||||
// Defensive: failed without a prior run — create a minimal one
|
||||
const target: TargetNodeModel = {
|
||||
targetIndex: ev.targetIndex,
|
||||
provider: ev.provider,
|
||||
model: ev.model,
|
||||
state: "failed",
|
||||
latencyMs: ev.latencyMs,
|
||||
error: ev.error,
|
||||
failKind: classifyFailKind(ev.error),
|
||||
};
|
||||
return {
|
||||
comboName: ev.comboName,
|
||||
strategy: "",
|
||||
targets: [target],
|
||||
outcome: "running",
|
||||
startedAt: ev.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedTargets = run.targets.map((t) =>
|
||||
t.targetIndex === ev.targetIndex
|
||||
? {
|
||||
...t,
|
||||
state: "failed" as TargetState,
|
||||
latencyMs: ev.latencyMs,
|
||||
error: ev.error,
|
||||
failKind: classifyFailKind(ev.error),
|
||||
}
|
||||
: t
|
||||
);
|
||||
|
||||
return {
|
||||
...run,
|
||||
targets: updatedTargets,
|
||||
};
|
||||
}
|
||||
|
||||
case "succeeded": {
|
||||
if (!run) {
|
||||
const target: TargetNodeModel = {
|
||||
targetIndex: ev.targetIndex,
|
||||
provider: ev.provider,
|
||||
model: ev.model,
|
||||
state: "succeeded",
|
||||
latencyMs: ev.latencyMs,
|
||||
};
|
||||
return {
|
||||
comboName: ev.comboName,
|
||||
strategy: "",
|
||||
targets: [target],
|
||||
outcome: "succeeded",
|
||||
startedAt: ev.timestamp,
|
||||
finishedAt: ev.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedTargets = run.targets.map((t) =>
|
||||
t.targetIndex === ev.targetIndex
|
||||
? {
|
||||
...t,
|
||||
state: "succeeded" as TargetState,
|
||||
latencyMs: ev.latencyMs,
|
||||
}
|
||||
: t
|
||||
);
|
||||
|
||||
return {
|
||||
...run,
|
||||
targets: updatedTargets,
|
||||
outcome: "succeeded",
|
||||
finishedAt: ev.timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── comboRunToFlow ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a ReactFlow graph from a `ComboRunModel`.
|
||||
*
|
||||
* Layout (left→right):
|
||||
* Request → StrategyNode → [ProviderCascadeNode × N] → Response
|
||||
*
|
||||
* Node count: N + 3 (request + strategy + N targets + response)
|
||||
* Edge count: N + 2 (one per consecutive node pair)
|
||||
*
|
||||
* Edge into each target node is styled by the target's state:
|
||||
* succeeded → active (green)
|
||||
* failed → error (red)
|
||||
* attempting → last (amber — "in-flight")
|
||||
* idle/skip → idle (muted)
|
||||
*
|
||||
* The edge out of the last target into the Response node mirrors the overall
|
||||
* outcome: green if succeeded, muted otherwise.
|
||||
*/
|
||||
export function comboRunToFlow(run: ComboRunModel): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
const X_STEP = 220;
|
||||
|
||||
// ── Request node ─────────────────────────────────────────────────────────
|
||||
const requestId = "request";
|
||||
nodes.push({
|
||||
id: requestId,
|
||||
type: "request",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: "Request",
|
||||
comboName: run.comboName,
|
||||
},
|
||||
});
|
||||
|
||||
// ── Strategy node ─────────────────────────────────────────────────────────
|
||||
const strategyId = "strategy";
|
||||
nodes.push({
|
||||
id: strategyId,
|
||||
type: "strategy",
|
||||
position: { x: X_STEP, y: 0 },
|
||||
data: {
|
||||
label: run.strategy || "combo",
|
||||
strategy: run.strategy,
|
||||
targetCount: run.targets.length,
|
||||
},
|
||||
});
|
||||
|
||||
// Edge: request → strategy (always active/idle depending on run state)
|
||||
const runActive = run.outcome === "running";
|
||||
const reqStratStyle = edgeStyle(runActive, !runActive, false);
|
||||
edges.push({
|
||||
id: `e-${requestId}-${strategyId}`,
|
||||
source: requestId,
|
||||
target: strategyId,
|
||||
style: reqStratStyle,
|
||||
});
|
||||
|
||||
// ── Target nodes ──────────────────────────────────────────────────────────
|
||||
let prevId = strategyId;
|
||||
for (let i = 0; i < run.targets.length; i++) {
|
||||
const t = run.targets[i];
|
||||
const nodeId = `target-${t.targetIndex}`;
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: "target",
|
||||
position: { x: (i + 2) * X_STEP, y: 0 },
|
||||
data: {
|
||||
label: `${t.provider}/${t.model}`,
|
||||
provider: t.provider,
|
||||
model: t.model,
|
||||
state: t.state,
|
||||
latencyMs: t.latencyMs,
|
||||
error: t.error,
|
||||
failKind: t.failKind,
|
||||
targetIndex: t.targetIndex,
|
||||
},
|
||||
});
|
||||
|
||||
// Edge styled by this target's state
|
||||
const isError = t.state === "failed";
|
||||
const isActive = t.state === "succeeded";
|
||||
const isLast = t.state === "attempting";
|
||||
const targetEdgeStyle = edgeStyle(isActive, isLast, isError);
|
||||
|
||||
edges.push({
|
||||
id: `e-${prevId}-${nodeId}`,
|
||||
source: prevId,
|
||||
target: nodeId,
|
||||
style: targetEdgeStyle,
|
||||
});
|
||||
|
||||
prevId = nodeId;
|
||||
}
|
||||
|
||||
// ── Response node ─────────────────────────────────────────────────────────
|
||||
const responseId = "response";
|
||||
nodes.push({
|
||||
id: responseId,
|
||||
type: "response",
|
||||
position: { x: (run.targets.length + 2) * X_STEP, y: 0 },
|
||||
data: {
|
||||
label: "Response",
|
||||
outcome: run.outcome,
|
||||
finishedAt: run.finishedAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Edge: last target → response — green if succeeded, muted otherwise
|
||||
const succeeded = run.outcome === "succeeded";
|
||||
const responseEdgeStyle = edgeStyle(succeeded, false, false);
|
||||
edges.push({
|
||||
id: `e-${prevId}-${responseId}`,
|
||||
source: prevId,
|
||||
target: responseId,
|
||||
style: responseEdgeStyle,
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* fleetAggregation — pure aggregation for Combo/Routing Studio fleet mode (U2).
|
||||
*
|
||||
* Pure functions: no React, no side effects, no Date.now() calls.
|
||||
* `now` is always passed as a parameter so this module is trivially unit-testable.
|
||||
*
|
||||
* `aggregateComboEventsToSets` classifies providers into three mutually-exclusive
|
||||
* sets based on their most recent event within a rolling time window:
|
||||
*
|
||||
* error — most recent event within windowMs was `failed`
|
||||
* active — most recent event within windowMs was `attempt` or `succeeded`
|
||||
* last — most recent event is older than windowMs (or no events)
|
||||
*
|
||||
* A provider appears in exactly one set (latest-event wins; error/active only for
|
||||
* events within the window; everything else lands in `last`).
|
||||
*
|
||||
* This mirrors the three-state fleet colouring of `ProviderTopology.tsx` (the
|
||||
* `activeSet / errorSet / lastSet` pattern) so the fleet view of Tela B uses the
|
||||
* same visual language as the home dashboard.
|
||||
*/
|
||||
|
||||
import type { ComboEventInput } from "./comboFlowModel";
|
||||
|
||||
// ── aggregateComboEventsToSets ────────────────────────────────────────────
|
||||
|
||||
export interface FleetSets {
|
||||
/** Providers whose most recent event (within window) was `failed`. */
|
||||
error: Set<string>;
|
||||
/** Providers whose most recent event (within window) was `attempt` or `succeeded`. */
|
||||
active: Set<string>;
|
||||
/** Providers with events only outside the window, or no events. */
|
||||
last: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate combo events into three provider sets for fleet mode display.
|
||||
*
|
||||
* @param events All combo events to consider (order does not matter — timestamps govern).
|
||||
* @param windowMs Rolling window size in ms. Events older than `now - windowMs` → `last`.
|
||||
* @param now Current time in ms (epoch). Caller-supplied for purity and testability.
|
||||
*/
|
||||
export function aggregateComboEventsToSets(
|
||||
events: ComboEventInput[],
|
||||
windowMs: number,
|
||||
now: number
|
||||
): FleetSets {
|
||||
// Build a map of provider → {latestTimestamp, latestType}
|
||||
// We only care about the most recent event per provider.
|
||||
const latestByProvider = new Map<string, { timestamp: number; type: ComboEventInput["type"] }>();
|
||||
|
||||
for (const ev of events) {
|
||||
const existing = latestByProvider.get(ev.provider);
|
||||
if (!existing || ev.timestamp > existing.timestamp) {
|
||||
latestByProvider.set(ev.provider, { timestamp: ev.timestamp, type: ev.type });
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Set<string>();
|
||||
const active = new Set<string>();
|
||||
const last = new Set<string>();
|
||||
|
||||
for (const [provider, { timestamp, type }] of latestByProvider) {
|
||||
const age = now - timestamp;
|
||||
const withinWindow = age < windowMs;
|
||||
|
||||
if (withinWindow && type === "failed") {
|
||||
error.add(provider);
|
||||
} else if (withinWindow && (type === "attempt" || type === "succeeded")) {
|
||||
active.add(provider);
|
||||
} else {
|
||||
last.add(provider);
|
||||
}
|
||||
}
|
||||
|
||||
return { error, active, last };
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { StatusDot } from "@/shared/components/flow/StatusDot";
|
||||
import { FLOW_EDGE_COLORS } from "@/shared/components/flow/edgeStyles";
|
||||
import type { TargetState, FailKind } from "../comboFlowModel";
|
||||
|
||||
// ── State → visual mapping ────────────────────────────────────────────────
|
||||
|
||||
function getStateBorderColor(state: TargetState): string {
|
||||
switch (state) {
|
||||
case "attempting":
|
||||
return FLOW_EDGE_COLORS.last; // amber
|
||||
case "failed":
|
||||
return FLOW_EDGE_COLORS.error; // red
|
||||
case "succeeded":
|
||||
return FLOW_EDGE_COLORS.active; // green
|
||||
case "idle":
|
||||
case "skipped":
|
||||
default:
|
||||
return "var(--color-border)";
|
||||
}
|
||||
}
|
||||
|
||||
function getStateGlow(state: TargetState): string {
|
||||
switch (state) {
|
||||
case "attempting":
|
||||
return `0 0 12px ${FLOW_EDGE_COLORS.last}40`;
|
||||
case "failed":
|
||||
return `0 0 12px ${FLOW_EDGE_COLORS.error}40`;
|
||||
case "succeeded":
|
||||
return `0 0 12px ${FLOW_EDGE_COLORS.active}40`;
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
const FAIL_KIND_LABELS: Record<FailKind, string> = {
|
||||
"rate-limit": "rate-limit",
|
||||
"circuit-open": "circuit-open",
|
||||
cooldown: "cooldown",
|
||||
other: "error",
|
||||
};
|
||||
|
||||
// ── Node data shape ───────────────────────────────────────────────────────
|
||||
|
||||
export interface ProviderCascadeNodeData {
|
||||
label: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
state: TargetState;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
failKind?: FailKind;
|
||||
targetIndex: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ProviderCascadeNode — one node per combo target in the routing cascade.
|
||||
*
|
||||
* Coloured by `state`:
|
||||
* idle/skipped → muted gray border
|
||||
* attempting → amber border + StatusDot pulse
|
||||
* failed → red border + failKind badge
|
||||
* succeeded → green border
|
||||
*
|
||||
* Has Left (target) and Right (source) Handles for the cascade flow.
|
||||
*/
|
||||
export function ProviderCascadeNode({ data }: NodeProps) {
|
||||
const { provider, model, state, latencyMs, failKind, targetIndex } =
|
||||
data as ProviderCascadeNodeData;
|
||||
|
||||
const borderColor = getStateBorderColor(state as TargetState);
|
||||
const glow = getStateGlow(state as TargetState);
|
||||
const isAttempting = state === "attempting";
|
||||
const isFailed = state === "failed";
|
||||
const isSucceeded = state === "succeeded";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col rounded-lg border-2 bg-bg transition-all duration-300 min-w-[150px] max-w-[190px]"
|
||||
style={{ borderColor, boxShadow: glow }}
|
||||
data-testid={`provider-cascade-node-${targetIndex}`}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!bg-transparent !border-0 !w-0 !h-0"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!bg-transparent !border-0 !w-0 !h-0"
|
||||
/>
|
||||
|
||||
{/* Header: provider icon + name */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2.5 pt-2 pb-1"
|
||||
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||
>
|
||||
<ProviderIcon providerId={provider as string} size={14} />
|
||||
<span
|
||||
className="text-xs font-semibold truncate flex-1"
|
||||
title={provider as string}
|
||||
data-testid="provider-name"
|
||||
>
|
||||
{provider as string}
|
||||
</span>
|
||||
{isAttempting && <StatusDot color={FLOW_EDGE_COLORS.last} sizeClass="size-1.5" />}
|
||||
{isFailed && <StatusDot color={FLOW_EDGE_COLORS.error} error sizeClass="size-1.5" />}
|
||||
{isSucceeded && (
|
||||
<span className="text-[9px] font-bold" style={{ color: FLOW_EDGE_COLORS.active }}>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body: model + latency */}
|
||||
<div className="px-2.5 py-1.5 flex flex-col gap-0.5">
|
||||
<span
|
||||
className="text-[10px] text-muted font-mono truncate"
|
||||
title={model as string}
|
||||
data-testid="model-name"
|
||||
>
|
||||
{model as string}
|
||||
</span>
|
||||
{latencyMs != null && (
|
||||
<span className="text-[10px] text-muted">{(latencyMs as number).toFixed(0)}ms</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: failKind badge when failed */}
|
||||
{isFailed && failKind && (
|
||||
<div className="px-2.5 pb-2">
|
||||
<span
|
||||
className="text-[9px] font-semibold px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: `${FLOW_EDGE_COLORS.error}20`,
|
||||
color: FLOW_EDGE_COLORS.error,
|
||||
}}
|
||||
data-testid="fail-kind-badge"
|
||||
>
|
||||
{FAIL_KIND_LABELS[failKind as FailKind]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProviderCascadeNode;
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
|
||||
// ── Node data shape ───────────────────────────────────────────────────────
|
||||
|
||||
export interface RequestNodeData {
|
||||
label: string;
|
||||
comboName: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* RequestNode — entry node in the combo cascade flow graph.
|
||||
* Shows "Request" label + the combo name. Source handle on the Right.
|
||||
*/
|
||||
export function RequestNode({ data }: NodeProps) {
|
||||
const { comboName } = data as RequestNodeData;
|
||||
const color = "#6366f1"; // indigo — matches IoNode input color
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border-2 bg-bg px-4 py-3 min-w-[110px] text-center transition-all duration-200"
|
||||
style={{
|
||||
borderColor: color,
|
||||
boxShadow: `0 0 12px ${color}25`,
|
||||
}}
|
||||
data-testid="request-node"
|
||||
>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!bg-transparent !border-0 !w-0 !h-0"
|
||||
/>
|
||||
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider mb-1" style={{ color }}>
|
||||
Request
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px] font-mono text-muted truncate max-w-[130px]"
|
||||
title={comboName as string}
|
||||
>
|
||||
{comboName as string}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RequestNode;
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { FLOW_EDGE_COLORS } from "@/shared/components/flow/edgeStyles";
|
||||
import type { ComboRunModel } from "../comboFlowModel";
|
||||
|
||||
// ── Node data shape ───────────────────────────────────────────────────────
|
||||
|
||||
export interface ResponseNodeData {
|
||||
label: string;
|
||||
outcome: ComboRunModel["outcome"];
|
||||
finishedAt?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function getOutcomeColor(outcome: ComboRunModel["outcome"]): string {
|
||||
switch (outcome) {
|
||||
case "succeeded":
|
||||
return FLOW_EDGE_COLORS.active;
|
||||
case "exhausted":
|
||||
return FLOW_EDGE_COLORS.error;
|
||||
case "running":
|
||||
default:
|
||||
return FLOW_EDGE_COLORS.last;
|
||||
}
|
||||
}
|
||||
|
||||
const OUTCOME_LABELS: Record<ComboRunModel["outcome"], string> = {
|
||||
succeeded: "succeeded",
|
||||
exhausted: "exhausted",
|
||||
running: "running…",
|
||||
};
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ResponseNode — terminal node in the cascade showing:
|
||||
* - Overall outcome: succeeded / exhausted / running
|
||||
* - Number of fallbacks (targets tried − 1 if succeeded, all if exhausted)
|
||||
* - Total latency (derived from finishedAt if available)
|
||||
*
|
||||
* Has a Left (target) Handle only — no right output.
|
||||
*/
|
||||
export function ResponseNode({ data }: NodeProps) {
|
||||
const { outcome, finishedAt } = data as ResponseNodeData;
|
||||
const color = getOutcomeColor(outcome as ComboRunModel["outcome"]);
|
||||
const outcomeLabel = OUTCOME_LABELS[outcome as ComboRunModel["outcome"]] ?? (outcome as string);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-1.5 px-3 py-2 rounded-lg border-2 bg-bg transition-all duration-300"
|
||||
style={{
|
||||
borderColor: color,
|
||||
boxShadow: `0 0 10px ${color}30`,
|
||||
minWidth: "100px",
|
||||
}}
|
||||
data-testid="response-node"
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!bg-transparent !border-0 !w-0 !h-0"
|
||||
/>
|
||||
|
||||
{/* Outcome indicator */}
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase tracking-wide"
|
||||
style={{ color }}
|
||||
data-testid="response-outcome"
|
||||
>
|
||||
{outcomeLabel}
|
||||
</span>
|
||||
|
||||
{/* Finished timestamp */}
|
||||
{finishedAt != null && (
|
||||
<span className="text-[9px] text-muted font-mono" data-testid="response-finished-at">
|
||||
{new Date(finishedAt as number).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Response label */}
|
||||
<span className="text-xs font-bold" style={{ color }}>
|
||||
Response
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResponseNode;
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
|
||||
// ── Strategy color map ────────────────────────────────────────────────────
|
||||
|
||||
const STRATEGY_COLORS: Record<string, string> = {
|
||||
priority: "#3b82f6",
|
||||
weighted: "#8b5cf6",
|
||||
"round-robin": "#06b6d4",
|
||||
"fill-first": "#f59e0b",
|
||||
p2c: "#10b981",
|
||||
random: "#6b7280",
|
||||
"least-used": "#84cc16",
|
||||
"cost-optimized": "#22c55e",
|
||||
"reset-aware": "#f97316",
|
||||
"reset-window": "#f97316",
|
||||
"strict-random": "#a855f7",
|
||||
auto: "#6366f1",
|
||||
lkgp: "#ec4899",
|
||||
"context-optimized": "#14b8a6",
|
||||
"context-relay": "#0ea5e9",
|
||||
};
|
||||
|
||||
function getStrategyColor(strategy: string): string {
|
||||
return STRATEGY_COLORS[strategy] ?? "#6b7280";
|
||||
}
|
||||
|
||||
// ── Node data shape ───────────────────────────────────────────────────────
|
||||
|
||||
export interface StrategyNodeData {
|
||||
label: string;
|
||||
strategy: string;
|
||||
targetCount: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* StrategyNode — pill that shows the combo routing strategy + target count.
|
||||
* Custom ReactFlow node with Left/Right Handles (left = source from request,
|
||||
* right = target into first provider cascade).
|
||||
*/
|
||||
export function StrategyNode({ data }: NodeProps) {
|
||||
const { strategy, targetCount } = data as StrategyNodeData;
|
||||
const color = getStrategyColor(strategy as string);
|
||||
const label = (strategy as string) || "combo";
|
||||
const count = targetCount as number;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-1 px-3 py-2 rounded-full border-2 bg-bg transition-all duration-300"
|
||||
style={{
|
||||
borderColor: color,
|
||||
boxShadow: `0 0 10px ${color}30`,
|
||||
minWidth: "110px",
|
||||
}}
|
||||
data-testid="strategy-node"
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!bg-transparent !border-0 !w-0 !h-0"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!bg-transparent !border-0 !w-0 !h-0"
|
||||
/>
|
||||
|
||||
<span
|
||||
className="text-xs font-semibold uppercase tracking-wide truncate"
|
||||
style={{ color }}
|
||||
data-testid="strategy-label"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="text-[10px] font-mono px-1.5 rounded-full"
|
||||
style={{ backgroundColor: `${color}20`, color }}
|
||||
data-testid="strategy-target-count"
|
||||
>
|
||||
{count} target{count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StrategyNode;
|
||||
27
src/app/(dashboard)/dashboard/combos/live/page.tsx
Normal file
27
src/app/(dashboard)/dashboard/combos/live/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { ComboLiveStudio } from "./ComboLiveStudio";
|
||||
import { useLiveComboStatus } from "@/hooks/useLiveDashboard";
|
||||
|
||||
/**
|
||||
* Combo/Routing Studio (Tela B) — live combo cascade.
|
||||
*
|
||||
* Thin route wrapper: subscribes to the `combo` WS channel via
|
||||
* `useLiveComboStatus` and feeds the events into the studio. `LiveComboEvent` is
|
||||
* structurally compatible with the studio's `ComboEventInput`. The studio shows a
|
||||
* "Live disabled" banner + empty state when the WS feed is off, so this degrades
|
||||
* gracefully.
|
||||
*/
|
||||
export default function ComboLiveStudioPage() {
|
||||
const { comboEvents, activeCombos, isConnected } = useLiveComboStatus();
|
||||
|
||||
return (
|
||||
<div className="p-4 h-[calc(100dvh-6rem)] min-h-[480px]">
|
||||
<ComboLiveStudio
|
||||
comboEvents={comboEvents}
|
||||
combos={[...activeCombos]}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { NodeTypes } from "@xyflow/react";
|
||||
import { FlowCanvas } from "@/shared/components/flow/FlowCanvas";
|
||||
import { EngineNode } from "./nodes/EngineNode";
|
||||
import { IoNode } from "./nodes/IoNode";
|
||||
import { compressionRunToFlow, type CompressionRunModel } from "./compressionFlowModel";
|
||||
import { useCompressionReplay, type ReplaySpeed } from "./useCompressionReplay";
|
||||
|
||||
// ── Static node type map (defined outside to avoid re-creation) ───────────
|
||||
|
||||
const NODE_TYPES: NodeTypes = {
|
||||
engine: EngineNode as unknown as NodeTypes["engine"],
|
||||
io: IoNode as unknown as NodeTypes["io"],
|
||||
// The model also sets type "input"/"output" for IoNode — map both
|
||||
input: IoNode as unknown as NodeTypes["input"],
|
||||
output: IoNode as unknown as NodeTypes["output"],
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
// ── Speed selector ────────────────────────────────────────────────────────
|
||||
|
||||
const SPEEDS: ReplaySpeed[] = [0.3, 1, 3];
|
||||
|
||||
function SpeedButton({
|
||||
s,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
s: ReplaySpeed;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="px-2 py-0.5 rounded text-[11px] border transition-colors"
|
||||
style={{
|
||||
borderColor: active ? "var(--color-primary)" : "var(--color-border)",
|
||||
color: active ? "var(--color-primary)" : "var(--color-text-muted)",
|
||||
background: active ? "var(--color-primary-subtle)" : "transparent",
|
||||
}}
|
||||
>
|
||||
{s}×
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-3 text-muted"
|
||||
data-testid="compression-cockpit-empty"
|
||||
>
|
||||
<span className="text-3xl opacity-40">⌁</span>
|
||||
<p className="text-sm">No compression run available.</p>
|
||||
<p className="text-xs opacity-60">Live data arrives via the WS compression channel.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompressionCockpitProps {
|
||||
/**
|
||||
* The run to render. If omitted, an empty state is shown — the parent owns the
|
||||
* live data (via `useLiveCompression`) and passes its latest run here.
|
||||
*/
|
||||
run?: CompressionRunModel | null;
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* CompressionCockpit — A3 cockpit view (Compression Studio, Tela A).
|
||||
*
|
||||
* Renders the compression pipeline as a ReactFlow canvas (A2) with:
|
||||
* - A header showing run metadata (mode, comboId, total savings).
|
||||
* - Replay controls (play/pause/reset + speed selector).
|
||||
* - Graceful empty state when no run is available.
|
||||
*
|
||||
* Controlled component: it renders the `run` prop when supplied and the empty
|
||||
* state otherwise. The parent owns the live data — it subscribes via the
|
||||
* `useLiveCompression` hook and passes `lastRun` as `run` — which keeps this
|
||||
* component WS-free and unit-testable with a static run.
|
||||
*/
|
||||
export function CompressionCockpit({ run: runProp }: CompressionCockpitProps) {
|
||||
// Replay drives frame-by-frame animation (cosmetic — sub-ms compression is sync)
|
||||
const { currentFrame, isPlaying, isComplete, speed, setSpeed, play, pause, reset } =
|
||||
useCompressionReplay(runProp ?? null);
|
||||
|
||||
// Displayed run: replay frame while playing, else the full run
|
||||
const displayRun = currentFrame ?? runProp ?? null;
|
||||
|
||||
// Build nodes/edges from the model
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
if (!displayRun) return { nodes: [], edges: [] };
|
||||
|
||||
// compressionRunToFlow uses type "input"/"output" for IoNode — remap to "io"
|
||||
const raw = compressionRunToFlow(displayRun);
|
||||
const remappedNodes = raw.nodes.map((n) => {
|
||||
if (n.type === "input") {
|
||||
return { ...n, type: "io", data: { ...n.data, variant: "input" } };
|
||||
}
|
||||
if (n.type === "output") {
|
||||
return {
|
||||
...n,
|
||||
type: "io",
|
||||
data: { ...n.data, variant: "output", savingsPercent: displayRun.savingsPercent },
|
||||
};
|
||||
}
|
||||
return n;
|
||||
});
|
||||
return { nodes: remappedNodes, edges: raw.edges };
|
||||
}, [displayRun]);
|
||||
|
||||
const fitKey = displayRun ? `${displayRun.requestId}-${nodes.length}` : "empty";
|
||||
|
||||
if (!runProp) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
const run = runProp;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-2" data-testid="compression-cockpit">
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg border border-border bg-bg/60 shrink-0">
|
||||
<span className="text-xs font-semibold text-muted uppercase tracking-wide">{run.mode}</span>
|
||||
{run.comboId && (
|
||||
<span className="text-xs text-muted px-1.5 py-0.5 rounded bg-border/40 font-mono">
|
||||
{run.comboId}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted font-mono opacity-70 truncate">{run.requestId}</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted">
|
||||
{fmt(run.originalTokens)} → {fmt(run.compressedTokens)} tok
|
||||
</span>
|
||||
<span className="text-xs font-bold" style={{ color: "#22c55e" }}>
|
||||
−{run.savingsPercent.toFixed(1)}%
|
||||
</span>
|
||||
{isComplete && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded border border-border text-muted">
|
||||
replay done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Canvas ─────────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-h-0 rounded-lg overflow-hidden border border-border">
|
||||
<FlowCanvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
fitKey={fitKey}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Replay controls ─────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-border bg-bg/60 shrink-0">
|
||||
<button
|
||||
onClick={isPlaying ? pause : play}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded border border-border text-xs hover:bg-border/30 transition-colors"
|
||||
aria-label={isPlaying ? "Pause replay" : "Play replay"}
|
||||
>
|
||||
{isPlaying ? "⏸ Pause" : "▷ Replay"}
|
||||
</button>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="px-2 py-1 rounded border border-border text-xs text-muted hover:bg-border/30 transition-colors"
|
||||
aria-label="Reset replay"
|
||||
>
|
||||
⟳
|
||||
</button>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{SPEEDS.map((s) => (
|
||||
<SpeedButton key={s} s={s} active={speed === s} onClick={() => setSpeed(s)} />
|
||||
))}
|
||||
</div>
|
||||
{displayRun && currentFrame && (
|
||||
<span className="ml-auto text-[11px] text-muted">
|
||||
step {displayRun?.steps.length ?? 0}/{run.steps.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompressionCockpit;
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import type { CompressionRunModel, CompressionEngineStep } from "./compressionFlowModel";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function savingsColor(pct: number): string {
|
||||
if (pct >= 30) return "#22c55e";
|
||||
if (pct >= 15) return "#f59e0b";
|
||||
return "#6b7280";
|
||||
}
|
||||
|
||||
function pctWidth(tokIn: number, tokOut: number): string {
|
||||
if (tokIn === 0) return "100%";
|
||||
return `${((tokOut / tokIn) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
// ── Row ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function StepRow({ step, maxTokens }: { step: CompressionEngineStep; maxTokens: number }) {
|
||||
const skipped = step.originalTokens === step.compressedTokens;
|
||||
const color = skipped ? "#6b7280" : savingsColor(step.savingsPercent);
|
||||
const barWidthIn = maxTokens > 0 ? (step.originalTokens / maxTokens) * 100 : 100;
|
||||
const barWidthOut = maxTokens > 0 ? (step.compressedTokens / maxTokens) * 100 : 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3 py-2 border-b border-border/50 last:border-0"
|
||||
data-testid="waterfall-step-row"
|
||||
>
|
||||
{/* Engine label */}
|
||||
<div className="w-28 shrink-0">
|
||||
<span className="text-xs font-semibold" style={{ color: skipped ? "#6b7280" : color }}>
|
||||
{step.engine}
|
||||
</span>
|
||||
{skipped && (
|
||||
<span className="ml-1.5 text-[10px] text-muted bg-muted/10 px-1 rounded">skip</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Savings bar column */}
|
||||
<div className="flex-1 flex flex-col gap-1 min-w-0">
|
||||
{/* in bar */}
|
||||
<div className="relative h-2 rounded-full bg-border/30 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-muted/40"
|
||||
style={{ width: `${barWidthIn.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* out bar */}
|
||||
{!skipped && (
|
||||
<div className="relative h-2 rounded-full bg-border/30 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{ width: `${barWidthOut.toFixed(1)}%`, backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token counts */}
|
||||
<div className="w-36 shrink-0 text-right">
|
||||
<div className="text-[10px] text-muted">
|
||||
{fmt(step.originalTokens)} → {fmt(step.compressedTokens)}
|
||||
</div>
|
||||
{!skipped && (
|
||||
<div
|
||||
className="text-[11px] font-bold"
|
||||
style={{ color }}
|
||||
data-testid="waterfall-savings-text"
|
||||
>
|
||||
{`-${step.savingsPercent.toFixed(1)}%`}
|
||||
</div>
|
||||
)}
|
||||
{step.durationMs != null && (
|
||||
<div className="text-[9px] text-muted">{step.durationMs.toFixed(1)}ms</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WaterfallInspectorProps {
|
||||
run: CompressionRunModel;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* WaterfallInspector — A1 waterfall list view for a `CompressionRunModel`.
|
||||
*
|
||||
* One row per engine step showing a horizontal savings bar (Langfuse-style):
|
||||
* - Top bar: tokens in (relative width).
|
||||
* - Bottom bar: tokens out (colored by savings %).
|
||||
* Reads the same `CompressionRunModel` as the Canvas / Cockpit.
|
||||
* Plain divs — no ReactFlow.
|
||||
*/
|
||||
export function WaterfallInspector({ run, className = "" }: WaterfallInspectorProps) {
|
||||
const maxTokens = run.originalTokens;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${className}`} data-testid="waterfall-inspector">
|
||||
{/* Header — INPUT */}
|
||||
<div className="flex items-center gap-3 py-2 border-b border-border font-semibold text-xs text-muted">
|
||||
<span className="w-28 shrink-0 text-primary">⌁ INPUT</span>
|
||||
<div className="flex-1">
|
||||
<div className="h-2 rounded-full bg-muted/30 overflow-hidden">
|
||||
<div className="h-full w-full rounded-full bg-muted/50" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-36 shrink-0 text-right">{fmt(run.originalTokens)} tokens</span>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
{run.steps.map((step, i) => (
|
||||
<StepRow key={`${step.engine}-${i}`} step={step} maxTokens={maxTokens} />
|
||||
))}
|
||||
|
||||
{/* Footer — OUTPUT */}
|
||||
<div className="flex items-center gap-3 py-2 border-t border-border mt-1">
|
||||
<span className="w-28 shrink-0 text-xs font-semibold text-green-500">✦ OUTPUT</span>
|
||||
<div className="flex-1">
|
||||
<div className="h-2 rounded-full bg-border/30 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-green-500"
|
||||
style={{ width: pctWidth(run.originalTokens, run.compressedTokens) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-36 shrink-0 text-right">
|
||||
<div className="text-xs font-semibold text-green-500">
|
||||
{fmt(run.compressedTokens)} tokens
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px] font-bold"
|
||||
style={{ color: "#22c55e" }}
|
||||
data-testid="waterfall-total-savings"
|
||||
>
|
||||
{`-${run.savingsPercent.toFixed(1)}%`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary bar */}
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-border/10 mt-2 text-[11px] text-muted">
|
||||
<span>
|
||||
{fmt(run.originalTokens)} → {fmt(run.compressedTokens)} tok
|
||||
</span>
|
||||
<span className="text-green-500 font-bold">−{run.savingsPercent.toFixed(1)}%</span>
|
||||
{run.comboId && <span className="font-mono opacity-70">{run.comboId}</span>}
|
||||
<span className="ml-auto opacity-60">{run.mode}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WaterfallInspector;
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* compressionFlowModel — pure reducer and replay helper for Compression Studio (Tela A).
|
||||
*
|
||||
* Pure functions: no React, no side effects.
|
||||
* Converts a `compression.completed` WS payload into:
|
||||
* 1. A `CompressionRunModel` (typed, serialisable model for UI).
|
||||
* 2. A ReactFlow `{ nodes, edges }` graph (left→right pipeline view).
|
||||
* 3. Progressive replay frames for animated step-by-step cascade.
|
||||
*/
|
||||
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import type { CompressionCompletedPayload } from "@/lib/events/types";
|
||||
|
||||
// ── Engine Step ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompressionEngineStep {
|
||||
engine: string;
|
||||
originalTokens: number;
|
||||
compressedTokens: number;
|
||||
savingsPercent: number;
|
||||
techniquesUsed: string[];
|
||||
rulesApplied?: string[];
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
// ── Run Model ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompressionRunModel {
|
||||
requestId: string;
|
||||
comboId: string | null;
|
||||
mode: string;
|
||||
originalTokens: number;
|
||||
compressedTokens: number;
|
||||
savingsPercent: number;
|
||||
steps: CompressionEngineStep[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ── compressionEventToModel ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a `CompressionRunModel` from a `compression.completed` WS payload.
|
||||
*/
|
||||
export function compressionEventToModel(payload: CompressionCompletedPayload): CompressionRunModel {
|
||||
const steps: CompressionEngineStep[] = payload.engineBreakdown.map((entry) => ({
|
||||
engine: entry.engine,
|
||||
originalTokens: entry.originalTokens,
|
||||
compressedTokens: entry.compressedTokens,
|
||||
savingsPercent: entry.savingsPercent,
|
||||
techniquesUsed: entry.techniquesUsed,
|
||||
rulesApplied: entry.rulesApplied,
|
||||
durationMs: entry.durationMs,
|
||||
}));
|
||||
|
||||
return {
|
||||
requestId: payload.requestId,
|
||||
comboId: payload.comboId,
|
||||
mode: payload.mode,
|
||||
originalTokens: payload.originalTokens,
|
||||
compressedTokens: payload.compressedTokens,
|
||||
savingsPercent: payload.savingsPercent,
|
||||
steps,
|
||||
timestamp: payload.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// ── compressionRunToFlow ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Produce a left→right ReactFlow graph from a `CompressionRunModel`.
|
||||
*
|
||||
* Layout: Input → [EngineStep × N] → Output
|
||||
* Nodes: N + 2 total (input + N engine + output)
|
||||
* Edges: N + 1 sequential connections
|
||||
*/
|
||||
export function compressionRunToFlow(model: CompressionRunModel): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
const X_STEP = 200;
|
||||
|
||||
// Input node
|
||||
const inputId = "input";
|
||||
nodes.push({
|
||||
id: inputId,
|
||||
type: "input",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: `Input\n${model.originalTokens} tokens`,
|
||||
tokens: model.originalTokens,
|
||||
},
|
||||
});
|
||||
|
||||
// Engine step nodes
|
||||
let prevId = inputId;
|
||||
for (let i = 0; i < model.steps.length; i++) {
|
||||
const step = model.steps[i];
|
||||
const nodeId = `engine-${i}`;
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: "engine",
|
||||
position: { x: (i + 1) * X_STEP, y: 0 },
|
||||
data: {
|
||||
engine: step.engine,
|
||||
originalTokens: step.originalTokens,
|
||||
compressedTokens: step.compressedTokens,
|
||||
savingsPercent: step.savingsPercent,
|
||||
techniquesUsed: step.techniquesUsed,
|
||||
rulesApplied: step.rulesApplied,
|
||||
durationMs: step.durationMs,
|
||||
label: step.engine,
|
||||
},
|
||||
});
|
||||
edges.push({
|
||||
id: `e-${prevId}-${nodeId}`,
|
||||
source: prevId,
|
||||
target: nodeId,
|
||||
});
|
||||
prevId = nodeId;
|
||||
}
|
||||
|
||||
// Output node
|
||||
const outputId = "output";
|
||||
nodes.push({
|
||||
id: outputId,
|
||||
type: "output",
|
||||
position: { x: (model.steps.length + 1) * X_STEP, y: 0 },
|
||||
data: {
|
||||
label: `Output\n${model.compressedTokens} tokens`,
|
||||
tokens: model.compressedTokens,
|
||||
savingsPercent: model.savingsPercent,
|
||||
},
|
||||
});
|
||||
edges.push({
|
||||
id: `e-${prevId}-${outputId}`,
|
||||
source: prevId,
|
||||
target: outputId,
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ── buildReplayFrames ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build progressive replay frames from a `CompressionRunModel`.
|
||||
*
|
||||
* Returns an array of N frames (where N = model.steps.length).
|
||||
* Frame[i] contains steps[0..i] — i.e., the state after applying i+1 engines.
|
||||
* Each frame is a self-contained `CompressionRunModel` snapshot.
|
||||
*
|
||||
* Intended for animated step-by-step replay in the UI: since compression is
|
||||
* sub-ms synchronous, "real-time" in the studio means replaying from a single
|
||||
* `compression.completed` event.
|
||||
*/
|
||||
export function buildReplayFrames(model: CompressionRunModel): CompressionRunModel[] {
|
||||
if (model.steps.length === 0) return [];
|
||||
|
||||
return model.steps.map((_, i) => {
|
||||
const slicedSteps = model.steps.slice(0, i + 1);
|
||||
const lastStep = slicedSteps[slicedSteps.length - 1];
|
||||
// compressedTokens at this frame = output of the last applied engine
|
||||
const compressedTokens = lastStep.compressedTokens;
|
||||
const savingsPercent =
|
||||
model.originalTokens > 0
|
||||
? ((model.originalTokens - compressedTokens) / model.originalTokens) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
requestId: model.requestId,
|
||||
comboId: model.comboId,
|
||||
mode: model.mode,
|
||||
originalTokens: model.originalTokens,
|
||||
compressedTokens,
|
||||
savingsPercent,
|
||||
steps: [...slicedSteps],
|
||||
timestamp: model.timestamp,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { StatusDot } from "@/shared/components/flow/StatusDot";
|
||||
|
||||
// ── Layer pill color map (10-layer model) ─────────────────────────────────
|
||||
|
||||
const LAYER_COLORS: Record<string, string> = {
|
||||
L1: "#3b82f6", // prose — blue
|
||||
L3: "#f97316", // tool — orange
|
||||
L4: "#22c55e", // code — green
|
||||
L5: "#a855f7", // MCP — purple
|
||||
L6: "#9ca3af", // history — gray
|
||||
L9: "#ec4899", // pruning — pink
|
||||
};
|
||||
|
||||
/** Map engine name → layer tags for the pill display */
|
||||
const ENGINE_LAYER_MAP: Record<string, string[]> = {
|
||||
rtk: ["L3", "L4"],
|
||||
caveman: ["L1"],
|
||||
headroom: ["L3"],
|
||||
llmlingua: ["L9"],
|
||||
lite: ["L1"],
|
||||
sigmap: ["L1", "L3"],
|
||||
gcf: ["L4"],
|
||||
cocoindex: ["L5", "L6"],
|
||||
"caveman:full": ["L1"],
|
||||
"rtk:standard": ["L3", "L4"],
|
||||
};
|
||||
|
||||
function getSavingsColor(savingsPercent: number): string {
|
||||
if (savingsPercent >= 30) return "#22c55e";
|
||||
if (savingsPercent >= 15) return "#f59e0b";
|
||||
return "#6b7280";
|
||||
}
|
||||
|
||||
// ── Node data shape ───────────────────────────────────────────────────────
|
||||
|
||||
export interface EngineNodeData {
|
||||
engine: string;
|
||||
originalTokens: number;
|
||||
compressedTokens: number;
|
||||
savingsPercent: number;
|
||||
techniquesUsed: string[];
|
||||
rulesApplied?: string[];
|
||||
durationMs?: number;
|
||||
/** visual replay state */
|
||||
stepState?: "pending" | "running" | "done" | "skipped";
|
||||
label?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function EngineNode({ data }: NodeProps) {
|
||||
const {
|
||||
engine,
|
||||
originalTokens,
|
||||
compressedTokens,
|
||||
savingsPercent,
|
||||
techniquesUsed,
|
||||
durationMs,
|
||||
stepState = "done",
|
||||
} = data as EngineNodeData;
|
||||
|
||||
const skipped = stepState === "skipped" || originalTokens === compressedTokens;
|
||||
const running = stepState === "running";
|
||||
const color = getSavingsColor(savingsPercent as number);
|
||||
const layers = ENGINE_LAYER_MAP[engine as string] ?? [];
|
||||
const savings = savingsPercent as number;
|
||||
const tokIn = originalTokens as number;
|
||||
const tokOut = compressedTokens as number;
|
||||
const techniques = (techniquesUsed as string[]).slice(0, 2);
|
||||
|
||||
const borderColor = skipped ? "var(--color-border)" : running ? "#f59e0b" : color;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border-2 bg-bg transition-all duration-300 min-w-[150px] max-w-[180px]"
|
||||
style={{
|
||||
borderColor,
|
||||
boxShadow: running ? `0 0 14px #f59e0b40` : skipped ? "none" : `0 0 10px ${color}30`,
|
||||
}}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!bg-transparent !border-0 !w-0 !h-0"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!bg-transparent !border-0 !w-0 !h-0"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2.5 pt-2 pb-1"
|
||||
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||
>
|
||||
{running && <StatusDot color="#f59e0b" sizeClass="size-1.5" />}
|
||||
<span className="text-xs font-semibold truncate flex-1" title={engine as string}>
|
||||
{engine as string}
|
||||
</span>
|
||||
{skipped && <span className="text-[10px] text-muted px-1 rounded bg-muted/10">skip</span>}
|
||||
</div>
|
||||
|
||||
{/* Body — token bar */}
|
||||
{!skipped && (
|
||||
<div className="px-2.5 py-1.5">
|
||||
<div className="flex justify-between text-[10px] text-muted mb-0.5">
|
||||
<span>{tokIn.toLocaleString()}</span>
|
||||
<span>→</span>
|
||||
<span>{tokOut.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="relative h-1.5 rounded-full overflow-hidden bg-border">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{
|
||||
width: tokIn > 0 ? `${(tokOut / tokIn) * 100}%` : "100%",
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-2.5 pb-2 pt-1 flex flex-col gap-0.5">
|
||||
{!skipped && (
|
||||
<span className="text-[11px] font-bold" style={{ color }} data-testid="savings-percent">
|
||||
{`-${savings.toFixed(1)}%`}
|
||||
</span>
|
||||
)}
|
||||
{techniques.length > 0 && (
|
||||
<span
|
||||
className="text-[9px] text-muted truncate"
|
||||
title={(techniquesUsed as string[]).join(", ")}
|
||||
>
|
||||
{techniques.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
{durationMs != null && (
|
||||
<span className="text-[9px] text-muted">{(durationMs as number).toFixed(1)}ms</span>
|
||||
)}
|
||||
{/* Layer pills */}
|
||||
{layers.length > 0 && (
|
||||
<div className="flex gap-0.5 flex-wrap mt-0.5">
|
||||
{layers.map((layer) => (
|
||||
<span
|
||||
key={layer}
|
||||
className="text-[9px] px-1 rounded-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: `${LAYER_COLORS[layer] ?? "#6b7280"}22`,
|
||||
color: LAYER_COLORS[layer] ?? "#6b7280",
|
||||
}}
|
||||
>
|
||||
{layer}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EngineNode;
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
|
||||
// ── Node data shapes ──────────────────────────────────────────────────────
|
||||
|
||||
export interface IoNodeData {
|
||||
variant: "input" | "output";
|
||||
tokens: number;
|
||||
savingsPercent?: number;
|
||||
label?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function IoNode({ data }: NodeProps) {
|
||||
const { variant, tokens, savingsPercent } = data as IoNodeData;
|
||||
|
||||
const isInput = variant === "input";
|
||||
const color = isInput ? "#6366f1" : "#22c55e"; // indigo for input, green for output
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border-2 bg-bg px-4 py-3 min-w-[120px] text-center transition-all duration-200"
|
||||
style={{
|
||||
borderColor: color,
|
||||
boxShadow: `0 0 12px ${color}25`,
|
||||
}}
|
||||
>
|
||||
{/* Input only uses source handle; Output only uses target handle */}
|
||||
{isInput ? (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!bg-transparent !border-0 !w-0 !h-0"
|
||||
/>
|
||||
) : (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!bg-transparent !border-0 !w-0 !h-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider mb-1" style={{ color }}>
|
||||
{isInput ? "Input" : "Output"}
|
||||
</div>
|
||||
|
||||
<div className="text-base font-bold" style={{ color }}>
|
||||
{(tokens as number).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted">tokens</div>
|
||||
|
||||
{!isInput && savingsPercent != null && (
|
||||
<div
|
||||
className="mt-1 text-[11px] font-semibold"
|
||||
style={{ color: "#22c55e" }}
|
||||
data-testid="io-savings-percent"
|
||||
>
|
||||
{`-${(savingsPercent as number).toFixed(1)}%`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IoNode;
|
||||
22
src/app/(dashboard)/dashboard/compression/studio/page.tsx
Normal file
22
src/app/(dashboard)/dashboard/compression/studio/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { CompressionCockpit } from "./CompressionCockpit";
|
||||
import { useLiveCompression } from "@/hooks/useLiveCompression";
|
||||
|
||||
/**
|
||||
* Compression Studio (Tela A) — live per-engine compression cascade.
|
||||
*
|
||||
* Thin route wrapper: subscribes to the `compression` WS channel via
|
||||
* `useLiveCompression` and renders the latest run in the cockpit. The cockpit is
|
||||
* a controlled component (renders an empty state until a run arrives), so this
|
||||
* page degrades gracefully when the live WS feed is off.
|
||||
*/
|
||||
export default function CompressionStudioPage() {
|
||||
const { lastRun } = useLiveCompression();
|
||||
|
||||
return (
|
||||
<div className="p-4 h-[calc(100dvh-6rem)] min-h-[480px]">
|
||||
<CompressionCockpit run={lastRun} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useEffect, useRef, useCallback } from "react";
|
||||
import { buildReplayFrames, type CompressionRunModel } from "./compressionFlowModel";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ReplaySpeed = 0.3 | 1 | 3;
|
||||
|
||||
export interface UseCompressionReplayReturn {
|
||||
/** The current in-progress frame (null when idle or complete). */
|
||||
currentFrame: CompressionRunModel | null;
|
||||
/** Index of the currently displayed frame (0-based). */
|
||||
frameIndex: number;
|
||||
/** Total number of frames available. */
|
||||
totalFrames: number;
|
||||
isPlaying: boolean;
|
||||
isComplete: boolean;
|
||||
speed: ReplaySpeed;
|
||||
setSpeed: (s: ReplaySpeed) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ── Timing ────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE_STEP_MS = 400;
|
||||
|
||||
function stepMs(speed: ReplaySpeed): number {
|
||||
return Math.round(BASE_STEP_MS / speed);
|
||||
}
|
||||
|
||||
// ── Reducer ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface ReplayState {
|
||||
frameIndex: number; // -1 = not started
|
||||
isPlaying: boolean;
|
||||
speed: ReplaySpeed;
|
||||
}
|
||||
|
||||
type ReplayAction =
|
||||
| { type: "RESET" }
|
||||
| { type: "PLAY"; frameIndex?: number }
|
||||
| { type: "PAUSE" }
|
||||
| { type: "TICK"; totalFrames: number }
|
||||
| { type: "SET_SPEED"; speed: ReplaySpeed };
|
||||
|
||||
const INITIAL_STATE: ReplayState = { frameIndex: -1, isPlaying: false, speed: 1 };
|
||||
|
||||
function replayReducer(state: ReplayState, action: ReplayAction): ReplayState {
|
||||
switch (action.type) {
|
||||
case "RESET":
|
||||
return { ...state, frameIndex: -1, isPlaying: false };
|
||||
case "PLAY":
|
||||
return {
|
||||
...state,
|
||||
isPlaying: true,
|
||||
frameIndex: action.frameIndex !== undefined ? action.frameIndex : state.frameIndex,
|
||||
};
|
||||
case "PAUSE":
|
||||
return { ...state, isPlaying: false };
|
||||
case "TICK": {
|
||||
const next = state.frameIndex + 1;
|
||||
if (next >= action.totalFrames - 1) {
|
||||
return { ...state, frameIndex: action.totalFrames - 1, isPlaying: false };
|
||||
}
|
||||
return { ...state, frameIndex: next };
|
||||
}
|
||||
case "SET_SPEED":
|
||||
return { ...state, speed: action.speed };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drives frame-by-frame replay of a `CompressionRunModel`.
|
||||
*
|
||||
* Since compression is sub-ms synchronous, this hook artificially paces
|
||||
* the reveal using a `setInterval` (cleared on unmount or model change).
|
||||
* All state lives in a single `useReducer` to avoid cascading setState calls
|
||||
* inside effects (satisfies react-hooks/set-state-in-effect).
|
||||
*/
|
||||
export function useCompressionReplay(
|
||||
model: CompressionRunModel | null
|
||||
): UseCompressionReplayReturn {
|
||||
const frames = model ? buildReplayFrames(model) : [];
|
||||
const totalFrames = frames.length;
|
||||
|
||||
const [state, dispatch] = useReducer(replayReducer, INITIAL_STATE);
|
||||
const { frameIndex, isPlaying, speed } = state;
|
||||
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Stable ref to totalFrames so the interval callback never closes over a stale value
|
||||
const totalFramesRef = useRef(totalFrames);
|
||||
const speedRef = useRef<ReplaySpeed>(speed);
|
||||
|
||||
// Sync refs in event-handler-safe effects
|
||||
useEffect(() => {
|
||||
totalFramesRef.current = totalFrames;
|
||||
}, [totalFrames]);
|
||||
|
||||
useEffect(() => {
|
||||
speedRef.current = speed;
|
||||
}, [speed]);
|
||||
|
||||
// Clear interval helper (stable — no deps)
|
||||
const clearTick = useCallback(() => {
|
||||
if (intervalRef.current != null) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Start the interval ticker
|
||||
const startTick = useCallback(() => {
|
||||
clearTick();
|
||||
intervalRef.current = setInterval(() => {
|
||||
// dispatch is stable — safe to call inside an interval callback
|
||||
dispatch({ type: "TICK", totalFrames: totalFramesRef.current });
|
||||
}, stepMs(speedRef.current));
|
||||
}, [clearTick]);
|
||||
|
||||
// Stop ticker when isPlaying turns false (either from PAUSE, RESET, or TICK→done)
|
||||
useEffect(() => {
|
||||
if (!isPlaying) clearTick();
|
||||
}, [isPlaying, clearTick]);
|
||||
|
||||
// Reset when model identity changes
|
||||
const modelRef = useRef(model);
|
||||
useEffect(() => {
|
||||
if (modelRef.current !== model) {
|
||||
modelRef.current = model;
|
||||
clearTick();
|
||||
dispatch({ type: "RESET" });
|
||||
}
|
||||
}, [model, clearTick]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => clearTick();
|
||||
}, [clearTick]);
|
||||
|
||||
// Public controls — called from event handlers, so setState inside is fine
|
||||
const play = useCallback(() => {
|
||||
if (totalFramesRef.current === 0) return;
|
||||
const restartIndex = frameIndex >= totalFramesRef.current - 1 ? 0 : undefined;
|
||||
dispatch({ type: "PLAY", ...(restartIndex !== undefined ? { frameIndex: restartIndex } : {}) });
|
||||
startTick();
|
||||
}, [frameIndex, startTick]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
dispatch({ type: "PAUSE" });
|
||||
clearTick();
|
||||
}, [clearTick]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
dispatch({ type: "RESET" });
|
||||
clearTick();
|
||||
}, [clearTick]);
|
||||
|
||||
const handleSetSpeed = useCallback(
|
||||
(s: ReplaySpeed) => {
|
||||
dispatch({ type: "SET_SPEED", speed: s });
|
||||
if (isPlaying) startTick();
|
||||
},
|
||||
[isPlaying, startTick]
|
||||
);
|
||||
|
||||
const currentFrame = frameIndex >= 0 && frameIndex < frames.length ? frames[frameIndex] : null;
|
||||
|
||||
const isComplete = totalFrames > 0 && frameIndex >= totalFrames - 1 && !isPlaying;
|
||||
|
||||
return {
|
||||
currentFrame,
|
||||
frameIndex,
|
||||
totalFrames,
|
||||
isPlaying,
|
||||
isComplete,
|
||||
speed,
|
||||
setSpeed: handleSetSpeed,
|
||||
play,
|
||||
pause,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useMemo, useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ReactFlow,
|
||||
Handle,
|
||||
Position,
|
||||
Controls,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { Handle, Position, type Node, type Edge, type NodeTypes } from "@xyflow/react";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { FlowCanvas } from "@/shared/components/flow/FlowCanvas";
|
||||
import { StatusDot } from "@/shared/components/flow/StatusDot";
|
||||
import { edgeStyle } from "@/shared/components/flow/edgeStyles";
|
||||
import { resolveTopologyNodeLabel } from "./topologyLabel";
|
||||
|
||||
const FE_ACTIVE_TIMEOUT_MS = 60_000;
|
||||
@@ -99,18 +93,7 @@ function ProviderNode({ data }: { data: ProviderNodeData }) {
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{(active || error) && (
|
||||
<span className="relative flex size-1.5 shrink-0">
|
||||
<span
|
||||
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-70"
|
||||
style={{ backgroundColor: error ? "#ef4444" : color }}
|
||||
/>
|
||||
<span
|
||||
className="relative inline-flex rounded-full size-1.5"
|
||||
style={{ backgroundColor: error ? "#ef4444" : color }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{(active || error) && <StatusDot color={color} error={error} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -165,13 +148,6 @@ const nodeTypes: NodeTypes = {
|
||||
|
||||
type ProviderEntry = { id?: string; provider: string; name?: string };
|
||||
|
||||
function edgeStyle(active: boolean, last: boolean, error: boolean) {
|
||||
if (error) return { stroke: "#ef4444", strokeWidth: 2, opacity: 0.85 };
|
||||
if (active) return { stroke: "#22c55e", strokeWidth: 2.5, opacity: 1 };
|
||||
if (last) return { stroke: "#f59e0b", strokeWidth: 1.5, opacity: 0.6 };
|
||||
return { stroke: "var(--color-border)", strokeWidth: 1, opacity: 0.2 };
|
||||
}
|
||||
|
||||
function getHandles(angle: number, cx: number): { sourceHandle: string; targetHandle: string } {
|
||||
const rel = (((angle + Math.PI / 2) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
||||
if (rel < Math.PI / 4 || rel > (7 * Math.PI) / 4)
|
||||
@@ -348,64 +324,27 @@ export default function ProviderTopology({
|
||||
[providers]
|
||||
);
|
||||
|
||||
const rfInstance = useRef<any>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const fitOpts = { padding: 0.22, duration: 250 };
|
||||
const containerClass =
|
||||
"h-[300px] w-full min-w-0 rounded-xl border border-border bg-bg-subtle/20 overflow-hidden sm:h-[420px]";
|
||||
|
||||
const onInit = useCallback((instance: any) => {
|
||||
rfInstance.current = instance;
|
||||
setTimeout(() => instance.fitView(fitOpts), 60);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
rfInstance.current?.fitView(fitOpts);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => rfInstance.current?.fitView(fitOpts), 60);
|
||||
return () => clearTimeout(id);
|
||||
}, [nodes.length]);
|
||||
if (providers.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={`${containerClass} flex flex-col items-center justify-center gap-2 text-text-muted`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[32px]">device_hub</span>
|
||||
<p className="text-sm">{t("providerTopologyEmpty")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-[300px] w-full min-w-0 rounded-xl border border-border bg-bg-subtle/20 overflow-hidden sm:h-[420px]"
|
||||
>
|
||||
{providers.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[32px]">device_hub</span>
|
||||
<p className="text-sm">{t("providerTopologyEmpty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ReactFlow
|
||||
key={providersKey}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={fitOpts}
|
||||
minZoom={0.08}
|
||||
maxZoom={2}
|
||||
onInit={onInit}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
zoomOnPinch
|
||||
zoomOnDoubleClick
|
||||
preventScrolling={false}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
>
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
)}
|
||||
</div>
|
||||
<FlowCanvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
fitKey={providersKey}
|
||||
className={containerClass}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
82
src/hooks/useLiveCompression.ts
Normal file
82
src/hooks/useLiveCompression.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
useLiveDashboard,
|
||||
type UseLiveDashboardOptions,
|
||||
type WsEventPayload,
|
||||
} from "./useLiveDashboard";
|
||||
import {
|
||||
compressionEventToModel,
|
||||
type CompressionRunModel,
|
||||
} from "@/app/(dashboard)/dashboard/compression/studio/compressionFlowModel";
|
||||
import type { CompressionCompletedPayload } from "@/lib/events/types";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_RUNS = 100;
|
||||
|
||||
// ── Accumulator (pure reducer — exported for unit tests) ─────────────────
|
||||
|
||||
/**
|
||||
* Pure accumulator: prepend the new run and cap at `maxRuns`.
|
||||
* Most-recent-first order.
|
||||
*/
|
||||
export function accumulateRun(
|
||||
prev: CompressionRunModel[],
|
||||
payload: CompressionCompletedPayload,
|
||||
maxRuns = MAX_RUNS
|
||||
): CompressionRunModel[] {
|
||||
const model = compressionEventToModel(payload);
|
||||
return [model, ...prev].slice(0, maxRuns);
|
||||
}
|
||||
|
||||
// ── useLiveCompression hook ───────────────────────────────────────────────
|
||||
|
||||
export interface UseLiveCompressionReturn {
|
||||
/** All accumulated runs, most-recent-first. */
|
||||
runs: CompressionRunModel[];
|
||||
/** The most recently received run, or null. */
|
||||
lastRun: CompressionRunModel | null;
|
||||
/** Quick lookup by requestId. */
|
||||
getRunById: (requestId: string) => CompressionRunModel | undefined;
|
||||
isConnected: boolean;
|
||||
reconnect: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the `compression` WS channel and accumulates
|
||||
* `CompressionRunModel[]` (most-recent-first, capped at 100).
|
||||
*
|
||||
* Mirrors the pattern of `useLiveComboStatus` and `useLiveRequests`.
|
||||
*/
|
||||
export function useLiveCompression(options?: UseLiveDashboardOptions): UseLiveCompressionReturn {
|
||||
const [runs, setRuns] = useState<CompressionRunModel[]>([]);
|
||||
|
||||
const handleEvent = useCallback((event: WsEventPayload) => {
|
||||
if (event.channel !== "compression") return;
|
||||
if (event.event !== "compression.completed") return;
|
||||
|
||||
const payload = event.data as CompressionCompletedPayload;
|
||||
setRuns((prev) => accumulateRun(prev, payload));
|
||||
}, []);
|
||||
|
||||
const { connection, reconnect } = useLiveDashboard({
|
||||
channels: ["compression"],
|
||||
onEvent: handleEvent,
|
||||
...options,
|
||||
});
|
||||
|
||||
const getRunById = useCallback(
|
||||
(requestId: string) => runs.find((r) => r.requestId === requestId),
|
||||
[runs]
|
||||
);
|
||||
|
||||
return {
|
||||
runs,
|
||||
lastRun: runs[0] ?? null,
|
||||
getRunById,
|
||||
isConnected: connection.isConnected,
|
||||
reconnect,
|
||||
};
|
||||
}
|
||||
@@ -349,6 +349,8 @@ export interface LiveComboEvent {
|
||||
provider: string;
|
||||
model: string;
|
||||
type: "attempt" | "succeeded" | "failed";
|
||||
/** Routing strategy, carried on the attempt payload (used by the Combo Studio). */
|
||||
strategy?: string;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
@@ -374,6 +376,7 @@ export function useLiveComboStatus(options?: UseLiveDashboardOptions) {
|
||||
provider: data.provider,
|
||||
model: data.model,
|
||||
type: "attempt",
|
||||
strategy: data.strategy,
|
||||
timestamp: event.timestamp,
|
||||
};
|
||||
} else if (event.event === "combo.target.succeeded") {
|
||||
|
||||
@@ -15,7 +15,8 @@ export type DashboardEventName =
|
||||
| "combo.target.attempt"
|
||||
| "combo.target.failed"
|
||||
| "combo.target.succeeded"
|
||||
| "credential.health.changed";
|
||||
| "credential.health.changed"
|
||||
| "compression.completed";
|
||||
|
||||
// ── Event Payloads ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -88,6 +89,27 @@ export interface CredentialHealthChangedPayload {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface CompressionCompletedPayload {
|
||||
requestId: string;
|
||||
comboId: string | null;
|
||||
mode: string;
|
||||
originalTokens: number;
|
||||
compressedTokens: number;
|
||||
savingsPercent: number;
|
||||
engineBreakdown: Array<{
|
||||
engine: string;
|
||||
originalTokens: number;
|
||||
compressedTokens: number;
|
||||
savingsPercent: number;
|
||||
techniquesUsed: string[];
|
||||
rulesApplied?: string[];
|
||||
durationMs?: number;
|
||||
}>;
|
||||
validationWarnings?: string[];
|
||||
fallbackApplied?: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ── Event Map ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardEventMap {
|
||||
@@ -99,6 +121,7 @@ export interface DashboardEventMap {
|
||||
"combo.target.failed": ComboTargetFailedPayload;
|
||||
"combo.target.succeeded": ComboTargetSucceededPayload;
|
||||
"credential.health.changed": CredentialHealthChangedPayload;
|
||||
"compression.completed": CompressionCompletedPayload;
|
||||
}
|
||||
|
||||
// ── Event Bus Listener ────────────────────────────────────────────────────
|
||||
@@ -110,13 +133,14 @@ export type DashboardEventListener<E extends DashboardEventName> = (
|
||||
// ── Channel Definitions ───────────────────────────────────────────────────
|
||||
|
||||
/** Available subscription channels */
|
||||
export type DashboardChannel = "requests" | "combo" | "credentials";
|
||||
export type DashboardChannel = "requests" | "combo" | "credentials" | "compression";
|
||||
|
||||
/** Map channels to their events */
|
||||
export const CHANNEL_EVENTS: Record<DashboardChannel, DashboardEventName[]> = {
|
||||
requests: ["request.started", "request.streaming", "request.completed", "request.failed"],
|
||||
combo: ["combo.target.attempt", "combo.target.failed", "combo.target.succeeded"],
|
||||
credentials: ["credential.health.changed"],
|
||||
compression: ["compression.completed"],
|
||||
};
|
||||
|
||||
/** Get channel for an event */
|
||||
|
||||
111
src/shared/components/flow/FlowCanvas.tsx
Normal file
111
src/shared/components/flow/FlowCanvas.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Controls,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
type EdgeTypes,
|
||||
type NodeMouseHandler,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
const FIT_VIEW_OPTIONS = { padding: 0.22, duration: 250 } as const;
|
||||
const MIN_ZOOM = 0.08;
|
||||
const MAX_ZOOM = 2;
|
||||
const REFIT_DELAY_MS = 60;
|
||||
|
||||
type FlowCanvasProps = {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
nodeTypes?: NodeTypes;
|
||||
edgeTypes?: EdgeTypes;
|
||||
/**
|
||||
* Changing this remounts the graph (fresh `fitView`). Pass a key derived from
|
||||
* the data identity (e.g. the sorted provider list) — same role as the
|
||||
* `key={providersKey}` ProviderTopology used.
|
||||
*/
|
||||
fitKey?: string | number;
|
||||
/** When false (default) the graph is read-only: no drag, no selection. */
|
||||
interactive?: boolean;
|
||||
/** Sizing/theme classes for the container that hosts the canvas. */
|
||||
className?: string;
|
||||
onNodeClick?: NodeMouseHandler;
|
||||
/** Overlays rendered inside the canvas (after Controls). */
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable ReactFlow wrapper (U0), extracted from `ProviderTopology` without
|
||||
* behavioural change: auto-fit on init, on resize (ResizeObserver) and on node
|
||||
* count change; attribution hidden; read-only by default. Shared by the home
|
||||
* topology, the Combo/Routing Studio (Tela B) and the Compression Studio (Tela A).
|
||||
*/
|
||||
export function FlowCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
nodeTypes,
|
||||
edgeTypes,
|
||||
fitKey,
|
||||
interactive = false,
|
||||
className = "h-full w-full min-w-0 overflow-hidden",
|
||||
onNodeClick,
|
||||
children,
|
||||
}: FlowCanvasProps) {
|
||||
const rfInstance = useRef<{ fitView: (opts: typeof FIT_VIEW_OPTIONS) => void } | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onInit = useCallback((instance: { fitView: (opts: typeof FIT_VIEW_OPTIONS) => void }) => {
|
||||
rfInstance.current = instance;
|
||||
setTimeout(() => instance.fitView(FIT_VIEW_OPTIONS), REFIT_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
rfInstance.current?.fitView(FIT_VIEW_OPTIONS);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => rfInstance.current?.fitView(FIT_VIEW_OPTIONS), REFIT_DELAY_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [nodes.length]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<ReactFlow
|
||||
key={fitKey}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
fitView
|
||||
fitViewOptions={FIT_VIEW_OPTIONS}
|
||||
minZoom={MIN_ZOOM}
|
||||
maxZoom={MAX_ZOOM}
|
||||
onInit={onInit}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
zoomOnPinch
|
||||
zoomOnDoubleClick
|
||||
preventScrolling={false}
|
||||
nodesDraggable={interactive}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={interactive}
|
||||
>
|
||||
<Controls showInteractive={false} />
|
||||
{children}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowCanvas;
|
||||
36
src/shared/components/flow/StatusDot.tsx
Normal file
36
src/shared/components/flow/StatusDot.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { FLOW_EDGE_COLORS } from "./edgeStyles";
|
||||
|
||||
type StatusDotProps = {
|
||||
/** Resolved base color (used when `error` is false). */
|
||||
color: string;
|
||||
/** When true the dot turns red, overriding `color`. */
|
||||
error?: boolean;
|
||||
/**
|
||||
* Tailwind size class for the dot (e.g. `size-1.5`, `size-2`). Defaults to the
|
||||
* value used by ProviderTopology so the home pulse is pixel-identical.
|
||||
*/
|
||||
sizeClass?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The pulsing presence indicator extracted from `ProviderTopology` (U0). Renders
|
||||
* an `animate-ping` halo plus a solid dot. Callers decide *whether* to show it
|
||||
* (e.g. only when a node is active or errored); this component only draws it.
|
||||
*/
|
||||
export function StatusDot({ color, error = false, sizeClass = "size-1.5" }: StatusDotProps) {
|
||||
const dotColor = error ? FLOW_EDGE_COLORS.error : color;
|
||||
return (
|
||||
<span className={`relative flex ${sizeClass} shrink-0`}>
|
||||
<span
|
||||
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-70"
|
||||
style={{ backgroundColor: dotColor }}
|
||||
/>
|
||||
<span
|
||||
className={`relative inline-flex rounded-full ${sizeClass}`}
|
||||
style={{ backgroundColor: dotColor }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatusDot;
|
||||
30
src/shared/components/flow/edgeStyles.ts
Normal file
30
src/shared/components/flow/edgeStyles.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Shared ReactFlow edge palette + styling, extracted verbatim from
|
||||
* `ProviderTopology.tsx` (U0). Reused by the Combo/Routing Studio (Tela B) and
|
||||
* the Compression Studio (Tela A) so all three flow graphs speak the same
|
||||
* color language: green = active, red = error, amber = last-used, muted = idle.
|
||||
*/
|
||||
export const FLOW_EDGE_COLORS = {
|
||||
active: "#22c55e",
|
||||
error: "#ef4444",
|
||||
last: "#f59e0b",
|
||||
idle: "var(--color-border)",
|
||||
} as const;
|
||||
|
||||
export interface FlowEdgeStyle {
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stroke style for an edge given its state. Precedence is
|
||||
* error > active > last-used > idle — identical to the original ProviderTopology
|
||||
* implementation (do not reorder without updating the home regression).
|
||||
*/
|
||||
export function edgeStyle(active: boolean, last: boolean, error: boolean): FlowEdgeStyle {
|
||||
if (error) return { stroke: FLOW_EDGE_COLORS.error, strokeWidth: 2, opacity: 0.85 };
|
||||
if (active) return { stroke: FLOW_EDGE_COLORS.active, strokeWidth: 2.5, opacity: 1 };
|
||||
if (last) return { stroke: FLOW_EDGE_COLORS.last, strokeWidth: 1.5, opacity: 0.6 };
|
||||
return { stroke: FLOW_EDGE_COLORS.idle, strokeWidth: 1, opacity: 0.2 };
|
||||
}
|
||||
295
tests/unit/compression/bailout.test.ts
Normal file
295
tests/unit/compression/bailout.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* TV1 — Transversal compression bail-out discipline (OPT-IN).
|
||||
*
|
||||
* Proves:
|
||||
* 1. An engine that THROWS in apply() → step skipped (no throw, original body kept).
|
||||
* 2. An engine whose gain is < minGainPercent (10%) → step skipped.
|
||||
* 3. An engine with gain ≥ 10% → applied normally.
|
||||
* 4. With bail-out DISABLED (default) → a <10%-gain engine IS applied
|
||||
* (proving that opt-in default never changes existing behaviour).
|
||||
*/
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
applyStackedCompression,
|
||||
applyStackedCompressionAsync,
|
||||
} from "../../../open-sse/services/compression/index.ts";
|
||||
import {
|
||||
registerCompressionEngine,
|
||||
unregisterCompressionEngine,
|
||||
} from "../../../open-sse/services/compression/engines/registry.ts";
|
||||
import type {
|
||||
CompressionEngine,
|
||||
CompressionEngineTarget,
|
||||
} from "../../../open-sse/services/compression/engines/types.ts";
|
||||
import type {
|
||||
CompressionPipelineStep,
|
||||
CompressionResult,
|
||||
} from "../../../open-sse/services/compression/types.ts";
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Base skeleton shared by all fake engines. */
|
||||
function makeBaseEngine(id: string): Omit<CompressionEngine, "apply"> {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: id,
|
||||
icon: "x",
|
||||
targets: ["messages"] as CompressionEngineTarget[],
|
||||
stackable: true,
|
||||
stackPriority: 0,
|
||||
metadata: {
|
||||
id,
|
||||
name: id,
|
||||
description: id,
|
||||
inputScope: "messages",
|
||||
targetLatencyMs: 1,
|
||||
supportsPreview: false,
|
||||
stable: true,
|
||||
},
|
||||
compress: (body) => ({ body, compressed: false, stats: null }),
|
||||
getConfigSchema: () => [],
|
||||
validateConfig: () => ({ valid: true, errors: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Engine that always throws during apply(). */
|
||||
const THROW_ENGINE_ID = "bailout-throw-engine";
|
||||
const throwEngine: CompressionEngine = {
|
||||
...makeBaseEngine(THROW_ENGINE_ID),
|
||||
apply: (_body) => {
|
||||
throw new Error("simulated engine failure");
|
||||
},
|
||||
};
|
||||
|
||||
/** Engine that returns a low gain (5% < 10% threshold). */
|
||||
const LOW_GAIN_ENGINE_ID = "bailout-low-gain-engine";
|
||||
function makeLowGainEngine(id = LOW_GAIN_ENGINE_ID): CompressionEngine {
|
||||
return {
|
||||
...makeBaseEngine(id),
|
||||
apply: (body) => {
|
||||
const messages = (body.messages as Array<{ role: string; content: string }>) ?? [];
|
||||
const next = messages.map((m) =>
|
||||
m.role === "user" ? { ...m, content: m.content + "|low" } : m
|
||||
);
|
||||
return {
|
||||
body: { ...body, messages: next },
|
||||
compressed: true,
|
||||
stats: {
|
||||
originalTokens: 100,
|
||||
compressedTokens: 95,
|
||||
savingsPercent: 5, // 5% < 10% threshold
|
||||
techniquesUsed: [id],
|
||||
mode: "stacked",
|
||||
timestamp: 0,
|
||||
durationMs: 0.1,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Engine that returns a high gain (20% ≥ 10% threshold). */
|
||||
const HIGH_GAIN_ENGINE_ID = "bailout-high-gain-engine";
|
||||
const highGainEngine: CompressionEngine = {
|
||||
...makeBaseEngine(HIGH_GAIN_ENGINE_ID),
|
||||
apply: (body) => {
|
||||
const messages = (body.messages as Array<{ role: string; content: string }>) ?? [];
|
||||
const next = messages.map((m) =>
|
||||
m.role === "user" ? { ...m, content: m.content + "|high" } : m
|
||||
);
|
||||
return {
|
||||
body: { ...body, messages: next },
|
||||
compressed: true,
|
||||
stats: {
|
||||
originalTokens: 100,
|
||||
compressedTokens: 80,
|
||||
savingsPercent: 20, // 20% ≥ 10% threshold
|
||||
techniquesUsed: [HIGH_GAIN_ENGINE_ID],
|
||||
mode: "stacked",
|
||||
timestamp: 0,
|
||||
durationMs: 0.1,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Async variant of the low-gain engine. */
|
||||
const LOW_GAIN_ASYNC_ID = "bailout-low-gain-async";
|
||||
const lowGainAsyncEngine: CompressionEngine = {
|
||||
...makeLowGainEngine(LOW_GAIN_ASYNC_ID),
|
||||
apply: (body) => ({ body, compressed: false, stats: null }), // sync pass-through (async-only pattern)
|
||||
applyAsync: async (body) => makeLowGainEngine(LOW_GAIN_ASYNC_ID).apply(body),
|
||||
};
|
||||
|
||||
/** Async variant of the throw engine. */
|
||||
const THROW_ASYNC_ID = "bailout-throw-async";
|
||||
const throwAsyncEngine: CompressionEngine = {
|
||||
...makeBaseEngine(THROW_ASYNC_ID),
|
||||
apply: (body) => ({ body, compressed: false, stats: null }), // sync pass-through
|
||||
applyAsync: async (_body) => {
|
||||
throw new Error("simulated async engine failure");
|
||||
},
|
||||
};
|
||||
|
||||
function pipeline(...ids: string[]): CompressionPipelineStep[] {
|
||||
return ids.map((engine) => ({ engine })) as unknown as CompressionPipelineStep[];
|
||||
}
|
||||
|
||||
function userContent(result: CompressionResult): string {
|
||||
const messages = result.body.messages as Array<{ role: string; content: string }>;
|
||||
return messages.find((m) => m.role === "user")!.content;
|
||||
}
|
||||
|
||||
const BAILOUT_ON = { bailout: { enabled: true, minGainPercent: 10 } };
|
||||
const BAILOUT_OFF = {}; // default — no bailout field
|
||||
|
||||
// ── suite ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TV1 — stacked pipeline bail-out discipline (OPT-IN)", () => {
|
||||
before(() => {
|
||||
registerCompressionEngine(throwEngine);
|
||||
registerCompressionEngine(makeLowGainEngine());
|
||||
registerCompressionEngine(highGainEngine);
|
||||
registerCompressionEngine(lowGainAsyncEngine);
|
||||
registerCompressionEngine(throwAsyncEngine);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
unregisterCompressionEngine(THROW_ENGINE_ID);
|
||||
unregisterCompressionEngine(LOW_GAIN_ENGINE_ID);
|
||||
unregisterCompressionEngine(HIGH_GAIN_ENGINE_ID);
|
||||
unregisterCompressionEngine(LOW_GAIN_ASYNC_ID);
|
||||
unregisterCompressionEngine(THROW_ASYNC_ID);
|
||||
});
|
||||
|
||||
// ── SYNC tests ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("sync — applyStackedCompression", () => {
|
||||
it("bail-out ON: throwing engine → step skipped, pipeline does NOT throw", () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
// Must not throw and original body is kept (throw engine was the only step)
|
||||
const result = applyStackedCompression(body, pipeline(THROW_ENGINE_ID), BAILOUT_ON);
|
||||
|
||||
assert.equal(result.compressed, false);
|
||||
assert.equal(userContent(result), "hello"); // body unchanged
|
||||
|
||||
// TV1 fix: a crashing engine must be RECORDED in telemetry, not silently gone.
|
||||
assert.equal(result.stats?.fallbackApplied, true, "throw must set fallbackApplied");
|
||||
assert.ok(
|
||||
result.stats?.validationErrors?.some((e) => e.includes(THROW_ENGINE_ID)),
|
||||
"throwing engine must be recorded in validationErrors"
|
||||
);
|
||||
});
|
||||
|
||||
it("bail-out ON: throwing engine before a good engine → good engine still runs", () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
// throw engine first, then high-gain engine — the high-gain must still run
|
||||
const result = applyStackedCompression(
|
||||
body,
|
||||
pipeline(THROW_ENGINE_ID, HIGH_GAIN_ENGINE_ID),
|
||||
BAILOUT_ON
|
||||
);
|
||||
|
||||
// high-gain engine appends "|high"
|
||||
assert.equal(userContent(result), "hello|high");
|
||||
assert.equal(result.compressed, true);
|
||||
});
|
||||
|
||||
it("bail-out ON: low-gain engine (5%) → body NOT advanced (step skipped)", () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
const result = applyStackedCompression(body, pipeline(LOW_GAIN_ENGINE_ID), BAILOUT_ON);
|
||||
|
||||
// low-gain would append "|low" but it should be skipped
|
||||
assert.equal(userContent(result), "hello");
|
||||
// compressed is false because the only step was skipped
|
||||
assert.equal(result.compressed, false);
|
||||
});
|
||||
|
||||
it("bail-out ON: high-gain engine (20%) → body IS advanced normally", () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
const result = applyStackedCompression(body, pipeline(HIGH_GAIN_ENGINE_ID), BAILOUT_ON);
|
||||
|
||||
assert.equal(userContent(result), "hello|high");
|
||||
assert.equal(result.compressed, true);
|
||||
});
|
||||
|
||||
it("bail-out ON: low-gain then high-gain → only high-gain advances body", () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
const result = applyStackedCompression(
|
||||
body,
|
||||
pipeline(LOW_GAIN_ENGINE_ID, HIGH_GAIN_ENGINE_ID),
|
||||
BAILOUT_ON
|
||||
);
|
||||
|
||||
// "|low" should be absent; "|high" should be present
|
||||
assert.equal(userContent(result), "hello|high");
|
||||
});
|
||||
|
||||
it("bail-out OFF (default): low-gain engine IS applied (opt-in guard)", () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
// No bailout config at all — original behavior
|
||||
const result = applyStackedCompression(body, pipeline(LOW_GAIN_ENGINE_ID), BAILOUT_OFF);
|
||||
|
||||
// Without bail-out, the step is always applied
|
||||
assert.equal(userContent(result), "hello|low");
|
||||
assert.equal(result.compressed, true);
|
||||
});
|
||||
|
||||
it("bail-out OFF (default): throwing engine propagates — unchanged existing behavior", () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
// Without bail-out, a throw is NOT caught → pipeline throws
|
||||
assert.throws(() => {
|
||||
applyStackedCompression(body, pipeline(THROW_ENGINE_ID), BAILOUT_OFF);
|
||||
}, /simulated engine failure/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ASYNC tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("async — applyStackedCompressionAsync", () => {
|
||||
it("bail-out ON: async throwing engine → step skipped, no throw", async () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
const result = await applyStackedCompressionAsync(body, pipeline(THROW_ASYNC_ID), BAILOUT_ON);
|
||||
|
||||
assert.equal(userContent(result), "hello");
|
||||
assert.equal(result.compressed, false);
|
||||
});
|
||||
|
||||
it("bail-out ON: async low-gain engine (5%) → step skipped", async () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
const result = await applyStackedCompressionAsync(
|
||||
body,
|
||||
pipeline(LOW_GAIN_ASYNC_ID),
|
||||
BAILOUT_ON
|
||||
);
|
||||
|
||||
assert.equal(userContent(result), "hello");
|
||||
assert.equal(result.compressed, false);
|
||||
});
|
||||
|
||||
it("bail-out OFF (default): async low-gain engine IS applied", async () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
const result = await applyStackedCompressionAsync(
|
||||
body,
|
||||
pipeline(LOW_GAIN_ASYNC_ID),
|
||||
BAILOUT_OFF
|
||||
);
|
||||
|
||||
assert.equal(userContent(result), "hello|low");
|
||||
assert.equal(result.compressed, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
173
tests/unit/compression/benchmark.test.ts
Normal file
173
tests/unit/compression/benchmark.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, it, before } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
BENCHMARK_CORPUS,
|
||||
engineToCompressFn,
|
||||
benchmarkEngines,
|
||||
compareReports,
|
||||
runBenchmarkGate,
|
||||
} from "../../../open-sse/services/compression/harness/benchmark.ts";
|
||||
|
||||
// ── RED/GREEN proof: all assertions here must hold once benchmark.ts exists ──
|
||||
|
||||
describe("benchmark — engineToCompressFn adapter", () => {
|
||||
it("returns a function for a known engine id", () => {
|
||||
const fn = engineToCompressFn("rtk");
|
||||
assert.equal(typeof fn, "function");
|
||||
});
|
||||
|
||||
it("compressFn returns a string for any text input", async () => {
|
||||
const fn = engineToCompressFn("rtk");
|
||||
const noisy =
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n".repeat(10) +
|
||||
"Unnecessary filler words that add no value whatsoever indeed actually.";
|
||||
const out = await fn(noisy);
|
||||
assert.equal(typeof out, "string");
|
||||
});
|
||||
|
||||
it("compressFn output is shorter or equal for clearly compressible prose", async () => {
|
||||
// A highly redundant input that caveman rules should shorten
|
||||
const fn = engineToCompressFn("caveman");
|
||||
const repetitive =
|
||||
"This is a very redundant message. This is a very redundant message.\n".repeat(8);
|
||||
const out = await fn(repetitive);
|
||||
// The adapter must return a string and it must not be longer than the input
|
||||
assert.ok(
|
||||
out.length <= repetitive.length,
|
||||
`expected shorter output, got ${out.length} vs ${repetitive.length}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("benchmark — benchmarkEngines", () => {
|
||||
let reports: Awaited<ReturnType<typeof benchmarkEngines>>;
|
||||
|
||||
before(async () => {
|
||||
reports = await benchmarkEngines(BENCHMARK_CORPUS, ["rtk", "caveman", "headroom"]);
|
||||
});
|
||||
|
||||
it("returns one report per requested engine", () => {
|
||||
assert.deepEqual(Object.keys(reports).sort(), ["caveman", "headroom", "rtk"]);
|
||||
});
|
||||
|
||||
it("each report has a valid meanSavingsPercent (a number)", () => {
|
||||
for (const [engineId, report] of Object.entries(reports)) {
|
||||
assert.equal(
|
||||
typeof report.meanSavingsPercent,
|
||||
"number",
|
||||
`${engineId}: meanSavingsPercent must be a number`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("each report has meanRetention in [0, 1]", () => {
|
||||
for (const [engineId, report] of Object.entries(reports)) {
|
||||
assert.ok(
|
||||
report.meanRetention >= 0 && report.meanRetention <= 1,
|
||||
`${engineId}: meanRetention ${report.meanRetention} must be in [0,1]`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("each report has results for every corpus item", () => {
|
||||
for (const [engineId, report] of Object.entries(reports)) {
|
||||
assert.equal(
|
||||
report.results.length,
|
||||
BENCHMARK_CORPUS.length,
|
||||
`${engineId}: expected ${BENCHMARK_CORPUS.length} result(s), got ${report.results.length}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("benchmark — compareReports", () => {
|
||||
it("returns one summary row per engine", async () => {
|
||||
const reports = await benchmarkEngines(BENCHMARK_CORPUS, ["rtk", "caveman"]);
|
||||
const summary = compareReports(reports);
|
||||
assert.equal(summary.length, 2);
|
||||
for (const row of summary) {
|
||||
assert.ok("engine" in row);
|
||||
assert.ok("meanSavingsPercent" in row);
|
||||
assert.ok("meanRetention" in row);
|
||||
assert.ok("totalCompressedTokens" in row);
|
||||
}
|
||||
});
|
||||
|
||||
it("is sorted by meanSavingsPercent descending (best saver first)", async () => {
|
||||
const reports = await benchmarkEngines(BENCHMARK_CORPUS, ["rtk", "caveman"]);
|
||||
const summary = compareReports(reports);
|
||||
for (let i = 1; i < summary.length; i++) {
|
||||
assert.ok(
|
||||
summary[i - 1].meanSavingsPercent >= summary[i].meanSavingsPercent,
|
||||
`row ${i - 1} savings ${summary[i - 1].meanSavingsPercent} should be >= row ${i} savings ${summary[i].meanSavingsPercent}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("benchmark — runBenchmarkGate (N4)", () => {
|
||||
it("passes when baselines match current costs", async () => {
|
||||
const reports = await benchmarkEngines(BENCHMARK_CORPUS, ["rtk"]);
|
||||
// Baseline = exact current tokensPerTask → must pass
|
||||
const rtkReport = reports["rtk"];
|
||||
const baselines: Record<string, { tasks: Record<string, number> }> = {};
|
||||
const taskTotals: Record<string, { sum: number; count: number }> = {};
|
||||
for (const r of rtkReport.results) {
|
||||
const t = taskTotals[r.task] ?? { sum: 0, count: 0 };
|
||||
t.sum += r.compressedTokens;
|
||||
t.count += 1;
|
||||
taskTotals[r.task] = t;
|
||||
}
|
||||
baselines["rtk"] = {
|
||||
tasks: Object.fromEntries(
|
||||
Object.entries(taskTotals).map(([k, v]) => [k, Math.round(v.sum / v.count)])
|
||||
),
|
||||
};
|
||||
|
||||
const gateResults = runBenchmarkGate(reports, baselines);
|
||||
const rtkGate = gateResults.find((g) => g.engine === "rtk");
|
||||
assert.ok(rtkGate, "rtk gate result missing");
|
||||
assert.equal(rtkGate.gate.passed, true, "gate should pass when baseline matches current");
|
||||
});
|
||||
|
||||
it("fails (regression) when baseline is tighter than actual cost", async () => {
|
||||
const reports = await benchmarkEngines(BENCHMARK_CORPUS, ["rtk"]);
|
||||
// Set an impossibly tight baseline (1 token per task) → regression guaranteed
|
||||
const impossibleBaselines: Record<string, { tasks: Record<string, number> }> = {
|
||||
rtk: { tasks: { prose: 1, "tool-output": 1, json: 1 } },
|
||||
};
|
||||
const gateResults = runBenchmarkGate(reports, impossibleBaselines);
|
||||
const rtkGate = gateResults.find((g) => g.engine === "rtk");
|
||||
assert.ok(rtkGate, "rtk gate result missing");
|
||||
assert.equal(rtkGate.gate.passed, false, "gate should fail when baseline is impossibly tight");
|
||||
assert.ok(rtkGate.gate.regressions.length > 0, "regressions array must be non-empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("benchmark — reproducibility", () => {
|
||||
it("two runs on the same corpus yield identical meanSavingsPercent", async () => {
|
||||
const engines = ["rtk", "caveman"];
|
||||
const [r1, r2] = await Promise.all([
|
||||
benchmarkEngines(BENCHMARK_CORPUS, engines),
|
||||
benchmarkEngines(BENCHMARK_CORPUS, engines),
|
||||
]);
|
||||
for (const id of engines) {
|
||||
assert.equal(
|
||||
r1[id].meanSavingsPercent,
|
||||
r2[id].meanSavingsPercent,
|
||||
`${id}: non-deterministic meanSavingsPercent`
|
||||
);
|
||||
assert.equal(
|
||||
r1[id].meanRetention,
|
||||
r2[id].meanRetention,
|
||||
`${id}: non-deterministic meanRetention`
|
||||
);
|
||||
assert.equal(
|
||||
r1[id].totalCompressedTokens,
|
||||
r2[id].totalCompressedTokens,
|
||||
`${id}: non-deterministic totalCompressedTokens`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
272
tests/unit/compression/ccr-marker-retrieve.test.ts
Normal file
272
tests/unit/compression/ccr-marker-retrieve.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* TDD tests for the CCR (Content-Compression-Retrieve) engine (H4).
|
||||
* Run: node --import tsx/esm --test tests/unit/compression/ccr-marker-retrieve.test.ts
|
||||
*
|
||||
* RED phase: these tests should FAIL before implementing the engine.
|
||||
* GREEN phase: these tests should PASS after implementation.
|
||||
*/
|
||||
|
||||
import { describe, it, before } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
ccrEngine,
|
||||
reconstructCcr,
|
||||
retrieveBlock,
|
||||
recordRetrieval,
|
||||
shouldSkipCompression,
|
||||
resetCcrStore,
|
||||
handleCcrRetrieve,
|
||||
} from "../../../open-sse/services/compression/engines/ccr/index.ts";
|
||||
import {
|
||||
registerBuiltinCompressionEngines,
|
||||
getCompressionEngine,
|
||||
} from "../../../open-sse/services/compression/index.ts";
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const LARGE_TEXT = `This is a large block of content that should trigger CCR compression.
|
||||
It contains multiple lines and substantial text.
|
||||
The CCR engine compresses large contiguous blocks of text.
|
||||
Replacing them with a content-addressed retrieve marker.
|
||||
This allows the model to retrieve the verbatim content on demand.
|
||||
Using the retrieve MCP tool with the hash from the marker.
|
||||
The block must be large enough to exceed the minimum threshold.
|
||||
Default minimum is 600 characters, so this block is crafted accordingly.
|
||||
We need to be thorough and ensure the block is truly large enough.
|
||||
This is line ten and still counting to make the block big enough.`;
|
||||
|
||||
const SMALL_TEXT = "Short content that should NOT be compressed.";
|
||||
|
||||
const SYSTEM_TEXT = "You are a helpful assistant with system instructions.";
|
||||
|
||||
function makeBody(messages: Array<{ role: string; content: string }>) {
|
||||
return { model: "gpt-4", messages };
|
||||
}
|
||||
|
||||
// ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ccr engine", () => {
|
||||
before(() => {
|
||||
resetCcrStore();
|
||||
registerBuiltinCompressionEngines();
|
||||
});
|
||||
|
||||
it("is registered and retrievable by id", () => {
|
||||
const engine = getCompressionEngine("ccr");
|
||||
assert.ok(engine, "getCompressionEngine('ccr') must return the engine");
|
||||
assert.equal(engine.id, "ccr");
|
||||
assert.equal(typeof engine.apply, "function");
|
||||
assert.equal(typeof engine.compress, "function");
|
||||
assert.equal(typeof engine.getConfigSchema, "function");
|
||||
assert.equal(typeof engine.validateConfig, "function");
|
||||
assert.equal(engine.stackable, true);
|
||||
assert.ok(typeof engine.stackPriority === "number");
|
||||
});
|
||||
|
||||
it("replaces a large block with a CCR marker", () => {
|
||||
resetCcrStore();
|
||||
const body = makeBody([{ role: "user", content: LARGE_TEXT }]);
|
||||
const result = ccrEngine.apply(body as Record<string, unknown>);
|
||||
|
||||
assert.equal(result.compressed, true, "should report compressed=true");
|
||||
|
||||
const messages = result.body.messages as Array<{ role: string; content: string }>;
|
||||
const content = messages[0].content;
|
||||
|
||||
// Marker must be present
|
||||
assert.match(
|
||||
content,
|
||||
/\[CCR retrieve hash=[0-9a-f]{24} chars=\d+\]/,
|
||||
"content must contain a [CCR retrieve hash=<24hex> chars=N] marker"
|
||||
);
|
||||
|
||||
// Original large text must be gone
|
||||
assert.ok(!content.includes(LARGE_TEXT), "original large block text must be replaced");
|
||||
|
||||
// Body must be shorter
|
||||
const originalLen = JSON.stringify(body).length;
|
||||
const compressedLen = JSON.stringify(result.body).length;
|
||||
assert.ok(compressedLen < originalLen, "compressed body must be shorter than original");
|
||||
});
|
||||
|
||||
it("stores and retrieves the verbatim block by hash", () => {
|
||||
resetCcrStore();
|
||||
const body = makeBody([{ role: "user", content: LARGE_TEXT }]);
|
||||
const result = ccrEngine.apply(body as Record<string, unknown>);
|
||||
|
||||
const messages = result.body.messages as Array<{ role: string; content: string }>;
|
||||
const content = messages[0].content;
|
||||
|
||||
// Extract hash from marker
|
||||
const match = content.match(/\[CCR retrieve hash=([0-9a-f]{24}) chars=\d+\]/);
|
||||
assert.ok(match, "marker must be present to extract hash");
|
||||
const hash = match[1];
|
||||
|
||||
// Retrieve the block
|
||||
const retrieved = retrieveBlock(hash);
|
||||
assert.ok(retrieved !== null, "retrieveBlock must return the stored block");
|
||||
assert.equal(retrieved, LARGE_TEXT, "retrieved block must equal the original verbatim text");
|
||||
});
|
||||
|
||||
it("reconstructs the body to deep-equal the original (round-trip)", () => {
|
||||
resetCcrStore();
|
||||
const originalMessages = [
|
||||
{ role: "user", content: LARGE_TEXT },
|
||||
{ role: "assistant", content: "I understand your point." },
|
||||
];
|
||||
const body = makeBody(originalMessages);
|
||||
const result = ccrEngine.apply(body as Record<string, unknown>);
|
||||
|
||||
assert.equal(result.compressed, true, "should be compressed");
|
||||
|
||||
const reconstructed = reconstructCcr(result.body);
|
||||
const reconstructedMessages = reconstructed.messages as Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
}>;
|
||||
|
||||
assert.deepEqual(
|
||||
reconstructedMessages,
|
||||
originalMessages,
|
||||
"reconstructed messages must deep-equal original messages"
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT compress small blocks (below minChars threshold)", () => {
|
||||
resetCcrStore();
|
||||
const body = makeBody([{ role: "user", content: SMALL_TEXT }]);
|
||||
const result = ccrEngine.apply(body as Record<string, unknown>);
|
||||
|
||||
assert.equal(result.compressed, false, "small text should NOT be compressed");
|
||||
|
||||
const messages = result.body.messages as Array<{ role: string; content: string }>;
|
||||
assert.equal(messages[0].content, SMALL_TEXT, "small text must remain unchanged");
|
||||
});
|
||||
|
||||
it("does NOT compress system messages", () => {
|
||||
resetCcrStore();
|
||||
const body = makeBody([{ role: "system", content: SYSTEM_TEXT + " ".repeat(700) }]);
|
||||
const result = ccrEngine.apply(body as Record<string, unknown>);
|
||||
|
||||
assert.equal(result.compressed, false, "system messages must NOT be compressed");
|
||||
|
||||
const messages = result.body.messages as Array<{ role: string; content: string }>;
|
||||
assert.ok(
|
||||
!messages[0].content.match(/\[CCR retrieve/),
|
||||
"system message must not contain a CCR marker"
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT compress if output would not be shorter", () => {
|
||||
resetCcrStore();
|
||||
// A text that's just barely at the threshold but the marker is longer than the text
|
||||
const shortishText = "X".repeat(601); // just above 600 but marker is ~50 chars
|
||||
const body = makeBody([{ role: "user", content: shortishText }]);
|
||||
// The marker is [CCR retrieve hash=<24hex> chars=601] ≈ 48 chars
|
||||
// So 601 chars → 48 char marker = savings, so this WILL compress
|
||||
// To test "not shorter", we'd need a tiny text — but that's already handled by minChars.
|
||||
// This test just confirms a borderline-large block compresses fine.
|
||||
const result = ccrEngine.apply(body as Record<string, unknown>);
|
||||
// Since 601 > 600 (default min), it should compress
|
||||
assert.equal(result.compressed, true, "text above minChars should compress");
|
||||
});
|
||||
|
||||
it("feedback: shouldSkipCompression returns true after enough retrievals", () => {
|
||||
resetCcrStore();
|
||||
const body = makeBody([{ role: "user", content: LARGE_TEXT }]);
|
||||
const result = ccrEngine.apply(body as Record<string, unknown>);
|
||||
|
||||
const messages = result.body.messages as Array<{ role: string; content: string }>;
|
||||
const content = messages[0].content;
|
||||
const match = content.match(/\[CCR retrieve hash=([0-9a-f]{24}) chars=\d+\]/);
|
||||
assert.ok(match, "marker must be present");
|
||||
const hash = match[1];
|
||||
|
||||
// Initially should not skip
|
||||
assert.equal(shouldSkipCompression(hash), false, "initially should not skip compression");
|
||||
|
||||
// Record retrievals above threshold (default 3)
|
||||
recordRetrieval(hash);
|
||||
recordRetrieval(hash);
|
||||
recordRetrieval(hash);
|
||||
|
||||
// After 3 retrievals, shouldSkipCompression should return true
|
||||
assert.equal(
|
||||
shouldSkipCompression(hash),
|
||||
true,
|
||||
"after enough retrievals, shouldSkipCompression must return true"
|
||||
);
|
||||
});
|
||||
|
||||
it("retrieveBlock returns null for unknown hash", () => {
|
||||
resetCcrStore();
|
||||
const result = retrieveBlock("000000000000000000000000");
|
||||
assert.equal(result, null, "unknown hash must return null");
|
||||
});
|
||||
|
||||
it("reconstructCcr is idempotent when no markers are present", () => {
|
||||
const body = makeBody([{ role: "user", content: SMALL_TEXT }]);
|
||||
const reconstructed = reconstructCcr(body as Record<string, unknown>);
|
||||
assert.deepEqual(reconstructed.messages as Array<{ role: string; content: string }>, [
|
||||
{ role: "user", content: SMALL_TEXT },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles multipart content (type:text parts)", () => {
|
||||
resetCcrStore();
|
||||
const body = {
|
||||
model: "gpt-4",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: LARGE_TEXT },
|
||||
{ type: "text", text: "and a small follow-up" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = ccrEngine.apply(body as Record<string, unknown>);
|
||||
assert.equal(result.compressed, true, "multipart message with large text-part should compress");
|
||||
|
||||
const messages = result.body.messages as Array<{
|
||||
role: string;
|
||||
content: Array<{ type: string; text: string }>;
|
||||
}>;
|
||||
const largePart = messages[0].content[0];
|
||||
assert.ok(
|
||||
largePart.text.match(/\[CCR retrieve hash=[0-9a-f]{24} chars=\d+\]/),
|
||||
"large text part must be replaced by a CCR marker"
|
||||
);
|
||||
// Small part untouched
|
||||
const smallPart = messages[0].content[1];
|
||||
assert.equal(smallPart.text, "and a small follow-up");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ccr MCP retrieve handler (pure function)", () => {
|
||||
it("handleCcrRetrieve returns content for known hash", () => {
|
||||
resetCcrStore();
|
||||
|
||||
const body = makeBody([{ role: "user", content: LARGE_TEXT }]);
|
||||
const result = ccrEngine.apply(body as Record<string, unknown>);
|
||||
const messages = result.body.messages as Array<{ role: string; content: string }>;
|
||||
const match = messages[0].content.match(/\[CCR retrieve hash=([0-9a-f]{24}) chars=\d+\]/);
|
||||
assert.ok(match, "marker must be present");
|
||||
const hash = match[1];
|
||||
|
||||
const handlerResult = handleCcrRetrieve({ hash });
|
||||
assert.ok("content" in handlerResult, "handler must return content field");
|
||||
assert.equal((handlerResult as { content: string }).content, LARGE_TEXT);
|
||||
});
|
||||
|
||||
it("handleCcrRetrieve returns error for unknown hash", () => {
|
||||
resetCcrStore();
|
||||
const result = handleCcrRetrieve({ hash: "deadbeef000000000000000a" });
|
||||
assert.ok("error" in result, "unknown hash must return error field");
|
||||
assert.ok(typeof (result as { error: string }).error === "string");
|
||||
assert.ok((result as { error: string }).error.length > 0);
|
||||
});
|
||||
});
|
||||
42
tests/unit/compression/compressionChannel.test.ts
Normal file
42
tests/unit/compression/compressionChannel.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
type DashboardChannel,
|
||||
CHANNEL_EVENTS,
|
||||
getChannelForEvent,
|
||||
} from "../../../src/lib/events/types.ts";
|
||||
|
||||
describe("compression WS channel (U5)", () => {
|
||||
it("DashboardChannel includes 'compression'", () => {
|
||||
// The type must include 'compression' — we verify it at runtime by ensuring
|
||||
// CHANNEL_EVENTS has the key (TypeScript narrows keys to DashboardChannel).
|
||||
const channels = Object.keys(CHANNEL_EVENTS) as DashboardChannel[];
|
||||
assert.ok(
|
||||
channels.includes("compression" as DashboardChannel),
|
||||
"CHANNEL_EVENTS must have a 'compression' key"
|
||||
);
|
||||
});
|
||||
|
||||
it("CHANNEL_EVENTS.compression contains 'compression.completed'", () => {
|
||||
const events = (CHANNEL_EVENTS as Record<string, string[]>)["compression"];
|
||||
assert.ok(Array.isArray(events), "CHANNEL_EVENTS.compression should be an array");
|
||||
assert.ok(
|
||||
events.includes("compression.completed"),
|
||||
"compression channel must list 'compression.completed'"
|
||||
);
|
||||
});
|
||||
|
||||
it("getChannelForEvent maps 'compression.completed' → 'compression'", () => {
|
||||
const channel = getChannelForEvent(
|
||||
"compression.completed" as Parameters<typeof getChannelForEvent>[0]
|
||||
);
|
||||
assert.equal(channel, "compression");
|
||||
});
|
||||
|
||||
it("existing channels still route correctly", () => {
|
||||
assert.equal(getChannelForEvent("request.started"), "requests");
|
||||
assert.equal(getChannelForEvent("combo.target.attempt"), "combo");
|
||||
assert.equal(getChannelForEvent("credential.health.changed"), "credentials");
|
||||
});
|
||||
});
|
||||
138
tests/unit/compression/harness.test.ts
Normal file
138
tests/unit/compression/harness.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
measureCompression,
|
||||
computeRetention,
|
||||
extractEntities,
|
||||
runCompressionEval,
|
||||
tokensPerTask,
|
||||
checkTokensPerTaskGate,
|
||||
replayTranscripts,
|
||||
transcriptsToCorpus,
|
||||
} from "../../../open-sse/services/compression/harness/index.ts";
|
||||
|
||||
const SAMPLE = "Call fetchUser() at https://api.example.com/v1 with MAX_RETRIES set to 3.0.0";
|
||||
|
||||
describe("compression harness — measure (C1)", () => {
|
||||
it("extracts technical entities from the original", () => {
|
||||
const entities = extractEntities(SAMPLE);
|
||||
assert.ok(entities.length >= 3, `expected >=3 entities, got ${entities.length}`);
|
||||
assert.ok(
|
||||
entities.some((e) => e.includes("api.example.com")),
|
||||
"url entity missing"
|
||||
);
|
||||
assert.ok(
|
||||
entities.some((e) => e.includes("MAX_RETRIES")),
|
||||
"const_case entity missing"
|
||||
);
|
||||
});
|
||||
|
||||
it("scores full retention when nothing is lost", () => {
|
||||
const m = measureCompression(SAMPLE, SAMPLE);
|
||||
assert.equal(m.retention.score, 1);
|
||||
assert.equal(m.retention.survived, m.retention.total);
|
||||
assert.equal(m.retention.lost.length, 0);
|
||||
assert.equal(m.savingsPercent, 0);
|
||||
});
|
||||
|
||||
it("retention drops and lost-list signals when a URL is removed (degraded)", () => {
|
||||
const degraded = "Call fetchUser() with MAX_RETRIES set to 3.0.0";
|
||||
const r = computeRetention(SAMPLE, degraded);
|
||||
assert.ok(r.score < 1, "retention should drop when the URL is dropped");
|
||||
assert.ok(
|
||||
r.lost.some((e) => e.includes("api.example.com")),
|
||||
"lost list must name the URL"
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a positive savings ratio when the compressed text is shorter", () => {
|
||||
const m = measureCompression(SAMPLE, "fetchUser https://api.example.com/v1 MAX_RETRIES 3.0.0");
|
||||
assert.ok(m.savingsPercent > 0, "shorter output should report savings");
|
||||
assert.ok(m.compressedTokens < m.originalTokens);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compression harness — eval runner (C1)", () => {
|
||||
it("aggregates ratio + retention across a corpus", async () => {
|
||||
const corpus = [
|
||||
{ id: "a", input: SAMPLE, task: "chat" },
|
||||
{ id: "b", input: "Read config from process.env.API_KEY then run build()", task: "chat" },
|
||||
];
|
||||
const report = await runCompressionEval(corpus, (s) => s); // identity = lossless
|
||||
assert.equal(report.results.length, 2);
|
||||
assert.equal(report.meanRetention, 1);
|
||||
assert.equal(report.meanSavingsPercent, 0);
|
||||
});
|
||||
|
||||
it("awaits async compress functions (H10-friendly)", async () => {
|
||||
const report = await runCompressionEval(
|
||||
[{ id: "a", input: SAMPLE, task: "chat" }],
|
||||
async (s) => s
|
||||
);
|
||||
assert.equal(report.results[0].retention.score, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compression harness — tokens-per-task gate (N4)", () => {
|
||||
const longInput = "lorem ipsum dolor sit amet ".repeat(40);
|
||||
|
||||
it("passes when cost/task matches the baseline", async () => {
|
||||
const corpus = [{ id: "a", input: longInput, task: "chat" }];
|
||||
const baselineReport = await runCompressionEval(corpus, () => "ok");
|
||||
const baseline = { tasks: tokensPerTask(baselineReport) };
|
||||
|
||||
const gate = checkTokensPerTaskGate(baselineReport, baseline);
|
||||
assert.equal(gate.passed, true);
|
||||
assert.equal(gate.regressions.length, 0);
|
||||
});
|
||||
|
||||
it("FAILS when compressed cost/task rises above the baseline", async () => {
|
||||
const corpus = [{ id: "a", input: longInput, task: "chat" }];
|
||||
const baselineReport = await runCompressionEval(corpus, () => "ok"); // tiny output
|
||||
const baseline = { tasks: tokensPerTask(baselineReport) };
|
||||
|
||||
// Regression: the pipeline now barely compresses (returns the full input).
|
||||
const regressedReport = await runCompressionEval(corpus, (s) => s);
|
||||
const gate = checkTokensPerTaskGate(regressedReport, baseline);
|
||||
|
||||
assert.equal(gate.passed, false);
|
||||
assert.equal(gate.regressions[0].task, "chat");
|
||||
assert.ok(gate.regressions[0].current > gate.regressions[0].baseline);
|
||||
assert.ok(gate.regressions[0].deltaPercent > gate.tolerancePercent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compression harness — transcript replay (TV3)", () => {
|
||||
it("flattens transcripts into a corpus skipping empty turns", () => {
|
||||
const corpus = transcriptsToCorpus([
|
||||
{
|
||||
id: "t1",
|
||||
turns: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: " " },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(corpus.length, 1);
|
||||
assert.equal(corpus[0].task, "t1");
|
||||
});
|
||||
|
||||
it("measures ratio + retention replaying real transcript turns", async () => {
|
||||
const report = await replayTranscripts(
|
||||
[
|
||||
{
|
||||
id: "t1",
|
||||
turns: [
|
||||
{ role: "user", content: "see https://x.com/a and call run()" },
|
||||
{ role: "assistant", content: "ok done" },
|
||||
],
|
||||
},
|
||||
],
|
||||
(s) => s
|
||||
);
|
||||
assert.equal(report.results.length, 2);
|
||||
assert.ok(report.results.every((r) => r.task === "t1"));
|
||||
assert.equal(report.meanRetention, 1);
|
||||
});
|
||||
});
|
||||
307
tests/unit/compression/headroom-smartcrusher.test.ts
Normal file
307
tests/unit/compression/headroom-smartcrusher.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* TDD tests for headroom engine: SmartCrusher tabular compaction (H3 + N5 + GP5').
|
||||
*
|
||||
* RED phase: these tests are written BEFORE the implementation.
|
||||
* After implementation they must all pass (GREEN).
|
||||
*/
|
||||
|
||||
import { describe, it, before } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Lazy imports resolved inside tests so RED gives clean "module not found" errors,
|
||||
// not mysterious runtime crashes before any assertion.
|
||||
let headroomEngine: import("../../../open-sse/services/compression/engines/headroom/index.ts").headroomEngine;
|
||||
let encodeTabular: (arr: Record<string, unknown>[]) => string;
|
||||
let decodeTabular: (text: string) => Record<string, unknown>[];
|
||||
let getCompressionEngine: (
|
||||
id: string
|
||||
) => import("../../../open-sse/services/compression/engines/types.ts").CompressionEngine | null;
|
||||
let registerBuiltinCompressionEngines: () => void;
|
||||
|
||||
before(async () => {
|
||||
const mod = await import("../../../open-sse/services/compression/engines/headroom/index.ts");
|
||||
headroomEngine = mod.headroomEngine;
|
||||
encodeTabular = mod.encodeTabular;
|
||||
decodeTabular = mod.decodeTabular;
|
||||
|
||||
const regMod = await import("../../../open-sse/services/compression/engines/index.ts");
|
||||
registerBuiltinCompressionEngines = regMod.registerBuiltinCompressionEngines;
|
||||
|
||||
const registryMod = await import("../../../open-sse/services/compression/engines/registry.ts");
|
||||
getCompressionEngine = registryMod.getCompressionEngine;
|
||||
});
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build a homogeneous array of N simple objects */
|
||||
function makeRows(n: number): Record<string, unknown>[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `item-${i + 1}`,
|
||||
value: (i + 1) * 10,
|
||||
active: i % 2 === 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Build a body whose single user message content is JSON of the given array */
|
||||
function makeBody(
|
||||
arr: Record<string, unknown>[],
|
||||
opts?: { asJsonFence?: boolean }
|
||||
): Record<string, unknown> {
|
||||
const json = JSON.stringify(arr);
|
||||
const content = opts?.asJsonFence ? "Here are the results:\n```json\n" + json + "\n```" : json;
|
||||
return {
|
||||
model: "test-model",
|
||||
messages: [{ role: "user", content }],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 1. Pure encoder round-trip (lossless) ────────────────────────────────────
|
||||
|
||||
describe("tabular encoder round-trip", () => {
|
||||
it("encodes and decodes a homogeneous array of 20 objects to the same values", async () => {
|
||||
const original = makeRows(20);
|
||||
const encoded = encodeTabular(original);
|
||||
const decoded = decodeTabular(encoded);
|
||||
assert.deepEqual(decoded, original);
|
||||
});
|
||||
|
||||
it("round-trips values that contain commas, double-quotes, and newlines", async () => {
|
||||
const original: Record<string, unknown>[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i,
|
||||
description: `value with, comma and "quote" and\nnewline inside`,
|
||||
path: `/some/path/${i}`,
|
||||
}));
|
||||
const encoded = encodeTabular(original);
|
||||
const decoded = decodeTabular(encoded);
|
||||
assert.deepEqual(decoded, original);
|
||||
});
|
||||
|
||||
it("round-trips rows with a nested object value in a cell", async () => {
|
||||
const original: Record<string, unknown>[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i,
|
||||
meta: { key: `k-${i}`, count: i * 2 },
|
||||
tags: ["alpha", "beta", i.toString()],
|
||||
}));
|
||||
const encoded = encodeTabular(original);
|
||||
const decoded = decodeTabular(encoded);
|
||||
assert.deepEqual(decoded, original);
|
||||
});
|
||||
|
||||
it("encoded form contains an explicit [N rows] count marker", async () => {
|
||||
const original = makeRows(25);
|
||||
const encoded = encodeTabular(original);
|
||||
assert.match(encoded, /\[25 rows\]/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. engine.apply compresses ≥30% and is reversible ───────────────────────
|
||||
|
||||
describe("headroomEngine.apply — compression", () => {
|
||||
it("compresses a body with a 20-row homogeneous array by ≥30% (JSON.stringify length)", async () => {
|
||||
const rows = makeRows(20);
|
||||
const body = makeBody(rows);
|
||||
|
||||
const result = headroomEngine.apply(body);
|
||||
assert.equal(result.compressed, true, "should be flagged as compressed");
|
||||
|
||||
const origLen = JSON.stringify(body).length;
|
||||
const compLen = JSON.stringify(result.body).length;
|
||||
const ratio = (origLen - compLen) / origLen;
|
||||
assert.ok(
|
||||
ratio >= 0.3,
|
||||
`Expected ≥30% savings, got ${(ratio * 100).toFixed(1)}% (orig=${origLen}, comp=${compLen})`
|
||||
);
|
||||
});
|
||||
|
||||
it("body compressed by apply is reversible: decoding restores deep-equal original body", async () => {
|
||||
const rows = makeRows(20);
|
||||
const body = makeBody(rows);
|
||||
|
||||
const result = headroomEngine.apply(body);
|
||||
assert.equal(result.compressed, true);
|
||||
|
||||
// The compressed body messages content contains the tabular block. We restore using decodeTabular.
|
||||
// The engine must expose a reconstruct helper — we test via the exported reconstructHeadroom fn.
|
||||
const reconstructMod =
|
||||
await import("../../../open-sse/services/compression/engines/headroom/index.ts");
|
||||
const reconstructHeadroom = reconstructMod.reconstructHeadroom;
|
||||
|
||||
const restored = reconstructHeadroom(result.body);
|
||||
assert.deepEqual(restored, body);
|
||||
});
|
||||
|
||||
it("stats.savingsPercent ≥ 30 when compressing a 20-row array", async () => {
|
||||
const rows = makeRows(20);
|
||||
const body = makeBody(rows);
|
||||
|
||||
const result = headroomEngine.apply(body);
|
||||
assert.equal(result.compressed, true);
|
||||
assert.ok(result.stats !== null, "stats should not be null");
|
||||
assert.ok(
|
||||
result.stats!.savingsPercent >= 30,
|
||||
`Expected savingsPercent ≥ 30, got ${result.stats!.savingsPercent}`
|
||||
);
|
||||
});
|
||||
|
||||
it("compressed body contains the [N rows] count marker", async () => {
|
||||
const n = 22;
|
||||
const rows = makeRows(n);
|
||||
const body = makeBody(rows);
|
||||
|
||||
const result = headroomEngine.apply(body);
|
||||
assert.equal(result.compressed, true);
|
||||
|
||||
const bodyStr = JSON.stringify(result.body);
|
||||
assert.match(bodyStr, /\[22 rows\]/, "compressed body must contain [N rows] marker");
|
||||
});
|
||||
|
||||
it("also compresses when the array is inside a ```json fence in message content", async () => {
|
||||
const rows = makeRows(20);
|
||||
const body = makeBody(rows, { asJsonFence: true });
|
||||
|
||||
const result = headroomEngine.apply(body);
|
||||
assert.equal(result.compressed, true);
|
||||
const origLen = JSON.stringify(body).length;
|
||||
const compLen = JSON.stringify(result.body).length;
|
||||
const ratio = (origLen - compLen) / origLen;
|
||||
assert.ok(
|
||||
ratio >= 0.3,
|
||||
`Expected ≥30% savings from fenced block, got ${(ratio * 100).toFixed(1)}%`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. Conservative guards — nested/flat should NOT regress ─────────────────
|
||||
|
||||
describe("headroomEngine.apply — conservative guards (no regression)", () => {
|
||||
it("does NOT compress a heterogeneous array (objects with different key sets)", async () => {
|
||||
// Objects have different keys — not homogeneous
|
||||
const rows: Record<string, unknown>[] = [
|
||||
...Array.from({ length: 10 }, (_, i) => ({ id: i, name: `n${i}` })),
|
||||
...Array.from({ length: 10 }, (_, i) => ({ key: i, label: `l${i}`, extra: true })),
|
||||
];
|
||||
const body = makeBody(rows);
|
||||
const result = headroomEngine.apply(body);
|
||||
assert.equal(result.compressed, false, "heterogeneous array should NOT be compressed");
|
||||
assert.deepEqual(result.body, body);
|
||||
});
|
||||
|
||||
it("does NOT compress a tiny array below minRows (< default 8)", async () => {
|
||||
const rows = makeRows(5);
|
||||
const body = makeBody(rows);
|
||||
const result = headroomEngine.apply(body);
|
||||
assert.equal(result.compressed, false, "tiny array should NOT be compressed");
|
||||
assert.deepEqual(result.body, body);
|
||||
});
|
||||
|
||||
it("does NOT compress system messages", async () => {
|
||||
const rows = makeRows(20);
|
||||
const body = {
|
||||
model: "test-model",
|
||||
messages: [
|
||||
{ role: "system", content: JSON.stringify(rows) },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
const result = headroomEngine.apply(body);
|
||||
// The system message should be untouched regardless of outcome
|
||||
const resultMsgs = result.body["messages"] as Array<Record<string, unknown>>;
|
||||
const systemMsg = resultMsgs[0];
|
||||
assert.equal(systemMsg["content"], JSON.stringify(rows), "system message must be untouched");
|
||||
});
|
||||
|
||||
it("does NOT compress non-array JSON content (plain object)", async () => {
|
||||
const content = JSON.stringify({ key: "value", nested: { a: 1 } });
|
||||
const body = {
|
||||
model: "test-model",
|
||||
messages: [{ role: "user", content }],
|
||||
};
|
||||
const result = headroomEngine.apply(body);
|
||||
assert.equal(result.compressed, false, "plain object JSON should NOT be compressed");
|
||||
assert.deepEqual(result.body, body);
|
||||
});
|
||||
|
||||
it("does NOT compress when tabular form would not be smaller (already short rows)", async () => {
|
||||
// Very short rows with single-char values — tabular overhead won't save space
|
||||
const rows: Record<string, unknown>[] = Array.from({ length: 8 }, (_, i) => ({ a: i }));
|
||||
const body = makeBody(rows);
|
||||
const result = headroomEngine.apply(body);
|
||||
// This might or might not compress depending on actual sizes; key requirement is
|
||||
// compressed:false when tabular is NOT smaller, and body unchanged in that case.
|
||||
if (!result.compressed) {
|
||||
assert.deepEqual(result.body, body, "if not compressed, body must be unchanged");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. Registry ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("engine registry", () => {
|
||||
it('getCompressionEngine("headroom") returns the headroom engine after registration', async () => {
|
||||
registerBuiltinCompressionEngines();
|
||||
const engine = getCompressionEngine("headroom");
|
||||
assert.ok(engine !== null, "headroom engine must be registered");
|
||||
assert.equal(engine!.id, "headroom");
|
||||
});
|
||||
|
||||
it("headroomEngine has stackable:true and a sensible stackPriority", async () => {
|
||||
assert.equal(headroomEngine.stackable, true);
|
||||
// stackPriority 15 = between rtk(10) and caveman(20)
|
||||
assert.equal(typeof headroomEngine.stackPriority, "number");
|
||||
assert.ok(headroomEngine.stackPriority > 0);
|
||||
});
|
||||
|
||||
it("getConfigSchema returns a non-empty array", async () => {
|
||||
const schema = headroomEngine.getConfigSchema();
|
||||
assert.ok(Array.isArray(schema) && schema.length > 0, "schema must have at least one field");
|
||||
});
|
||||
|
||||
it("validateConfig accepts an empty config", async () => {
|
||||
const result = headroomEngine.validateConfig({});
|
||||
assert.equal(result.valid, true);
|
||||
});
|
||||
|
||||
it("compress delegates to apply", async () => {
|
||||
const rows = makeRows(20);
|
||||
const body = makeBody(rows);
|
||||
const r1 = headroomEngine.apply(body);
|
||||
const r2 = headroomEngine.compress(body);
|
||||
assert.equal(r1.compressed, r2.compressed);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. Losslessness invariant on mixed-type / nullable columns (regression) ──
|
||||
// The decoder applies ONE kind per column (derived from row 0). A column that is
|
||||
// key-present but type-heterogeneous across rows (e.g. nullable, or mixed
|
||||
// number/string) would corrupt the round-trip unless such arrays are left
|
||||
// untouched. This invariant must hold whether the engine compacts or skips.
|
||||
describe("headroomEngine — losslessness on mixed-type columns (regression)", () => {
|
||||
async function reconstruct(body: Record<string, unknown>) {
|
||||
const mod = await import("../../../open-sse/services/compression/engines/headroom/index.ts");
|
||||
return mod.reconstructHeadroom(body);
|
||||
}
|
||||
|
||||
it("never loses data on a nullable numeric column (null in some rows, number in others)", async () => {
|
||||
const rows = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
score: i % 3 === 0 ? null : (i + 1) * 7,
|
||||
name: `row-${i + 1}`,
|
||||
}));
|
||||
const body = makeBody(rows);
|
||||
const result = headroomEngine.apply(body);
|
||||
const restored = await reconstruct(result.body);
|
||||
assert.deepEqual(restored, body, "nullable column must round-trip without data loss");
|
||||
});
|
||||
|
||||
it("never loses data on a column with mixed number/string values", async () => {
|
||||
const rows = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
mixed: i % 2 === 0 ? i : `str-${i}`,
|
||||
}));
|
||||
const body = makeBody(rows);
|
||||
const result = headroomEngine.apply(body);
|
||||
const restored = await reconstruct(result.body);
|
||||
assert.deepEqual(restored, body, "mixed-type column must round-trip without data loss");
|
||||
});
|
||||
});
|
||||
177
tests/unit/compression/llmlingua-failopen.test.ts
Normal file
177
tests/unit/compression/llmlingua-failopen.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* TDD tests for the llmlingua async compression engine (L1/L3 — F2.1).
|
||||
*
|
||||
* Tests are written RED-first (before the implementation exists).
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Happy path: fake backend compresses prose → body shorter, compressed:true, stats present.
|
||||
* 2. Fail-open: backend throws → original body returned unchanged, compressed:false, no throw.
|
||||
* 3. Code-block protection: fenced code block survives verbatim; backend never receives code.
|
||||
* 4. System messages are never compressed.
|
||||
* 5. Sync `apply` is a pass-through (compressed:false, body unchanged).
|
||||
*/
|
||||
|
||||
import { describe, it, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
llmlinguaEngine,
|
||||
setLlmlinguaBackend,
|
||||
} from "../../../open-sse/services/compression/engines/llmlingua/index.ts";
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeBody(messages: Array<{ role: string; content: string }>): Record<string, unknown> {
|
||||
return { model: "gpt-4o", messages };
|
||||
}
|
||||
|
||||
/** Backend that replaces content with a single word to simulate compression. */
|
||||
function compressingBackend(text: string): Promise<string> {
|
||||
// Return a clearly shorter string so we can detect compression
|
||||
return Promise.resolve("COMPRESSED");
|
||||
}
|
||||
|
||||
/** Backend that always throws — simulates worker/model failure. */
|
||||
function throwingBackend(_text: string): Promise<string> {
|
||||
return Promise.reject(new Error("Model unavailable"));
|
||||
}
|
||||
|
||||
// ─── reset after suite ────────────────────────────────────────────────────────
|
||||
|
||||
after(() => {
|
||||
setLlmlinguaBackend(null);
|
||||
});
|
||||
|
||||
// ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("llmlingua engine", () => {
|
||||
it("id is 'llmlingua'", () => {
|
||||
assert.equal(llmlinguaEngine.id, "llmlingua");
|
||||
});
|
||||
|
||||
it("is stackable with a numeric stackPriority", () => {
|
||||
assert.equal(llmlinguaEngine.stackable, true);
|
||||
assert.equal(typeof llmlinguaEngine.stackPriority, "number");
|
||||
});
|
||||
|
||||
// ── 5. sync apply is a pass-through ────────────────────────────────────────
|
||||
it("sync apply() is a pass-through — compressed:false, body unchanged", () => {
|
||||
const body = makeBody([
|
||||
{ role: "user", content: "Hello world, this is a long prose message." },
|
||||
]);
|
||||
const result = llmlinguaEngine.apply(body);
|
||||
assert.equal(result.compressed, false);
|
||||
assert.equal(result.stats, null);
|
||||
assert.deepEqual(result.body, body);
|
||||
});
|
||||
|
||||
// ── 1. happy path ───────────────────────────────────────────────────────────
|
||||
it("applyAsync compresses prose with a working backend → compressed:true, stats present", async () => {
|
||||
setLlmlinguaBackend(compressingBackend);
|
||||
|
||||
const originalContent =
|
||||
"The quick brown fox jumps over the lazy dog. " +
|
||||
"This is a sufficiently long prose paragraph to ensure compression is triggered.";
|
||||
const body = makeBody([{ role: "user", content: originalContent }]);
|
||||
|
||||
const result = await llmlinguaEngine.applyAsync!(body);
|
||||
|
||||
assert.equal(result.compressed, true, "should be marked compressed");
|
||||
assert.notEqual(result.stats, null, "stats should be present");
|
||||
assert.ok(result.stats!.savingsPercent > 0, "savings should be positive");
|
||||
|
||||
// The output body should be shorter than the input
|
||||
const outContent = (result.body.messages as Array<{ role: string; content: string }>)[0]!
|
||||
.content;
|
||||
assert.ok(
|
||||
outContent.length < originalContent.length,
|
||||
`output (${outContent.length}) should be shorter than input (${originalContent.length})`
|
||||
);
|
||||
});
|
||||
|
||||
// ── 2. fail-open on backend error ───────────────────────────────────────────
|
||||
it("applyAsync FAIL-OPENS when backend throws — returns original body, compressed:false, no throw", async () => {
|
||||
setLlmlinguaBackend(throwingBackend);
|
||||
|
||||
const body = makeBody([
|
||||
{ role: "user", content: "Prose that the model would compress if it were available." },
|
||||
]);
|
||||
|
||||
let result: Awaited<ReturnType<typeof llmlinguaEngine.applyAsync>>;
|
||||
try {
|
||||
result = await llmlinguaEngine.applyAsync!(body);
|
||||
} catch (err) {
|
||||
assert.fail(`applyAsync must not throw on backend error, but threw: ${err}`);
|
||||
}
|
||||
|
||||
assert.equal(result!.compressed, false, "compressed must be false on fail-open");
|
||||
assert.equal(result!.stats, null, "stats must be null on fail-open");
|
||||
assert.deepEqual(result!.body, body, "fail-open must return the original body unchanged");
|
||||
});
|
||||
|
||||
// ── 3. code-block protection ─────────────────────────────────────────────────
|
||||
it("code blocks are never passed to the backend and survive verbatim", async () => {
|
||||
const backendCalls: string[] = [];
|
||||
|
||||
setLlmlinguaBackend((text) => {
|
||||
backendCalls.push(text);
|
||||
return Promise.resolve("PROSE_COMPRESSED");
|
||||
});
|
||||
|
||||
const codeBlock = "```typescript\nconst x = 1;\nconst y = x + 2;\n```";
|
||||
const prose = "Here is some prose before the code block and also after it.";
|
||||
const content = `${prose}\n\n${codeBlock}\n\nMore prose follows the code block here.`;
|
||||
|
||||
const body = makeBody([{ role: "user", content }]);
|
||||
const result = await llmlinguaEngine.applyAsync!(body);
|
||||
|
||||
// Code block text must be byte-identical in the output
|
||||
const outContent = (result.body.messages as Array<{ role: string; content: string }>)[0]!
|
||||
.content;
|
||||
assert.ok(
|
||||
outContent.includes(codeBlock),
|
||||
`Code block must survive verbatim in output.\nOutput:\n${outContent}`
|
||||
);
|
||||
|
||||
// Backend must only have been called with prose segments, never with the code block
|
||||
for (const call of backendCalls) {
|
||||
assert.ok(
|
||||
!call.includes("```typescript"),
|
||||
`Backend must NOT receive code block content, but received:\n${call}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 4. system messages are never compressed ─────────────────────────────────
|
||||
it("system messages are never compressed", async () => {
|
||||
const systemCalls: string[] = [];
|
||||
|
||||
setLlmlinguaBackend((text) => {
|
||||
systemCalls.push(text);
|
||||
return Promise.resolve("COMPRESSED");
|
||||
});
|
||||
|
||||
const systemContent = "You are a helpful assistant. Follow these instructions carefully.";
|
||||
const userContent = "This is a user message with enough prose to potentially compress.";
|
||||
|
||||
const body = makeBody([
|
||||
{ role: "system", content: systemContent },
|
||||
{ role: "user", content: userContent },
|
||||
]);
|
||||
|
||||
const result = await llmlinguaEngine.applyAsync!(body);
|
||||
|
||||
const outMessages = result.body.messages as Array<{ role: string; content: string }>;
|
||||
const outSystem = outMessages.find((m) => m.role === "system")!;
|
||||
|
||||
assert.equal(outSystem.content, systemContent, "System message content must be unchanged");
|
||||
|
||||
// Backend must not have been called with system message content
|
||||
for (const call of systemCalls) {
|
||||
assert.ok(
|
||||
!call.includes(systemContent),
|
||||
`Backend must NOT be called with system message content`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
61
tests/unit/compression/rtk-comments.test.ts
Normal file
61
tests/unit/compression/rtk-comments.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { stripCode } from "../../../open-sse/services/compression/index.ts";
|
||||
|
||||
const OPTS = { removeComments: true, removeEmptyLines: false, collapseWhitespace: false };
|
||||
|
||||
describe("RTK code stripper — real comment removal (R1/N3)", () => {
|
||||
it("removes JS line and block comments when removeComments is enabled", () => {
|
||||
const out = stripCode("// header\nconst x = 1; /* inline */\nconst y = 2;", "javascript", OPTS);
|
||||
assert.ok(!out.text.includes("header"), "line comment not removed");
|
||||
assert.ok(!out.text.includes("inline"), "block comment not removed");
|
||||
assert.ok(out.text.includes("const x = 1;"), "code corrupted (x)");
|
||||
assert.ok(out.text.includes("const y = 2;"), "code corrupted (y)");
|
||||
});
|
||||
|
||||
it("preserves string content that looks like a comment", () => {
|
||||
const out = stripCode(
|
||||
'const url = "https://example.com/a//b"; // real comment',
|
||||
"javascript",
|
||||
OPTS
|
||||
);
|
||||
assert.ok(out.text.includes("https://example.com/a//b"), "URL inside string lost");
|
||||
assert.ok(!out.text.includes("real comment"), "trailing comment not removed");
|
||||
});
|
||||
|
||||
it("preserves regex literals containing slashes", () => {
|
||||
const out = stripCode("const re = /foo\\/\\/bar/g; // strip me", "typescript", OPTS);
|
||||
assert.ok(out.text.includes("/foo\\/\\/bar/g"), "regex literal corrupted");
|
||||
assert.ok(!out.text.includes("strip me"), "trailing comment not removed");
|
||||
});
|
||||
|
||||
it("preserves template literals containing comment-like text", () => {
|
||||
const out = stripCode(
|
||||
"const t = `a // not a comment /* nor this */ b`; // yes comment",
|
||||
"javascript",
|
||||
OPTS
|
||||
);
|
||||
assert.ok(
|
||||
out.text.includes("a // not a comment /* nor this */ b"),
|
||||
"template literal corrupted"
|
||||
);
|
||||
assert.ok(!out.text.includes("yes comment"), "trailing comment not removed");
|
||||
});
|
||||
|
||||
it("preserves JSX comments (bails out on JSX to avoid corruption)", () => {
|
||||
const out = stripCode("const el = <div>{/* keep me */}</div>;", "typescript", OPTS);
|
||||
assert.ok(out.text.includes("{/* keep me */}"), "JSX comment must be preserved");
|
||||
assert.ok(out.text.includes("<div>"), "JSX must be preserved");
|
||||
});
|
||||
|
||||
it("preserves comments by default (no behavioral change without opt-in)", () => {
|
||||
const out = stripCode("// keep\nconst x = 1;", "javascript");
|
||||
assert.ok(out.text.includes("// keep"), "default must preserve comments");
|
||||
});
|
||||
|
||||
it("does not touch non-JS/TS languages", () => {
|
||||
const py = stripCode("# py comment\nprint('ok')", "python", OPTS);
|
||||
assert.ok(py.text.includes("# py comment"), "python comment should be untouched by JS remover");
|
||||
});
|
||||
});
|
||||
28
tests/unit/compression/rtk-discover-redos.test.ts
Normal file
28
tests/unit/compression/rtk-discover-redos.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { discoverNormalizeLine } from "../../../open-sse/services/compression/engines/rtk/discover.ts";
|
||||
|
||||
describe("RTK discover — ReDoS safety (CLAUDE.md regex rule)", () => {
|
||||
it("normalizes a long word-char line with no '@' in bounded time", () => {
|
||||
// The package@version regex used to be /[\w][\w.-]*@.../ — catastrophic
|
||||
// backtracking on a long word-char run lacking '@'. Bounded quantifiers fix it.
|
||||
const pathological = "a".repeat(100_000); // 100k word chars, no '@'
|
||||
const start = performance.now();
|
||||
const out = discoverNormalizeLine(pathological);
|
||||
const elapsedMs = performance.now() - start;
|
||||
assert.ok(typeof out === "string");
|
||||
assert.ok(
|
||||
elapsedMs < 1000,
|
||||
`discoverNormalizeLine took ${elapsedMs.toFixed(0)}ms (ReDoS regression — must be < 1000ms)`
|
||||
);
|
||||
});
|
||||
|
||||
it("still normalizes real package@version templates", () => {
|
||||
assert.equal(discoverNormalizeLine("left-pad@1.2.3"), "<PKG>@<N>");
|
||||
assert.equal(
|
||||
discoverNormalizeLine("npm WARN deprecated foo@2.0.0: msg"),
|
||||
"npm WARN deprecated <PKG>@<N>: msg"
|
||||
);
|
||||
});
|
||||
});
|
||||
142
tests/unit/compression/rtk-grouping.test.ts
Normal file
142
tests/unit/compression/rtk-grouping.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
groupSimilarLines,
|
||||
type GroupingOptions,
|
||||
} from "../../../open-sse/services/compression/engines/rtk/grouper.ts";
|
||||
import { processRtkText } from "../../../open-sse/services/compression/index.ts";
|
||||
|
||||
describe("RTK grouping strategy (R5)", () => {
|
||||
// ── Unit: groupSimilarLines ───────────────────────────────────────────────
|
||||
|
||||
it("collapses N near-equivalent lines that differ only by numbers into one representative + count", () => {
|
||||
const lines = [
|
||||
"Downloaded chunk 1",
|
||||
"Downloaded chunk 2",
|
||||
"Downloaded chunk 3",
|
||||
"Downloaded chunk 4",
|
||||
"Downloaded chunk 5",
|
||||
"Downloaded chunk 6",
|
||||
];
|
||||
const result = groupSimilarLines(lines.join("\n"));
|
||||
|
||||
// Should have collapsed to fewer lines
|
||||
const outputLines = result.text.split("\n").filter((l) => l.trim());
|
||||
assert.ok(outputLines.length < lines.length, "grouped output should have fewer lines");
|
||||
|
||||
// Count N (6) should appear in the output
|
||||
assert.match(result.text, /6/, "count of grouped items must appear in output");
|
||||
|
||||
// grouped marker must be present
|
||||
assert.match(result.text, /\[rtk:grouped/i, "must emit rtk:grouped marker");
|
||||
|
||||
// grouped count must be collapsed (no line 2..6 variants)
|
||||
assert.ok(!result.text.includes("chunk 2"), "intermediate variants should be collapsed");
|
||||
assert.ok(result.grouped > 0, "grouped count should be > 0");
|
||||
});
|
||||
|
||||
it("preserves non-similar lines untouched", () => {
|
||||
const input = [
|
||||
"Downloading package 1",
|
||||
"Downloading package 2",
|
||||
"Downloading package 3",
|
||||
"Build succeeded",
|
||||
"Tests passed: 42",
|
||||
].join("\n");
|
||||
|
||||
const result = groupSimilarLines(input);
|
||||
|
||||
assert.ok(result.text.includes("Build succeeded"), "unique line must survive");
|
||||
assert.ok(result.text.includes("Tests passed: 42"), "unique line must survive");
|
||||
});
|
||||
|
||||
it("does not group when run-length is below threshold (default 3)", () => {
|
||||
const input = ["Fetching step 1", "Fetching step 2"].join("\n");
|
||||
const result = groupSimilarLines(input);
|
||||
|
||||
// Below default threshold of 3 — should NOT be grouped
|
||||
assert.equal(result.grouped, 0);
|
||||
assert.ok(result.text.includes("Fetching step 1"));
|
||||
assert.ok(result.text.includes("Fetching step 2"));
|
||||
});
|
||||
|
||||
it("respects a custom threshold option", () => {
|
||||
const input = ["Item 1", "Item 2"].join("\n");
|
||||
const resultDefault = groupSimilarLines(input);
|
||||
const resultLow = groupSimilarLines(input, { threshold: 2 });
|
||||
|
||||
assert.equal(resultDefault.grouped, 0, "default threshold=3 should not group 2 lines");
|
||||
assert.ok(resultLow.grouped > 0, "threshold=2 should group 2 similar lines");
|
||||
});
|
||||
|
||||
it("groups lines that differ by hex ids (volatile bits)", () => {
|
||||
const lines = [
|
||||
"Processing task a1b2c3d4",
|
||||
"Processing task e5f6a7b8",
|
||||
"Processing task c9d0e1f2",
|
||||
];
|
||||
const result = groupSimilarLines(lines.join("\n"), { threshold: 3 });
|
||||
|
||||
assert.ok(result.grouped > 0, "hex-id lines should be grouped");
|
||||
assert.match(result.text, /\[rtk:grouped/i);
|
||||
});
|
||||
|
||||
it("groups lines that differ by timestamps (volatile bits)", () => {
|
||||
const lines = [
|
||||
"[2024-01-01 10:00:00] Heartbeat received",
|
||||
"[2024-01-01 10:00:05] Heartbeat received",
|
||||
"[2024-01-01 10:00:10] Heartbeat received",
|
||||
];
|
||||
const result = groupSimilarLines(lines.join("\n"), { threshold: 3 });
|
||||
|
||||
assert.ok(result.grouped > 0, "timestamp-prefixed lines should be grouped");
|
||||
assert.match(result.text, /\[rtk:grouped/i);
|
||||
});
|
||||
|
||||
// ── Integration: processRtkText with enableGrouping flag ─────────────────
|
||||
|
||||
it("processRtkText emits rtk-grouping in techniquesUsed when enableGrouping=true", () => {
|
||||
const lines = Array.from({ length: 6 }, (_, i) => `Downloaded chunk ${i + 1}`);
|
||||
const result = processRtkText(lines.join("\n"), {
|
||||
config: {
|
||||
enabled: true,
|
||||
enableGrouping: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
result.techniquesUsed.includes("rtk-grouping"),
|
||||
`techniquesUsed must include 'rtk-grouping', got: ${result.techniquesUsed.join(", ")}`
|
||||
);
|
||||
assert.ok(
|
||||
result.rulesApplied.some((r) => r.startsWith("rtk:group")),
|
||||
`rulesApplied must include rtk:group* rule, got: ${result.rulesApplied.join(", ")}`
|
||||
);
|
||||
});
|
||||
|
||||
it("processRtkText does NOT apply grouping when enableGrouping is false (default)", () => {
|
||||
const lines = Array.from({ length: 6 }, (_, i) => `Downloaded chunk ${i + 1}`);
|
||||
const result = processRtkText(lines.join("\n"), {
|
||||
config: { enabled: true },
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
!result.techniquesUsed.includes("rtk-grouping"),
|
||||
"grouping must be OFF by default to preserve existing behaviour"
|
||||
);
|
||||
});
|
||||
|
||||
it("grouped output is shorter than original input", () => {
|
||||
const lines = Array.from({ length: 8 }, (_, i) => `Checking dependency v${i + 1}.0.0`);
|
||||
const input = lines.join("\n");
|
||||
const result = processRtkText(input, {
|
||||
config: { enabled: true, enableGrouping: true },
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
result.text.length < input.length,
|
||||
"grouped output must be shorter than the original"
|
||||
);
|
||||
});
|
||||
});
|
||||
218
tests/unit/compression/rtk-learn.test.ts
Normal file
218
tests/unit/compression/rtk-learn.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* TDD tests for RTK filter learning (R6/R7/N7).
|
||||
*
|
||||
* Scope: pure functions operating on in-memory CommandSample arrays.
|
||||
* No DB reads, no I/O. DB wiring (reading from call_logs) is a follow-up task.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { discoverRepeatedNoise } from "../../../open-sse/services/compression/engines/rtk/discover.ts";
|
||||
import { suggestFilter } from "../../../open-sse/services/compression/engines/rtk/learn.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic test fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build 10 `npm install` output samples.
|
||||
*
|
||||
* Each sample has:
|
||||
* - A noisy deprecated-warning line whose package name and version vary.
|
||||
* - A unique summary line indicating how many packages were added.
|
||||
* - An error line with ERR!/E404.
|
||||
* - A line that appears only in one sample (truly unique — should NOT be suggested).
|
||||
*/
|
||||
function makeSamples() {
|
||||
const packages = [
|
||||
["left-pad", "1.3.0"],
|
||||
["moment", "2.29.4"],
|
||||
["lodash", "4.17.21"],
|
||||
["underscore", "1.13.6"],
|
||||
["request", "2.88.2"],
|
||||
["express", "4.18.2"],
|
||||
["debug", "4.3.4"],
|
||||
["chalk", "5.2.0"],
|
||||
["semver", "7.5.1"],
|
||||
["bluebird", "3.7.2"],
|
||||
];
|
||||
|
||||
return packages.map(([pkg, ver], i) => ({
|
||||
command: "npm install",
|
||||
output: [
|
||||
`npm WARN deprecated ${pkg}@${ver}: Use a newer version`,
|
||||
`added ${100 + i * 3} packages in ${i + 1}s`,
|
||||
`npm ERR! code E404`,
|
||||
`npm ERR! 404 Not Found - GET https://registry.npmjs.org/${pkg}`,
|
||||
// One truly unique line appears only in sample index 3
|
||||
...(i === 3 ? ["unique line only in sample 3"] : []),
|
||||
].join("\n"),
|
||||
}));
|
||||
}
|
||||
|
||||
const samples = makeSamples();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// discoverRepeatedNoise
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("discoverRepeatedNoise", () => {
|
||||
it("surfaces the deprecated-warning template as a high-hit candidate", () => {
|
||||
const hits = discoverRepeatedNoise(samples);
|
||||
// The normalised form of 'npm WARN deprecated <pkg>@<ver>: Use a newer version'
|
||||
// should appear with hits === samples.length (10).
|
||||
const deprecatedCandidate = hits.find((h) => h.pattern.includes("WARN deprecated"));
|
||||
assert.ok(
|
||||
deprecatedCandidate !== undefined,
|
||||
"should surface a deprecated-warning drop candidate"
|
||||
);
|
||||
assert.ok(
|
||||
deprecatedCandidate.hits >= samples.length,
|
||||
`expected hits >= ${samples.length}, got ${deprecatedCandidate.hits}`
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT surface a line that appears only once as a drop candidate", () => {
|
||||
const hits = discoverRepeatedNoise(samples);
|
||||
const uniqueCandidate = hits.find((h) => h.pattern.includes("unique line only in sample"));
|
||||
assert.equal(uniqueCandidate, undefined, "unique lines must not appear as drop candidates");
|
||||
});
|
||||
|
||||
it("returns candidates sorted descending by hits", () => {
|
||||
const hits = discoverRepeatedNoise(samples);
|
||||
for (let i = 1; i < hits.length; i++) {
|
||||
assert.ok(hits[i].hits <= hits[i - 1].hits, "results should be sorted descending by hits");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty array for an empty sample set", () => {
|
||||
const hits = discoverRepeatedNoise([]);
|
||||
assert.deepEqual(hits, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// suggestFilter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("suggestFilter", () => {
|
||||
it("returns a filter whose match.commands pattern anchors the command", () => {
|
||||
const filter = suggestFilter("npm install", samples);
|
||||
assert.ok(filter.match.commands.length > 0, "should have at least one command pattern");
|
||||
const pattern = filter.match.commands[0];
|
||||
assert.ok(
|
||||
new RegExp(pattern).test("npm install"),
|
||||
`command pattern '${pattern}' should match 'npm install'`
|
||||
);
|
||||
});
|
||||
|
||||
it("includes a dropPattern that matches the deprecated warnings", () => {
|
||||
const filter = suggestFilter("npm install", samples);
|
||||
const hasDeprecatedDrop = filter.rules.dropPatterns.some((p) => {
|
||||
try {
|
||||
return new RegExp(p, "i").test("npm WARN deprecated left-pad@1.3.0: Use a newer version");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
assert.ok(hasDeprecatedDrop, "dropPatterns should cover deprecated warnings");
|
||||
});
|
||||
|
||||
it("includes an errorPattern that covers the ERR!/E404 line", () => {
|
||||
const filter = suggestFilter("npm install", samples);
|
||||
const errLine = "npm ERR! code E404";
|
||||
const hasErrorPattern = filter.preserve.errorPatterns.some((p) => {
|
||||
try {
|
||||
return new RegExp(p, "i").test(errLine);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
assert.ok(hasErrorPattern, `errorPatterns should cover '${errLine}'`);
|
||||
});
|
||||
|
||||
it("includes a summaryPattern that covers the 'added N packages' line", () => {
|
||||
const filter = suggestFilter("npm install", samples);
|
||||
const summaryLine = "added 120 packages in 5s";
|
||||
const hasSummaryPattern = filter.preserve.summaryPatterns.some((p) => {
|
||||
try {
|
||||
return new RegExp(p, "i").test(summaryLine);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
assert.ok(hasSummaryPattern, `summaryPatterns should cover '${summaryLine}'`);
|
||||
});
|
||||
|
||||
it("no dropPattern matches a preserved error line (no preserve-vs-drop conflict)", () => {
|
||||
const filter = suggestFilter("npm install", samples);
|
||||
const preservedLines = [
|
||||
"npm ERR! code E404",
|
||||
"npm ERR! 404 Not Found - GET https://registry.npmjs.org/left-pad",
|
||||
"added 120 packages in 5s",
|
||||
];
|
||||
const allPreservePatterns = [
|
||||
...filter.preserve.errorPatterns,
|
||||
...filter.preserve.summaryPatterns,
|
||||
];
|
||||
|
||||
for (const line of preservedLines) {
|
||||
const matchedByPreserve = allPreservePatterns.some((p) => {
|
||||
try {
|
||||
return new RegExp(p, "i").test(line);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!matchedByPreserve) continue; // line not in preserve scope — skip conflict check
|
||||
|
||||
const droppedByDrop = filter.rules.dropPatterns.some((p) => {
|
||||
try {
|
||||
return new RegExp(p, "i").test(line);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
assert.equal(
|
||||
droppedByDrop,
|
||||
false,
|
||||
`preserved line '${line}' must NOT be matched by a dropPattern`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("unique line is NOT in dropPatterns", () => {
|
||||
const filter = suggestFilter("npm install", samples);
|
||||
const uniqueLine = "unique line only in sample 3";
|
||||
const droppedByDrop = filter.rules.dropPatterns.some((p) => {
|
||||
try {
|
||||
return new RegExp(p, "i").test(uniqueLine);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
assert.equal(droppedByDrop, false, "unique lines must not appear in dropPatterns");
|
||||
});
|
||||
|
||||
it("returns a filter with the expected RtkFilterPack shape", () => {
|
||||
const filter = suggestFilter("npm install", samples);
|
||||
// Check required top-level shape fields
|
||||
assert.equal(typeof filter.id, "string");
|
||||
assert.equal(typeof filter.label, "string");
|
||||
assert.ok(filter.id.length > 0);
|
||||
assert.ok(Array.isArray(filter.match.commands));
|
||||
assert.ok(Array.isArray(filter.match.patterns));
|
||||
assert.ok(Array.isArray(filter.match.outputTypes));
|
||||
assert.ok(Array.isArray(filter.rules.dropPatterns));
|
||||
assert.ok(Array.isArray(filter.preserve.errorPatterns));
|
||||
assert.ok(Array.isArray(filter.preserve.summaryPatterns));
|
||||
assert.equal(typeof filter.rules.stripAnsi, "boolean");
|
||||
});
|
||||
|
||||
it("returns an empty-patterns filter for an empty sample set without throwing", () => {
|
||||
const filter = suggestFilter("unknown-command", []);
|
||||
assert.equal(filter.rules.dropPatterns.length, 0);
|
||||
assert.ok(Array.isArray(filter.preserve.errorPatterns));
|
||||
assert.ok(Array.isArray(filter.preserve.summaryPatterns));
|
||||
});
|
||||
});
|
||||
88
tests/unit/compression/rtk-truncate-preserve.test.ts
Normal file
88
tests/unit/compression/rtk-truncate-preserve.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { processRtkText } from "../../../open-sse/services/compression/engines/rtk/index.ts";
|
||||
|
||||
// The make filter (filters/make.json) declares:
|
||||
// preserve.summaryPatterns: ["built", "linking"]
|
||||
// preserve.errorPatterns: ["error:", "failed", "***"]
|
||||
//
|
||||
// Its dropPatterns strip only "entering/leaving directory", empty lines, and
|
||||
// "Nothing to be done". It has NO includePatterns (keepPatterns), so lines
|
||||
// are NOT narrowed down by a keep-only pass — bulk filler survives the filter.
|
||||
//
|
||||
// This lets us craft content where, after applyLineFilter, there are still
|
||||
// more lines than maxLinesPerResult, so the second smartTruncate in
|
||||
// processRtkText fires. The summary / error lines land in the MIDDLE, so
|
||||
// they are outside the preserved head/tail and get dropped — unless the fix
|
||||
// propagates filter.priorityPatterns into that smartTruncate call.
|
||||
|
||||
// Hardcoded default pattern in processRtkText:
|
||||
// /error|failed|exception|traceback|TS\d{4}|FAIL|✖/i
|
||||
//
|
||||
// "linking" does NOT match the hardcoded pattern → good test for summaryPatterns.
|
||||
// "***" alone (without "Error"/"failed") does NOT match → good test for
|
||||
// errorPatterns. We use "*** [make-sentinel]" with no error-family words.
|
||||
|
||||
describe("RTK truncate – filter preserve patterns propagate to smartTruncate", () => {
|
||||
it("summary line declared in filter preserve.summaryPatterns survives truncation", () => {
|
||||
// 50 filler lines that don't match make's collapsePatterns (^cc|gcc|clang|g\+\+)
|
||||
const filler = Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => `ld -rpath module${i}.o -o module${i}`
|
||||
).join("\n");
|
||||
// "linking" matches make's summaryPatterns but NOT the hardcoded defaults.
|
||||
const summaryLine = "linking all objects into the final binary";
|
||||
const moreFiller = Array.from({ length: 50 }, (_, i) => `ranlib libfoo${i}.a`).join("\n");
|
||||
|
||||
// make filter matches on command "^make\\b"
|
||||
const input = [filler, summaryLine, moreFiller].join("\n");
|
||||
|
||||
// maxLinesPerResult=30 forces smartTruncate to fire after the filter
|
||||
const result = processRtkText(input, {
|
||||
command: "make all",
|
||||
config: { maxLinesPerResult: 30, maxCharsPerResult: 0, applyToCodeBlocks: false },
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
result.techniquesUsed.includes("rtk-truncate"),
|
||||
`expected rtk-truncate; techniquesUsed: ${result.techniquesUsed.join(", ")}`
|
||||
);
|
||||
|
||||
// After the fix, "linking" must survive because the make filter's
|
||||
// preserve.summaryPatterns includes "linking".
|
||||
assert.ok(
|
||||
result.text.includes("linking"),
|
||||
`summary line was truncated away; got:\n${result.text}`
|
||||
);
|
||||
});
|
||||
|
||||
it("error sentinel declared in filter preserve.errorPatterns survives truncation", () => {
|
||||
const filler = Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => `ld -rpath module${i}.o -o module${i}`
|
||||
).join("\n");
|
||||
// "***" matches make's errorPatterns but NOT the default hardcoded pattern
|
||||
// (/error|failed|exception|traceback|TS\d{4}|FAIL|✖/i) — no "error"/"failed".
|
||||
const errorLine = "make: *** [Makefile:10: all] compilation-sentinel-37f4";
|
||||
const moreFiller = Array.from({ length: 50 }, (_, i) => `ranlib libfoo${i}.a`).join("\n");
|
||||
|
||||
const input = [filler, errorLine, moreFiller].join("\n");
|
||||
|
||||
const result = processRtkText(input, {
|
||||
command: "make all",
|
||||
config: { maxLinesPerResult: 30, maxCharsPerResult: 0, applyToCodeBlocks: false },
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
result.techniquesUsed.includes("rtk-truncate"),
|
||||
`expected rtk-truncate; techniquesUsed: ${result.techniquesUsed.join(", ")}`
|
||||
);
|
||||
|
||||
// "***" is in make filter's errorPatterns — must survive after the fix.
|
||||
assert.ok(
|
||||
result.text.includes("compilation-sentinel-37f4"),
|
||||
`error sentinel line was truncated away; got:\n${result.text}`
|
||||
);
|
||||
});
|
||||
});
|
||||
184
tests/unit/compression/session-dedup.test.ts
Normal file
184
tests/unit/compression/session-dedup.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* TDD tests for the session-dedup compression engine (R11/N2/TO1).
|
||||
* Run: node --import tsx/esm --test tests/unit/compression/session-dedup.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, before } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
sessionDedupEngine,
|
||||
reconstructSessionDedup,
|
||||
} from "../../../open-sse/services/compression/engines/session-dedup/index.ts";
|
||||
import {
|
||||
registerBuiltinCompressionEngines,
|
||||
getCompressionEngine,
|
||||
} from "../../../open-sse/services/compression/index.ts";
|
||||
|
||||
const REPEATED_BLOCK = `function expensiveCalc(x) {
|
||||
// step 1
|
||||
const a = x * 2;
|
||||
// step 2
|
||||
const b = a + 100;
|
||||
// step 3
|
||||
return b;
|
||||
}`;
|
||||
|
||||
function makeBody(messages: Array<{ role: string; content: string }>) {
|
||||
return { model: "gpt-4", messages };
|
||||
}
|
||||
|
||||
describe("session-dedup engine", () => {
|
||||
before(() => {
|
||||
registerBuiltinCompressionEngines();
|
||||
});
|
||||
|
||||
it("is registered and retrievable by id", () => {
|
||||
const engine = getCompressionEngine("session-dedup");
|
||||
assert.ok(engine, "getCompressionEngine('session-dedup') must return the engine");
|
||||
assert.equal(engine.id, "session-dedup");
|
||||
assert.equal(typeof engine.apply, "function");
|
||||
assert.equal(typeof engine.compress, "function");
|
||||
assert.equal(typeof engine.getConfigSchema, "function");
|
||||
assert.equal(typeof engine.validateConfig, "function");
|
||||
assert.equal(engine.stackable, true);
|
||||
assert.ok(typeof engine.stackPriority === "number");
|
||||
});
|
||||
|
||||
it("deduplicates a block appearing verbatim in turn 1 and turn 3", () => {
|
||||
const body = makeBody([
|
||||
{ role: "user", content: `Here is the code:\n${REPEATED_BLOCK}` },
|
||||
{ role: "assistant", content: "I understand the code." },
|
||||
{ role: "user", content: `Please review again:\n${REPEATED_BLOCK}` },
|
||||
]);
|
||||
|
||||
const result = sessionDedupEngine.apply(body as Record<string, unknown>);
|
||||
|
||||
assert.equal(result.compressed, true, "should report compressed=true when dedup happened");
|
||||
|
||||
const messages = result.body.messages as Array<{ role: string; content: string }>;
|
||||
|
||||
// Turn 0 (index 0): first occurrence — must keep the block intact
|
||||
assert.ok(
|
||||
messages[0].content.includes(REPEATED_BLOCK),
|
||||
"first occurrence must remain intact in turn 0"
|
||||
);
|
||||
|
||||
// Turn 2 (index 2): duplicate — must NOT contain the raw repeated block text
|
||||
assert.ok(
|
||||
!messages[2].content.includes(REPEATED_BLOCK),
|
||||
"duplicate block must be removed from turn 2"
|
||||
);
|
||||
|
||||
// Turn 2: must contain a reference marker
|
||||
assert.match(
|
||||
messages[2].content,
|
||||
/\[dedup:ref sha=[0-9a-f]{24}\]/,
|
||||
"turn 2 must contain a [dedup:ref sha=<24hex>] marker"
|
||||
);
|
||||
|
||||
// Output must be shorter than input
|
||||
const inputLen = JSON.stringify(body).length;
|
||||
const outputLen = JSON.stringify(result.body).length;
|
||||
assert.ok(outputLen < inputLen, "output must be shorter than input");
|
||||
|
||||
// Stats must be present
|
||||
assert.ok(result.stats !== null, "stats must be present");
|
||||
assert.ok(result.stats!.originalTokens > 0);
|
||||
assert.ok(result.stats!.compressedTokens < result.stats!.originalTokens);
|
||||
});
|
||||
|
||||
it("round-trip: reconstructSessionDedup restores original body exactly", () => {
|
||||
const body = makeBody([
|
||||
{ role: "user", content: `Here is the code:\n${REPEATED_BLOCK}` },
|
||||
{ role: "assistant", content: "I understand the code." },
|
||||
{ role: "user", content: `Please review again:\n${REPEATED_BLOCK}` },
|
||||
]);
|
||||
|
||||
const result = sessionDedupEngine.apply(body as Record<string, unknown>);
|
||||
assert.equal(result.compressed, true);
|
||||
|
||||
const restored = reconstructSessionDedup(result.body);
|
||||
|
||||
// Deep-equal to original
|
||||
assert.deepEqual(
|
||||
restored.messages,
|
||||
body.messages,
|
||||
"reconstructed body must deep-equal original"
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT dedup small/unique blocks (no false positives)", () => {
|
||||
const body = makeBody([
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "hello" },
|
||||
{ role: "user", content: "bye" },
|
||||
]);
|
||||
|
||||
const result = sessionDedupEngine.apply(body as Record<string, unknown>);
|
||||
|
||||
assert.equal(result.compressed, false, "must not dedup tiny/unique messages");
|
||||
|
||||
const messages = result.body.messages as Array<{ role: string; content: string }>;
|
||||
assert.equal(messages[0].content, "hi");
|
||||
assert.equal(messages[1].content, "hello");
|
||||
assert.equal(messages[2].content, "bye");
|
||||
});
|
||||
|
||||
it("never deduplicates the system prompt", () => {
|
||||
const body = makeBody([
|
||||
{ role: "system", content: REPEATED_BLOCK },
|
||||
{ role: "user", content: REPEATED_BLOCK },
|
||||
{ role: "assistant", content: "ok" },
|
||||
]);
|
||||
|
||||
const result = sessionDedupEngine.apply(body as Record<string, unknown>);
|
||||
|
||||
const messages = result.body.messages as Array<{ role: string; content: string }>;
|
||||
// System prompt must always remain intact regardless
|
||||
assert.ok(messages[0].content.includes(REPEATED_BLOCK), "system prompt must never be touched");
|
||||
});
|
||||
|
||||
it("does not corrupt multipart (non-string) content items", () => {
|
||||
const multipartBody = {
|
||||
model: "gpt-4",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: REPEATED_BLOCK },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: "ok" },
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: REPEATED_BLOCK }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Must not throw and must not corrupt image parts
|
||||
const result = sessionDedupEngine.apply(multipartBody as Record<string, unknown>);
|
||||
const messages = result.body.messages as Array<{ role: string; content: unknown }>;
|
||||
const firstContent = messages[0].content as Array<{ type: string; image_url?: unknown }>;
|
||||
const imageItem = firstContent.find((c) => c.type === "image_url");
|
||||
assert.ok(imageItem, "image_url item must still be present");
|
||||
assert.deepEqual(imageItem!.image_url, { url: "data:image/png;base64,abc" });
|
||||
});
|
||||
|
||||
it("getConfigSchema returns an array with expected fields", () => {
|
||||
const schema = sessionDedupEngine.getConfigSchema();
|
||||
assert.ok(Array.isArray(schema));
|
||||
const keys = schema.map((f) => f.key);
|
||||
assert.ok(keys.includes("minBlockChars"), "schema must include minBlockChars");
|
||||
assert.ok(keys.includes("enabled"), "schema must include enabled");
|
||||
});
|
||||
|
||||
it("validateConfig accepts valid config and rejects invalid", () => {
|
||||
assert.equal(sessionDedupEngine.validateConfig({}).valid, true);
|
||||
assert.equal(sessionDedupEngine.validateConfig({ minBlockChars: 50 }).valid, true);
|
||||
assert.equal(sessionDedupEngine.validateConfig({ minBlockChars: -1 }).valid, false);
|
||||
assert.equal(sessionDedupEngine.validateConfig({ enabled: "yes" }).valid, false);
|
||||
});
|
||||
});
|
||||
159
tests/unit/compression/stacked-async.test.ts
Normal file
159
tests/unit/compression/stacked-async.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
applyStackedCompression,
|
||||
applyStackedCompressionAsync,
|
||||
} from "../../../open-sse/services/compression/index.ts";
|
||||
import {
|
||||
registerCompressionEngine,
|
||||
unregisterCompressionEngine,
|
||||
} from "../../../open-sse/services/compression/engines/registry.ts";
|
||||
import type {
|
||||
CompressionEngine,
|
||||
CompressionEngineTarget,
|
||||
} from "../../../open-sse/services/compression/engines/types.ts";
|
||||
import type {
|
||||
CompressionPipelineStep,
|
||||
CompressionResult,
|
||||
} from "../../../open-sse/services/compression/types.ts";
|
||||
|
||||
const FS = "fake-sync-engine";
|
||||
const FA = "fake-async-engine";
|
||||
|
||||
/**
|
||||
* Transform that tags every user message content with `|<id>` so the test can
|
||||
* assert both that the engine ran and the order in which engines ran.
|
||||
*/
|
||||
function tag(id: string, body: Record<string, unknown>): CompressionResult {
|
||||
const messages = (body.messages as Array<{ role: string; content: string }>) ?? [];
|
||||
const next = messages.map((m) =>
|
||||
m.role === "user" ? { ...m, content: `${m.content}|${id}` } : m
|
||||
);
|
||||
return {
|
||||
body: { ...body, messages: next },
|
||||
compressed: true,
|
||||
stats: {
|
||||
originalTokens: 10,
|
||||
compressedTokens: 9,
|
||||
savingsPercent: 10,
|
||||
techniquesUsed: [id],
|
||||
mode: "stacked",
|
||||
timestamp: 0,
|
||||
durationMs: 0.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeEngine(id: string, opts: { async?: boolean } = {}): CompressionEngine {
|
||||
const base: CompressionEngine = {
|
||||
id,
|
||||
name: id,
|
||||
description: id,
|
||||
icon: "x",
|
||||
targets: ["messages"] as CompressionEngineTarget[],
|
||||
stackable: true,
|
||||
stackPriority: 0,
|
||||
metadata: {
|
||||
id,
|
||||
name: id,
|
||||
description: id,
|
||||
inputScope: "messages",
|
||||
targetLatencyMs: 1,
|
||||
supportsPreview: false,
|
||||
stable: true,
|
||||
},
|
||||
// Async-only engines provide a graceful sync pass-through so the legacy
|
||||
// sync stacked path never crashes and simply skips the async-only work.
|
||||
apply: opts.async
|
||||
? (body) => ({ body, compressed: false, stats: null })
|
||||
: (body) => tag(id, body),
|
||||
compress: (body) => tag(id, body),
|
||||
getConfigSchema: () => [],
|
||||
validateConfig: () => ({ valid: true, errors: [] }),
|
||||
};
|
||||
if (opts.async) {
|
||||
return { ...base, applyAsync: async (body) => tag(id, body) };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function userContent(result: CompressionResult): string {
|
||||
const messages = result.body.messages as Array<{ role: string; content: string }>;
|
||||
return messages.find((m) => m.role === "user")!.content;
|
||||
}
|
||||
|
||||
function pipeline(...ids: string[]): CompressionPipelineStep[] {
|
||||
return ids.map((engine) => ({ engine })) as unknown as CompressionPipelineStep[];
|
||||
}
|
||||
|
||||
describe("stacked compression — async interface (H10)", () => {
|
||||
before(() => {
|
||||
registerCompressionEngine(makeEngine(FS));
|
||||
registerCompressionEngine(makeEngine(FA, { async: true }));
|
||||
});
|
||||
after(() => {
|
||||
unregisterCompressionEngine(FS);
|
||||
unregisterCompressionEngine(FA);
|
||||
});
|
||||
|
||||
it("runs a mixed sync+async pipeline in pipeline order", async () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
const result = await applyStackedCompressionAsync(body, pipeline(FS, FA));
|
||||
|
||||
assert.equal(userContent(result), "hello|fake-sync-engine|fake-async-engine");
|
||||
assert.equal(result.compressed, true);
|
||||
assert.equal(result.stats?.engine, "stacked");
|
||||
assert.deepEqual(
|
||||
result.stats?.engineBreakdown?.map((e) => e.engine),
|
||||
[FS, FA]
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves order when the async engine runs first", async () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
const result = await applyStackedCompressionAsync(body, pipeline(FA, FS));
|
||||
|
||||
assert.equal(userContent(result), "hello|fake-async-engine|fake-sync-engine");
|
||||
assert.deepEqual(
|
||||
result.stats?.engineBreakdown?.map((e) => e.engine),
|
||||
[FA, FS]
|
||||
);
|
||||
});
|
||||
|
||||
it("async path yields the same result as sync path for sync-only engines", async () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
const asyncResult = await applyStackedCompressionAsync(body, pipeline(FS));
|
||||
const syncResult = applyStackedCompression(body, pipeline(FS));
|
||||
|
||||
assert.deepEqual(asyncResult.body, syncResult.body);
|
||||
assert.equal(userContent(asyncResult), "hello|fake-sync-engine");
|
||||
});
|
||||
|
||||
it("legacy sync path gracefully skips async-only work without crashing", () => {
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
const result = applyStackedCompression(body, pipeline(FA));
|
||||
|
||||
// The async-only engine's sync apply() is a pass-through: no transform.
|
||||
assert.equal(userContent(result), "hello");
|
||||
});
|
||||
|
||||
it("regression: real rtk→caveman stacked pipeline still works (sync)", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "tool", content: Array.from({ length: 8 }, () => "same noisy line").join("\n") },
|
||||
{ role: "user", content: "Explain the authentication configuration in detail please" },
|
||||
],
|
||||
};
|
||||
const result = applyStackedCompression(body, [
|
||||
{ engine: "rtk", intensity: "standard" },
|
||||
{ engine: "caveman", intensity: "full" },
|
||||
]);
|
||||
|
||||
assert.equal(result.stats?.engine, "stacked");
|
||||
assert.deepEqual(
|
||||
result.stats?.engineBreakdown?.map((e) => e.engine),
|
||||
["rtk", "caveman"]
|
||||
);
|
||||
});
|
||||
});
|
||||
285
tests/unit/mcp/tool-cardinality.test.ts
Normal file
285
tests/unit/mcp/tool-cardinality.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Tests for MCP tool manifest cardinality reduction (TV5 / F4.3).
|
||||
*
|
||||
* The pure function `reduceToolManifest` takes a tool manifest + a profile
|
||||
* and returns a reduced manifest announcing fewer tools to the model,
|
||||
* saving tokens in the tool-manifest portion of the context window.
|
||||
*
|
||||
* The live MCP server registration loop is UNCHANGED — this is a pure
|
||||
* utility; activation is a follow-up task.
|
||||
*/
|
||||
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
reduceToolManifest,
|
||||
estimateManifestTokens,
|
||||
type ToolManifestEntry,
|
||||
type ToolProfile,
|
||||
} from "../../../open-sse/mcp-server/toolCardinality.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic manifest — 10 tools across 3 scopes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SYNTHETIC_TOOLS: ToolManifestEntry[] = [
|
||||
{ name: "tool_read_a", description: "Read resource A", scopes: ["read:x"] },
|
||||
{ name: "tool_read_b", description: "Read resource B", scopes: ["read:x"] },
|
||||
{ name: "tool_read_c", description: "Read resource C with extra detail", scopes: ["read:x"] },
|
||||
{ name: "tool_write_a", description: "Write resource A", scopes: ["write:y"] },
|
||||
{ name: "tool_write_b", description: "Write resource B", scopes: ["write:y"] },
|
||||
{ name: "tool_write_c", description: "Write resource C", scopes: ["write:y"] },
|
||||
{ name: "tool_admin_a", description: "Admin action A", scopes: ["admin:z"] },
|
||||
{ name: "tool_admin_b", description: "Admin action B", scopes: ["admin:z"] },
|
||||
{ name: "tool_multi", description: "Multi-scope tool", scopes: ["read:x", "write:y"] },
|
||||
{ name: "tool_no_scope", description: "Tool with no scopes declared", scopes: [] },
|
||||
];
|
||||
|
||||
// Object-keyed variant (name → entry)
|
||||
const SYNTHETIC_TOOLS_MAP: Record<string, ToolManifestEntry> = Object.fromEntries(
|
||||
SYNTHETIC_TOOLS.map((t) => [t.name, t])
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function names(result: ToolManifestEntry[] | Record<string, ToolManifestEntry>): string[] {
|
||||
const entries = Array.isArray(result) ? result : Object.values(result);
|
||||
return entries.map((t) => t.name).sort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("reduceToolManifest — allowScopes filter (array input)", () => {
|
||||
test("returns ONLY read:x tools when profile has allowScopes=['read:x']", () => {
|
||||
const profile: ToolProfile = { name: "reader", allowScopes: ["read:x"] };
|
||||
const result = reduceToolManifest(SYNTHETIC_TOOLS, profile);
|
||||
|
||||
assert.ok(Array.isArray(result), "result should be an array");
|
||||
const resultNames = names(result as ToolManifestEntry[]);
|
||||
|
||||
// Must include the three read:x tools + multi (which has read:x as one scope)
|
||||
assert.ok(resultNames.includes("tool_read_a"), "should include tool_read_a");
|
||||
assert.ok(resultNames.includes("tool_read_b"), "should include tool_read_b");
|
||||
assert.ok(resultNames.includes("tool_read_c"), "should include tool_read_c");
|
||||
assert.ok(resultNames.includes("tool_multi"), "should include tool_multi (has read:x)");
|
||||
|
||||
// Must NOT include write:y-only or admin:z-only tools
|
||||
assert.ok(!resultNames.includes("tool_write_a"), "should NOT include tool_write_a");
|
||||
assert.ok(!resultNames.includes("tool_admin_a"), "should NOT include tool_admin_a");
|
||||
|
||||
// Count strictly less than full manifest
|
||||
assert.ok(
|
||||
(result as ToolManifestEntry[]).length < SYNTHETIC_TOOLS.length,
|
||||
`reduced count (${(result as ToolManifestEntry[]).length}) must be < full count (${SYNTHETIC_TOOLS.length})`
|
||||
);
|
||||
});
|
||||
|
||||
test("estimated tokens of reduced manifest strictly smaller than full manifest", () => {
|
||||
const profile: ToolProfile = { name: "reader", allowScopes: ["read:x"] };
|
||||
const result = reduceToolManifest(SYNTHETIC_TOOLS, profile) as ToolManifestEntry[];
|
||||
|
||||
const fullTokens = estimateManifestTokens(SYNTHETIC_TOOLS);
|
||||
const reducedTokens = estimateManifestTokens(result);
|
||||
|
||||
assert.ok(
|
||||
reducedTokens < fullTokens,
|
||||
`reduced tokens (${reducedTokens}) must be < full tokens (${fullTokens})`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceToolManifest — allowScopes filter (object/map input)", () => {
|
||||
test("returns a Record when given a Record input", () => {
|
||||
const profile: ToolProfile = { name: "reader", allowScopes: ["read:x"] };
|
||||
const result = reduceToolManifest(SYNTHETIC_TOOLS_MAP, profile);
|
||||
|
||||
assert.ok(!Array.isArray(result), "result should be a Record");
|
||||
const resultNames = names(result as Record<string, ToolManifestEntry>);
|
||||
|
||||
assert.ok(resultNames.includes("tool_read_a"), "should include tool_read_a");
|
||||
assert.ok(!resultNames.includes("tool_write_a"), "should NOT include tool_write_a");
|
||||
assert.ok(
|
||||
Object.keys(result as Record<string, ToolManifestEntry>).length < SYNTHETIC_TOOLS.length
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceToolManifest — allowTools and denyTools", () => {
|
||||
test("allowTools explicitly includes named tools regardless of scope", () => {
|
||||
const profile: ToolProfile = {
|
||||
name: "allow-list-test",
|
||||
allowScopes: ["read:x"],
|
||||
allowTools: ["tool_admin_a"], // admin tool explicitly allowed even without admin:z scope
|
||||
};
|
||||
const result = reduceToolManifest(SYNTHETIC_TOOLS, profile) as ToolManifestEntry[];
|
||||
const resultNames = names(result);
|
||||
|
||||
assert.ok(resultNames.includes("tool_admin_a"), "allowTools should force-include tool_admin_a");
|
||||
assert.ok(resultNames.includes("tool_read_a"), "read:x tools should still be included");
|
||||
assert.ok(!resultNames.includes("tool_write_a"), "write:y tools should still be excluded");
|
||||
});
|
||||
|
||||
test("denyTools removes a tool even if its scope would be allowed", () => {
|
||||
const profile: ToolProfile = {
|
||||
name: "deny-test",
|
||||
allowScopes: ["read:x"],
|
||||
denyTools: ["tool_read_b"],
|
||||
};
|
||||
const result = reduceToolManifest(SYNTHETIC_TOOLS, profile) as ToolManifestEntry[];
|
||||
const resultNames = names(result);
|
||||
|
||||
assert.ok(!resultNames.includes("tool_read_b"), "denyTools should remove tool_read_b");
|
||||
assert.ok(resultNames.includes("tool_read_a"), "other read:x tools should still be included");
|
||||
});
|
||||
|
||||
test("denyTools takes priority over allowTools (deny wins)", () => {
|
||||
const profile: ToolProfile = {
|
||||
name: "deny-over-allow",
|
||||
allowScopes: ["read:x"],
|
||||
allowTools: ["tool_read_b"],
|
||||
denyTools: ["tool_read_b"],
|
||||
};
|
||||
const result = reduceToolManifest(SYNTHETIC_TOOLS, profile) as ToolManifestEntry[];
|
||||
const resultNames = names(result);
|
||||
|
||||
assert.ok(!resultNames.includes("tool_read_b"), "deny should beat allow for tool_read_b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceToolManifest — maxTools cap", () => {
|
||||
test("maxTools caps count deterministically", () => {
|
||||
const profile: ToolProfile = {
|
||||
name: "capped",
|
||||
allowScopes: ["read:x", "write:y", "admin:z"],
|
||||
maxTools: 3,
|
||||
};
|
||||
const result = reduceToolManifest(SYNTHETIC_TOOLS, profile) as ToolManifestEntry[];
|
||||
assert.equal(result.length, 3, "exactly 3 tools when maxTools=3");
|
||||
});
|
||||
|
||||
test("maxTools is deterministic — same input always produces same output", () => {
|
||||
const profile: ToolProfile = {
|
||||
name: "det",
|
||||
allowScopes: ["read:x", "write:y", "admin:z"],
|
||||
maxTools: 4,
|
||||
};
|
||||
const r1 = names(reduceToolManifest(SYNTHETIC_TOOLS, profile) as ToolManifestEntry[]);
|
||||
const r2 = names(reduceToolManifest(SYNTHETIC_TOOLS, profile) as ToolManifestEntry[]);
|
||||
assert.deepEqual(r1, r2, "same call produces same subset");
|
||||
});
|
||||
|
||||
test("maxTools keeps allowTools-listed tools first, then fills from name-sorted rest", () => {
|
||||
const profile: ToolProfile = {
|
||||
name: "priority-cap",
|
||||
allowScopes: ["read:x", "write:y"],
|
||||
allowTools: ["tool_write_a"], // must be in final result even though write:y tools come later alphabetically
|
||||
maxTools: 2,
|
||||
};
|
||||
const result = reduceToolManifest(SYNTHETIC_TOOLS, profile) as ToolManifestEntry[];
|
||||
const resultNames = names(result);
|
||||
|
||||
assert.equal(result.length, 2, "exactly 2 tools");
|
||||
assert.ok(resultNames.includes("tool_write_a"), "allow-listed tool_write_a must be kept");
|
||||
});
|
||||
|
||||
test("maxTools larger than available tools returns all available tools unchanged", () => {
|
||||
const profile: ToolProfile = {
|
||||
name: "big-cap",
|
||||
allowScopes: ["read:x"],
|
||||
maxTools: 999,
|
||||
};
|
||||
const result = reduceToolManifest(SYNTHETIC_TOOLS, profile) as ToolManifestEntry[];
|
||||
// Only read:x tools pass the scope filter; maxTools doesn't shrink further
|
||||
const readXCount = SYNTHETIC_TOOLS.filter(
|
||||
(t) => t.scopes && t.scopes.includes("read:x")
|
||||
).length;
|
||||
assert.equal(result.length, readXCount);
|
||||
});
|
||||
|
||||
test("negative maxTools is treated as no cap (not a silent tail-drop)", () => {
|
||||
const profile: ToolProfile = { name: "neg", allowScopes: ["read:x"], maxTools: -1 };
|
||||
const result = reduceToolManifest(SYNTHETIC_TOOLS, profile) as ToolManifestEntry[];
|
||||
const readXCount = SYNTHETIC_TOOLS.filter(
|
||||
(t) => t.scopes && t.scopes.includes("read:x")
|
||||
).length;
|
||||
assert.equal(result.length, readXCount, "negative maxTools must not drop tail entries");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceToolManifest — allow-everything profile", () => {
|
||||
test("profile with no allowScopes/allowTools = no filtering, returns full manifest", () => {
|
||||
const profile: ToolProfile = { name: "superuser" };
|
||||
const result = reduceToolManifest(SYNTHETIC_TOOLS, profile) as ToolManifestEntry[];
|
||||
|
||||
assert.equal(result.length, SYNTHETIC_TOOLS.length, "all tools should be kept");
|
||||
assert.deepEqual(names(result), names(SYNTHETIC_TOOLS), "same tools as input");
|
||||
});
|
||||
|
||||
test("allow-everything object input also returns full manifest", () => {
|
||||
const profile: ToolProfile = { name: "superuser" };
|
||||
const result = reduceToolManifest(SYNTHETIC_TOOLS_MAP, profile) as Record<
|
||||
string,
|
||||
ToolManifestEntry
|
||||
>;
|
||||
assert.equal(
|
||||
Object.keys(result).length,
|
||||
Object.keys(SYNTHETIC_TOOLS_MAP).length,
|
||||
"all keys preserved"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceToolManifest — immutability", () => {
|
||||
test("original array manifest is not mutated after call", () => {
|
||||
const copy = SYNTHETIC_TOOLS.map((t) => ({ ...t, scopes: [...(t.scopes ?? [])] }));
|
||||
const profile: ToolProfile = { name: "reader", allowScopes: ["read:x"] };
|
||||
|
||||
reduceToolManifest(copy, profile);
|
||||
|
||||
assert.equal(copy.length, SYNTHETIC_TOOLS.length, "original length unchanged");
|
||||
assert.deepEqual(
|
||||
copy.map((t) => t.name),
|
||||
SYNTHETIC_TOOLS.map((t) => t.name),
|
||||
"original order and names unchanged"
|
||||
);
|
||||
});
|
||||
|
||||
test("original map manifest is not mutated after call", () => {
|
||||
const copyMap = Object.fromEntries(
|
||||
Object.entries(SYNTHETIC_TOOLS_MAP).map(([k, v]) => [
|
||||
k,
|
||||
{ ...v, scopes: [...(v.scopes ?? [])] },
|
||||
])
|
||||
);
|
||||
const keysBeforeCall = Object.keys(copyMap).sort();
|
||||
|
||||
const profile: ToolProfile = { name: "reader", allowScopes: ["read:x"] };
|
||||
reduceToolManifest(copyMap, profile);
|
||||
|
||||
assert.deepEqual(Object.keys(copyMap).sort(), keysBeforeCall, "original map keys unchanged");
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateManifestTokens", () => {
|
||||
test("empty manifest returns 0", () => {
|
||||
assert.equal(estimateManifestTokens([]), 0);
|
||||
});
|
||||
|
||||
test("larger manifest has more tokens than smaller manifest", () => {
|
||||
const small = SYNTHETIC_TOOLS.slice(0, 2);
|
||||
const large = SYNTHETIC_TOOLS;
|
||||
assert.ok(estimateManifestTokens(large) > estimateManifestTokens(small));
|
||||
});
|
||||
|
||||
test("accepts Record input as well as array", () => {
|
||||
const arrTokens = estimateManifestTokens(SYNTHETIC_TOOLS);
|
||||
const mapTokens = estimateManifestTokens(SYNTHETIC_TOOLS_MAP);
|
||||
// Same tools, so token estimates should be very close (maybe tiny diff due to key serialization)
|
||||
assert.ok(typeof arrTokens === "number" && arrTokens > 0);
|
||||
assert.ok(typeof mapTokens === "number" && mapTokens > 0);
|
||||
});
|
||||
});
|
||||
360
tests/unit/ui/comboFlowModel.test.ts
Normal file
360
tests/unit/ui/comboFlowModel.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* tests/unit/ui/comboFlowModel.test.ts
|
||||
*
|
||||
* TDD for `comboFlowModel` — the pure reducer/model core of Tela B.
|
||||
* Run: node --import tsx/esm --test tests/unit/ui/comboFlowModel.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
classifyFailKind,
|
||||
reduceComboEvent,
|
||||
comboRunToFlow,
|
||||
type ComboRunModel,
|
||||
type ComboEventInput,
|
||||
} from "../../../src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts";
|
||||
|
||||
import { FLOW_EDGE_COLORS } from "../../../src/shared/components/flow/edgeStyles.ts";
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function mkAttempt(
|
||||
targetIndex: number,
|
||||
strategy = "priority",
|
||||
provider = `prov${targetIndex}`,
|
||||
model = `model${targetIndex}`
|
||||
): ComboEventInput {
|
||||
return {
|
||||
comboName: "test-combo",
|
||||
targetIndex,
|
||||
provider,
|
||||
model,
|
||||
type: "attempt",
|
||||
strategy,
|
||||
timestamp: 1000 + targetIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function mkFailed(
|
||||
targetIndex: number,
|
||||
error: string,
|
||||
provider = `prov${targetIndex}`,
|
||||
model = `model${targetIndex}`
|
||||
): ComboEventInput {
|
||||
return {
|
||||
comboName: "test-combo",
|
||||
targetIndex,
|
||||
provider,
|
||||
model,
|
||||
type: "failed",
|
||||
error,
|
||||
latencyMs: 100,
|
||||
timestamp: 2000 + targetIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function mkSucceeded(
|
||||
targetIndex: number,
|
||||
provider = `prov${targetIndex}`,
|
||||
model = `model${targetIndex}`
|
||||
): ComboEventInput {
|
||||
return {
|
||||
comboName: "test-combo",
|
||||
targetIndex,
|
||||
provider,
|
||||
model,
|
||||
type: "succeeded",
|
||||
latencyMs: 42,
|
||||
timestamp: 3000 + targetIndex,
|
||||
};
|
||||
}
|
||||
|
||||
// Full 6-event cascade:
|
||||
// attempt(0) → failed(0,"429 rate limited") → attempt(1) → failed(1,"circuit open") → attempt(2) → succeeded(2)
|
||||
function buildFullRun(): ComboRunModel {
|
||||
let run: ComboRunModel | null = null;
|
||||
run = reduceComboEvent(run, mkAttempt(0));
|
||||
run = reduceComboEvent(run, mkFailed(0, "429 rate limited"));
|
||||
run = reduceComboEvent(run, mkAttempt(1));
|
||||
run = reduceComboEvent(run, mkFailed(1, "circuit open"));
|
||||
run = reduceComboEvent(run, mkAttempt(2));
|
||||
run = reduceComboEvent(run, mkSucceeded(2));
|
||||
return run;
|
||||
}
|
||||
|
||||
// ── classifyFailKind ──────────────────────────────────────────────────────
|
||||
|
||||
describe("classifyFailKind", () => {
|
||||
it("returns undefined when error is absent", () => {
|
||||
assert.equal(classifyFailKind(undefined), undefined);
|
||||
assert.equal(classifyFailKind(""), undefined);
|
||||
});
|
||||
|
||||
it("returns 'rate-limit' for 429 messages", () => {
|
||||
assert.equal(classifyFailKind("429 Too Many Requests"), "rate-limit");
|
||||
assert.equal(classifyFailKind("upstream rate limit exceeded"), "rate-limit");
|
||||
assert.equal(classifyFailKind("RATE_LIMIT_EXCEEDED"), "rate-limit");
|
||||
});
|
||||
|
||||
it("returns 'circuit-open' for circuit breaker messages", () => {
|
||||
assert.equal(classifyFailKind("circuit open"), "circuit-open");
|
||||
assert.equal(classifyFailKind("Provider circuit breaker is open"), "circuit-open");
|
||||
assert.equal(classifyFailKind("CIRCUIT_OPEN"), "circuit-open");
|
||||
});
|
||||
|
||||
it("returns 'cooldown' for cooldown messages", () => {
|
||||
assert.equal(classifyFailKind("connection cooldown active"), "cooldown");
|
||||
assert.equal(classifyFailKind("cooldown period"), "cooldown");
|
||||
});
|
||||
|
||||
it("returns 'other' for unrecognized error strings", () => {
|
||||
assert.equal(classifyFailKind("Internal server error"), "other");
|
||||
assert.equal(classifyFailKind("timeout"), "other");
|
||||
assert.equal(classifyFailKind("some unknown failure"), "other");
|
||||
});
|
||||
|
||||
it("circuit-open takes precedence over rate-limit when both match", () => {
|
||||
// edge: a message with both circuit and rate — circuit wins by regex ordering
|
||||
const result = classifyFailKind("circuit open after 429");
|
||||
assert.equal(result, "circuit-open");
|
||||
});
|
||||
});
|
||||
|
||||
// ── reduceComboEvent ──────────────────────────────────────────────────────
|
||||
|
||||
describe("reduceComboEvent — basic events", () => {
|
||||
it("creates a new run on first attempt event (null input)", () => {
|
||||
const run = reduceComboEvent(null, mkAttempt(0));
|
||||
|
||||
assert.equal(run.comboName, "test-combo");
|
||||
assert.equal(run.strategy, "priority");
|
||||
assert.equal(run.outcome, "running");
|
||||
assert.ok(run.startedAt > 0);
|
||||
assert.equal(run.finishedAt, undefined);
|
||||
assert.equal(run.targets.length, 1);
|
||||
assert.equal(run.targets[0].targetIndex, 0);
|
||||
assert.equal(run.targets[0].provider, "prov0");
|
||||
assert.equal(run.targets[0].model, "model0");
|
||||
assert.equal(run.targets[0].state, "attempting");
|
||||
});
|
||||
|
||||
it("marks target as failed and sets failKind on failed event", () => {
|
||||
let run = reduceComboEvent(null, mkAttempt(0));
|
||||
run = reduceComboEvent(run, mkFailed(0, "429 rate limited"));
|
||||
|
||||
assert.equal(run.targets[0].state, "failed");
|
||||
assert.equal(run.targets[0].failKind, "rate-limit");
|
||||
assert.equal(run.targets[0].error, "429 rate limited");
|
||||
assert.equal(run.outcome, "running"); // not done yet
|
||||
});
|
||||
|
||||
it("marks target as succeeded and sets outcome + finishedAt on succeeded event", () => {
|
||||
let run = reduceComboEvent(null, mkAttempt(0));
|
||||
run = reduceComboEvent(run, mkSucceeded(0));
|
||||
|
||||
assert.equal(run.targets[0].state, "succeeded");
|
||||
assert.equal(run.targets[0].latencyMs, 42);
|
||||
assert.equal(run.outcome, "succeeded");
|
||||
assert.ok(run.finishedAt != null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceComboEvent — full cascade (3 targets)", () => {
|
||||
it("produces 3 targets ordered by targetIndex", () => {
|
||||
const run = buildFullRun();
|
||||
|
||||
assert.equal(run.targets.length, 3);
|
||||
assert.equal(run.targets[0].targetIndex, 0);
|
||||
assert.equal(run.targets[1].targetIndex, 1);
|
||||
assert.equal(run.targets[2].targetIndex, 2);
|
||||
});
|
||||
|
||||
it("states are [failed, failed, succeeded]", () => {
|
||||
const run = buildFullRun();
|
||||
|
||||
assert.equal(run.targets[0].state, "failed");
|
||||
assert.equal(run.targets[1].state, "failed");
|
||||
assert.equal(run.targets[2].state, "succeeded");
|
||||
});
|
||||
|
||||
it("failKinds are [rate-limit, circuit-open, undefined]", () => {
|
||||
const run = buildFullRun();
|
||||
|
||||
assert.equal(run.targets[0].failKind, "rate-limit");
|
||||
assert.equal(run.targets[1].failKind, "circuit-open");
|
||||
assert.equal(run.targets[2].failKind, undefined);
|
||||
});
|
||||
|
||||
it("outcome is 'succeeded' and finishedAt is set", () => {
|
||||
const run = buildFullRun();
|
||||
|
||||
assert.equal(run.outcome, "succeeded");
|
||||
assert.ok(run.finishedAt != null);
|
||||
});
|
||||
|
||||
it("strategy is set from the attempt payload", () => {
|
||||
const run = buildFullRun();
|
||||
|
||||
assert.equal(run.strategy, "priority");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceComboEvent — ordering", () => {
|
||||
it("keeps targets sorted by targetIndex even if events arrive out of order", () => {
|
||||
// Unusual but defensive: attempt(2) then attempt(0)
|
||||
let run = reduceComboEvent(null, mkAttempt(2));
|
||||
run = reduceComboEvent(run, mkAttempt(0));
|
||||
|
||||
assert.equal(run.targets[0].targetIndex, 0);
|
||||
assert.equal(run.targets[1].targetIndex, 2);
|
||||
});
|
||||
|
||||
it("idempotently applies a repeated attempt for the same target", () => {
|
||||
let run = reduceComboEvent(null, mkAttempt(0));
|
||||
run = reduceComboEvent(run, mkAttempt(0)); // duplicate
|
||||
|
||||
assert.equal(run.targets.length, 1);
|
||||
assert.equal(run.targets[0].state, "attempting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceComboEvent — comboName key", () => {
|
||||
it("ignores events for a different comboName when run already exists", () => {
|
||||
let run = reduceComboEvent(null, mkAttempt(0));
|
||||
|
||||
// Event for a different combo — should be ignored, run returned unchanged
|
||||
const alienEvent: ComboEventInput = {
|
||||
comboName: "other-combo",
|
||||
targetIndex: 99,
|
||||
provider: "alien",
|
||||
model: "alien",
|
||||
type: "failed",
|
||||
error: "some error",
|
||||
timestamp: 9999,
|
||||
};
|
||||
run = reduceComboEvent(run, alienEvent);
|
||||
|
||||
assert.equal(run.targets.length, 1);
|
||||
assert.equal(run.comboName, "test-combo");
|
||||
});
|
||||
});
|
||||
|
||||
// ── comboRunToFlow ────────────────────────────────────────────────────────
|
||||
|
||||
describe("comboRunToFlow", () => {
|
||||
it("returns N+3 nodes for N targets (request + strategy + N targets + response)", () => {
|
||||
const run = buildFullRun();
|
||||
const { nodes } = comboRunToFlow(run);
|
||||
|
||||
// 3 targets → 1 request + 1 strategy + 3 targets + 1 response = 6 nodes
|
||||
assert.equal(nodes.length, 6, `expected 6 nodes, got ${nodes.length}`);
|
||||
});
|
||||
|
||||
it("node types: first=request, second=strategy, N middle=target, last=response", () => {
|
||||
const run = buildFullRun();
|
||||
const { nodes } = comboRunToFlow(run);
|
||||
|
||||
assert.equal(nodes[0].type, "request");
|
||||
assert.equal(nodes[1].type, "strategy");
|
||||
assert.equal(nodes[2].type, "target");
|
||||
assert.equal(nodes[3].type, "target");
|
||||
assert.equal(nodes[4].type, "target");
|
||||
assert.equal(nodes[5].type, "response");
|
||||
});
|
||||
|
||||
it("target nodes carry provider, model, state, failKind in data", () => {
|
||||
const run = buildFullRun();
|
||||
const { nodes } = comboRunToFlow(run);
|
||||
|
||||
const t0 = nodes[2];
|
||||
assert.equal((t0.data as Record<string, unknown>).provider, "prov0");
|
||||
assert.equal((t0.data as Record<string, unknown>).model, "model0");
|
||||
assert.equal((t0.data as Record<string, unknown>).state, "failed");
|
||||
assert.equal((t0.data as Record<string, unknown>).failKind, "rate-limit");
|
||||
|
||||
const t2 = nodes[4];
|
||||
assert.equal((t2.data as Record<string, unknown>).state, "succeeded");
|
||||
assert.equal((t2.data as Record<string, unknown>).failKind, undefined);
|
||||
});
|
||||
|
||||
it("strategy node carries the strategy name in data", () => {
|
||||
const run = buildFullRun();
|
||||
const { nodes } = comboRunToFlow(run);
|
||||
|
||||
assert.equal((nodes[1].data as Record<string, unknown>).strategy, "priority");
|
||||
assert.equal((nodes[1].data as Record<string, unknown>).targetCount, 3);
|
||||
});
|
||||
|
||||
it("edges: N+3 sequential edges (one per node-to-node link)", () => {
|
||||
const run = buildFullRun();
|
||||
const { edges } = comboRunToFlow(run);
|
||||
|
||||
// 6 nodes → 5 edges: request→strategy, strategy→t0, t0→t1, t1→t2, t2→response
|
||||
assert.equal(edges.length, 5, `expected 5 edges, got ${edges.length}`);
|
||||
});
|
||||
|
||||
it("each edge connects consecutive nodes in order", () => {
|
||||
const run = buildFullRun();
|
||||
const { nodes, edges } = comboRunToFlow(run);
|
||||
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
assert.equal(edges[i].source, nodes[i].id, `edge[${i}].source mismatch`);
|
||||
assert.equal(edges[i].target, nodes[i + 1].id, `edge[${i}].target mismatch`);
|
||||
}
|
||||
});
|
||||
|
||||
it("failed edges are styled with error color", () => {
|
||||
const run = buildFullRun();
|
||||
const { edges } = comboRunToFlow(run);
|
||||
|
||||
// edge[2] is strategy→t0 (failed)… actually edges are: [0]req→strat, [1]strat→t0, [2]t0→t1, [3]t1→t2, [4]t2→resp
|
||||
// edge for t0 (failed): index 1 (strategy→t0)
|
||||
// The edge going INTO t0 should reflect t0 state
|
||||
const t0IncomingEdge = edges[1]; // strategy→target0
|
||||
const style = t0IncomingEdge.style as Record<string, unknown> | undefined;
|
||||
assert.ok(style != null, "edge should have style");
|
||||
assert.equal(style.stroke, FLOW_EDGE_COLORS.error, "failed target edge should be error color");
|
||||
});
|
||||
|
||||
it("succeeded edge is styled with active/green color", () => {
|
||||
const run = buildFullRun();
|
||||
const { edges } = comboRunToFlow(run);
|
||||
|
||||
// edge[4] is t2→response (t2 succeeded)
|
||||
const t2ResponseEdge = edges[4];
|
||||
const style = t2ResponseEdge.style as Record<string, unknown> | undefined;
|
||||
assert.ok(style != null);
|
||||
assert.equal(
|
||||
style.stroke,
|
||||
FLOW_EDGE_COLORS.active,
|
||||
"succeeded target edge should be active/green color"
|
||||
);
|
||||
});
|
||||
|
||||
it("idle/attempting edges are styled with idle or last-used color (not error/green)", () => {
|
||||
// A run with one target still attempting
|
||||
let run = reduceComboEvent(null, mkAttempt(0));
|
||||
const { edges } = comboRunToFlow(run);
|
||||
|
||||
// edge[1] = strategy→t0 (attempting)
|
||||
const attemptingEdge = edges[1];
|
||||
const style = attemptingEdge.style as Record<string, unknown> | undefined;
|
||||
assert.ok(style != null);
|
||||
assert.notEqual(style.stroke, FLOW_EDGE_COLORS.error);
|
||||
assert.notEqual(style.stroke, FLOW_EDGE_COLORS.active);
|
||||
});
|
||||
|
||||
it("produces deterministic node IDs", () => {
|
||||
const run = buildFullRun();
|
||||
const { nodes } = comboRunToFlow(run);
|
||||
|
||||
assert.equal(nodes[0].id, "request");
|
||||
assert.equal(nodes[1].id, "strategy");
|
||||
assert.equal(nodes[2].id, "target-0");
|
||||
assert.equal(nodes[3].id, "target-1");
|
||||
assert.equal(nodes[4].id, "target-2");
|
||||
assert.equal(nodes[5].id, "response");
|
||||
});
|
||||
});
|
||||
213
tests/unit/ui/comboLiveStudio.test.tsx
Normal file
213
tests/unit/ui/comboLiveStudio.test.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
||||
import type { ComboRunModel } from "@/app/(dashboard)/dashboard/combos/live/comboFlowModel";
|
||||
|
||||
// ── Polyfill ResizeObserver (required by ReactFlow) ───────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Stub @xyflow/react so ReactFlow renders without canvas/DOM measurement APIs
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = (await vi.importActual("@xyflow/react")) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
Handle: (_props: Record<string, unknown>) => null,
|
||||
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
|
||||
};
|
||||
});
|
||||
|
||||
// Stub next/image to avoid Next.js internals in jsdom
|
||||
vi.mock("next/image", () => ({
|
||||
default: (props: Record<string, unknown>) =>
|
||||
React.createElement("img", { src: props.src as string, alt: props.alt as string }),
|
||||
}));
|
||||
|
||||
// ── Import after mocks ─────────────────────────────────────────────────────
|
||||
|
||||
const { ComboLiveStudio } = await import("@/app/(dashboard)/dashboard/combos/live/ComboLiveStudio");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Sample run: 2 failures then 1 success ────────────────────────────────
|
||||
|
||||
const SAMPLE_RUN: ComboRunModel = {
|
||||
comboName: "daily-cascade",
|
||||
strategy: "priority",
|
||||
targets: [
|
||||
{
|
||||
targetIndex: 0,
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
state: "failed",
|
||||
latencyMs: 1200,
|
||||
error: "429 rate limit exceeded",
|
||||
failKind: "rate-limit",
|
||||
},
|
||||
{
|
||||
targetIndex: 1,
|
||||
provider: "anthropic",
|
||||
model: "claude-3-sonnet",
|
||||
state: "failed",
|
||||
latencyMs: 800,
|
||||
error: "circuit open after 5 failures",
|
||||
failKind: "circuit-open",
|
||||
},
|
||||
{
|
||||
targetIndex: 2,
|
||||
provider: "gemini",
|
||||
model: "gemini-1.5-pro",
|
||||
state: "succeeded",
|
||||
latencyMs: 620,
|
||||
},
|
||||
],
|
||||
outcome: "succeeded",
|
||||
startedAt: 1718000000000,
|
||||
finishedAt: 1718000003000,
|
||||
};
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ComboLiveStudio", () => {
|
||||
describe("with a static run prop", () => {
|
||||
it("renders the studio wrapper", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector("[data-testid='combo-live-studio']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the ReactFlow canvas (.react-flow)", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector(".react-flow")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the combo name in the toolbar", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.textContent).toContain("daily-cascade");
|
||||
});
|
||||
|
||||
it("shows the strategy in the toolbar", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.textContent).toContain("priority");
|
||||
});
|
||||
|
||||
it("shows the outcome in the toolbar", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector("[data-testid='run-outcome']")?.textContent).toBe("succeeded");
|
||||
});
|
||||
|
||||
it("shows provider names from target nodes", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
const text = container.textContent ?? "";
|
||||
// Node internals rendered via ReactFlow in jsdom — at minimum the toolbar
|
||||
// shows the combo name. Provider names appear in node elements if ReactFlow
|
||||
// renders custom node interiors.
|
||||
expect(text).toContain("daily-cascade");
|
||||
});
|
||||
|
||||
it("does NOT render the combo selector when a run prop is supplied", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector("[data-testid='combo-selector']")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the Single/Fleet toggle buttons", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector("[data-testid='mode-single']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='mode-fleet']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT show the disconnected banner when isConnected=true", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} isConnected={true} />);
|
||||
expect(container.querySelector("[data-testid='combo-disconnected-banner']")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the disconnected banner when isConnected=false", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} isConnected={false} />);
|
||||
expect(container.querySelector("[data-testid='combo-disconnected-banner']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state (no run, no events)", () => {
|
||||
it("renders the empty state when run=null", () => {
|
||||
const container = mount(<ComboLiveStudio run={null} />);
|
||||
expect(container.querySelector("[data-testid='combo-live-studio-empty']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the empty state message", () => {
|
||||
const container = mount(<ComboLiveStudio run={null} />);
|
||||
expect(container.textContent).toContain("No combo run available");
|
||||
});
|
||||
|
||||
it("shows the combo selector with no events", () => {
|
||||
const container = mount(<ComboLiveStudio comboEvents={[]} />);
|
||||
// No run prop → selector should appear
|
||||
expect(container.querySelector("[data-testid='combo-selector']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the studio wrapper even when empty", () => {
|
||||
const container = mount(<ComboLiveStudio />);
|
||||
expect(container.querySelector("[data-testid='combo-live-studio']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fleet mode", () => {
|
||||
it("shows fleet overview after clicking Fleet toggle", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} comboEvents={[]} />);
|
||||
const fleetBtn = container.querySelector(
|
||||
"[data-testid='mode-fleet']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(fleetBtn).toBeTruthy();
|
||||
act(() => {
|
||||
fleetBtn!.click();
|
||||
});
|
||||
// Fleet panel renders (either FleetOverview or its empty state)
|
||||
expect(container.querySelector("[data-testid='combo-live-studio']")).toBeTruthy();
|
||||
// The .react-flow canvas should no longer be visible in fleet mode
|
||||
expect(container.querySelector(".react-flow")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disconnected state with a run", () => {
|
||||
it("still renders the canvas when disconnected but run is provided", () => {
|
||||
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} isConnected={false} />);
|
||||
// Canvas renders (graceful degrade — show last known state)
|
||||
expect(container.querySelector(".react-flow")).toBeTruthy();
|
||||
// Banner also shows
|
||||
expect(container.querySelector("[data-testid='combo-disconnected-banner']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
149
tests/unit/ui/compressionCockpit.test.tsx
Normal file
149
tests/unit/ui/compressionCockpit.test.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
||||
import type { CompressionRunModel } from "@/app/(dashboard)/dashboard/compression/studio/compressionFlowModel";
|
||||
|
||||
// ── Polyfill ResizeObserver (required by ReactFlow) ───────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Stub @xyflow/react so ReactFlow renders without canvas/DOM measurement APIs
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = (await vi.importActual("@xyflow/react")) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
Handle: (_props: Record<string, unknown>) => null,
|
||||
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
|
||||
};
|
||||
});
|
||||
|
||||
// ── Import after mocks ─────────────────────────────────────────────────────
|
||||
|
||||
const { CompressionCockpit } =
|
||||
await import("@/app/(dashboard)/dashboard/compression/studio/CompressionCockpit");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Sample run ────────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_RUN: CompressionRunModel = {
|
||||
requestId: "test-req-001",
|
||||
comboId: "daily-cascade",
|
||||
mode: "stacked",
|
||||
originalTokens: 12480,
|
||||
compressedTokens: 6090,
|
||||
savingsPercent: 51.2,
|
||||
timestamp: 1718000000000,
|
||||
steps: [
|
||||
{
|
||||
engine: "rtk",
|
||||
originalTokens: 12480,
|
||||
compressedTokens: 9734,
|
||||
savingsPercent: 22.0,
|
||||
techniquesUsed: ["strip-comments", "shell-filter"],
|
||||
durationMs: 1.8,
|
||||
},
|
||||
{
|
||||
engine: "headroom",
|
||||
originalTokens: 9734,
|
||||
compressedTokens: 8524,
|
||||
savingsPercent: 12.4,
|
||||
techniquesUsed: ["smartcrusher"],
|
||||
durationMs: 0.9,
|
||||
},
|
||||
{
|
||||
engine: "caveman",
|
||||
originalTokens: 8524,
|
||||
compressedTokens: 6896,
|
||||
savingsPercent: 19.1,
|
||||
techniquesUsed: ["pt-BR", "filler-drop"],
|
||||
durationMs: 0.6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CompressionCockpit", () => {
|
||||
it("renders the cockpit wrapper when a run is provided", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector("[data-testid='compression-cockpit']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the ReactFlow canvas", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector(".react-flow")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the mode in the header", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
expect(container.textContent).toContain("stacked");
|
||||
});
|
||||
|
||||
it("shows the comboId in the header", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
expect(container.textContent).toContain("daily-cascade");
|
||||
});
|
||||
|
||||
it("shows the total savings percentage in the header", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
expect(container.textContent).toContain("51.2");
|
||||
});
|
||||
|
||||
it("shows engine names (from node labels via ReactFlow)", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
// The engine names appear in the header text even if ReactFlow node internals
|
||||
// don't render in jsdom — we also check the header region.
|
||||
const text = container.textContent ?? "";
|
||||
// At minimum the run metadata renders correctly
|
||||
expect(text).toContain("test-req-001");
|
||||
});
|
||||
|
||||
it("renders the empty state when no run is given", () => {
|
||||
const container = mount(<CompressionCockpit />);
|
||||
expect(container.querySelector("[data-testid='compression-cockpit-empty']")).toBeTruthy();
|
||||
expect(container.textContent).toContain("No compression run available");
|
||||
});
|
||||
|
||||
it("renders replay controls when a run is provided", () => {
|
||||
const container = mount(<CompressionCockpit run={SAMPLE_RUN} />);
|
||||
// Replay button present
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Replay");
|
||||
});
|
||||
});
|
||||
212
tests/unit/ui/compressionFlowModel.test.ts
Normal file
212
tests/unit/ui/compressionFlowModel.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
compressionEventToModel,
|
||||
compressionRunToFlow,
|
||||
buildReplayFrames,
|
||||
type CompressionRunModel,
|
||||
} from "../../../src/app/(dashboard)/dashboard/compression/studio/compressionFlowModel.ts";
|
||||
import type { CompressionCompletedPayload } from "../../../src/lib/events/types.ts";
|
||||
|
||||
// ── fixture ───────────────────────────────────────────────────────────────
|
||||
|
||||
const THREE_ENGINE_PAYLOAD: CompressionCompletedPayload = {
|
||||
requestId: "abc123",
|
||||
comboId: "my-combo",
|
||||
mode: "stacked",
|
||||
originalTokens: 1000,
|
||||
compressedTokens: 600,
|
||||
savingsPercent: 40,
|
||||
engineBreakdown: [
|
||||
{
|
||||
engine: "lite",
|
||||
originalTokens: 1000,
|
||||
compressedTokens: 900,
|
||||
savingsPercent: 10,
|
||||
techniquesUsed: ["whitespace"],
|
||||
durationMs: 2,
|
||||
},
|
||||
{
|
||||
engine: "caveman",
|
||||
originalTokens: 900,
|
||||
compressedTokens: 750,
|
||||
savingsPercent: 16.7,
|
||||
techniquesUsed: ["filler", "dedup"],
|
||||
durationMs: 5,
|
||||
},
|
||||
{
|
||||
engine: "rtk",
|
||||
originalTokens: 750,
|
||||
compressedTokens: 600,
|
||||
savingsPercent: 20,
|
||||
techniquesUsed: ["tool-output-trim"],
|
||||
rulesApplied: ["max-lines"],
|
||||
durationMs: 8,
|
||||
},
|
||||
],
|
||||
timestamp: 1718000000000,
|
||||
};
|
||||
|
||||
// ── compressionEventToModel ───────────────────────────────────────────────
|
||||
|
||||
describe("compressionEventToModel", () => {
|
||||
it("builds the model from a 3-engine payload", () => {
|
||||
const model: CompressionRunModel = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
|
||||
assert.equal(model.requestId, "abc123");
|
||||
assert.equal(model.comboId, "my-combo");
|
||||
assert.equal(model.mode, "stacked");
|
||||
assert.equal(model.originalTokens, 1000);
|
||||
assert.equal(model.compressedTokens, 600);
|
||||
assert.equal(model.savingsPercent, 40);
|
||||
assert.equal(model.steps.length, 3);
|
||||
});
|
||||
|
||||
it("maps each engine step correctly", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
|
||||
assert.equal(model.steps[0].engine, "lite");
|
||||
assert.equal(model.steps[0].originalTokens, 1000);
|
||||
assert.equal(model.steps[0].compressedTokens, 900);
|
||||
assert.equal(model.steps[0].savingsPercent, 10);
|
||||
assert.deepEqual(model.steps[0].techniquesUsed, ["whitespace"]);
|
||||
assert.equal(model.steps[0].durationMs, 2);
|
||||
|
||||
assert.equal(model.steps[2].engine, "rtk");
|
||||
assert.deepEqual(model.steps[2].rulesApplied, ["max-lines"]);
|
||||
});
|
||||
|
||||
it("handles null comboId gracefully", () => {
|
||||
const payload = { ...THREE_ENGINE_PAYLOAD, comboId: null };
|
||||
const model = compressionEventToModel(payload);
|
||||
assert.equal(model.comboId, null);
|
||||
});
|
||||
|
||||
it("handles empty engineBreakdown", () => {
|
||||
const payload = { ...THREE_ENGINE_PAYLOAD, engineBreakdown: [] };
|
||||
const model = compressionEventToModel(payload);
|
||||
assert.equal(model.steps.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── compressionRunToFlow ──────────────────────────────────────────────────
|
||||
|
||||
describe("compressionRunToFlow", () => {
|
||||
it("returns N+2 nodes for N engine steps (input + N + output)", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const flow = compressionRunToFlow(model);
|
||||
|
||||
// 3 engine steps → 1 input + 3 engine + 1 output = 5 nodes
|
||||
assert.equal(flow.nodes.length, 5, `expected 5 nodes, got ${flow.nodes.length}`);
|
||||
});
|
||||
|
||||
it("node types: first=input, last=output, middle=engine", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const flow = compressionRunToFlow(model);
|
||||
|
||||
assert.equal(flow.nodes[0].type, "input");
|
||||
assert.equal(flow.nodes[flow.nodes.length - 1].type, "output");
|
||||
assert.equal(flow.nodes[1].type, "engine");
|
||||
assert.equal(flow.nodes[2].type, "engine");
|
||||
assert.equal(flow.nodes[3].type, "engine");
|
||||
});
|
||||
|
||||
it("edges connect nodes in sequence (N+1 edges for N+2 nodes)", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const flow = compressionRunToFlow(model);
|
||||
|
||||
// 5 nodes → 4 sequential edges
|
||||
assert.equal(flow.edges.length, 4, `expected 4 edges, got ${flow.edges.length}`);
|
||||
});
|
||||
|
||||
it("each edge source/target connects consecutive nodes", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const flow = compressionRunToFlow(model);
|
||||
|
||||
for (let i = 0; i < flow.edges.length; i++) {
|
||||
assert.equal(
|
||||
flow.edges[i].source,
|
||||
flow.nodes[i].id,
|
||||
`edge ${i} source should be node[${i}].id`
|
||||
);
|
||||
assert.equal(
|
||||
flow.edges[i].target,
|
||||
flow.nodes[i + 1].id,
|
||||
`edge ${i} target should be node[${i + 1}].id`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("engine nodes carry per-step data", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const flow = compressionRunToFlow(model);
|
||||
|
||||
// nodes[1] is the first engine step (lite)
|
||||
const liteNode = flow.nodes[1];
|
||||
assert.equal((liteNode.data as Record<string, unknown>).engine, "lite");
|
||||
assert.equal((liteNode.data as Record<string, unknown>).originalTokens, 1000);
|
||||
assert.equal((liteNode.data as Record<string, unknown>).compressedTokens, 900);
|
||||
assert.equal((liteNode.data as Record<string, unknown>).savingsPercent, 10);
|
||||
});
|
||||
|
||||
it("handles zero-engine model (only input + output)", () => {
|
||||
const payload = { ...THREE_ENGINE_PAYLOAD, engineBreakdown: [] };
|
||||
const model = compressionEventToModel(payload);
|
||||
const flow = compressionRunToFlow(model);
|
||||
|
||||
assert.equal(flow.nodes.length, 2);
|
||||
assert.equal(flow.edges.length, 1);
|
||||
assert.equal(flow.nodes[0].type, "input");
|
||||
assert.equal(flow.nodes[1].type, "output");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildReplayFrames ─────────────────────────────────────────────────────
|
||||
|
||||
describe("buildReplayFrames", () => {
|
||||
it("returns progressive snapshots (1 engine applied, 2 applied, ...)", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const frames = buildReplayFrames(model);
|
||||
|
||||
// 3 engines → 3 frames
|
||||
assert.equal(frames.length, 3, `expected 3 frames, got ${frames.length}`);
|
||||
});
|
||||
|
||||
it("frame[0] has 1 step, frame[1] has 2 steps, frame[2] has 3 steps", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const frames = buildReplayFrames(model);
|
||||
|
||||
assert.equal(frames[0].steps.length, 1);
|
||||
assert.equal(frames[1].steps.length, 2);
|
||||
assert.equal(frames[2].steps.length, 3);
|
||||
});
|
||||
|
||||
it("frames are independent objects (mutations don't bleed)", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const frames = buildReplayFrames(model);
|
||||
|
||||
// Mutating frame[0].steps should not affect frame[1]
|
||||
frames[0].steps.push({ engine: "fake" } as (typeof frames)[0]["steps"][0]);
|
||||
assert.equal(frames[1].steps.length, 2);
|
||||
});
|
||||
|
||||
it("each frame carries the correct compressedTokens up to that step", () => {
|
||||
const model = compressionEventToModel(THREE_ENGINE_PAYLOAD);
|
||||
const frames = buildReplayFrames(model);
|
||||
|
||||
// Frame 0: after lite — compressedTokens = 900
|
||||
assert.equal(frames[0].compressedTokens, 900);
|
||||
// Frame 1: after caveman — compressedTokens = 750
|
||||
assert.equal(frames[1].compressedTokens, 750);
|
||||
// Frame 2: after rtk — compressedTokens = 600
|
||||
assert.equal(frames[2].compressedTokens, 600);
|
||||
});
|
||||
|
||||
it("returns empty array for model with no steps", () => {
|
||||
const payload = { ...THREE_ENGINE_PAYLOAD, engineBreakdown: [] };
|
||||
const model = compressionEventToModel(payload);
|
||||
const frames = buildReplayFrames(model);
|
||||
assert.equal(frames.length, 0);
|
||||
});
|
||||
});
|
||||
50
tests/unit/ui/edgeStyles.test.ts
Normal file
50
tests/unit/ui/edgeStyles.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { edgeStyle, FLOW_EDGE_COLORS } from "../../../src/shared/components/flow/edgeStyles.ts";
|
||||
|
||||
describe("flow edgeStyles (U0 — extracted from ProviderTopology)", () => {
|
||||
it("exposes the shared flow palette", () => {
|
||||
assert.equal(FLOW_EDGE_COLORS.active, "#22c55e");
|
||||
assert.equal(FLOW_EDGE_COLORS.error, "#ef4444");
|
||||
assert.equal(FLOW_EDGE_COLORS.last, "#f59e0b");
|
||||
assert.equal(FLOW_EDGE_COLORS.idle, "var(--color-border)");
|
||||
});
|
||||
|
||||
it("styles an error edge", () => {
|
||||
assert.deepEqual(edgeStyle(false, false, true), {
|
||||
stroke: "#ef4444",
|
||||
strokeWidth: 2,
|
||||
opacity: 0.85,
|
||||
});
|
||||
});
|
||||
|
||||
it("styles an active edge", () => {
|
||||
assert.deepEqual(edgeStyle(true, false, false), {
|
||||
stroke: "#22c55e",
|
||||
strokeWidth: 2.5,
|
||||
opacity: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("styles a last-used edge", () => {
|
||||
assert.deepEqual(edgeStyle(false, true, false), {
|
||||
stroke: "#f59e0b",
|
||||
strokeWidth: 1.5,
|
||||
opacity: 0.6,
|
||||
});
|
||||
});
|
||||
|
||||
it("styles an idle edge", () => {
|
||||
assert.deepEqual(edgeStyle(false, false, false), {
|
||||
stroke: "var(--color-border)",
|
||||
strokeWidth: 1,
|
||||
opacity: 0.2,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies precedence error > active > last", () => {
|
||||
assert.equal(edgeStyle(true, true, true).stroke, "#ef4444"); // error wins
|
||||
assert.equal(edgeStyle(true, true, false).stroke, "#22c55e"); // active beats last
|
||||
});
|
||||
});
|
||||
141
tests/unit/ui/engineNode.test.tsx
Normal file
141
tests/unit/ui/engineNode.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// ── Polyfill ResizeObserver ────────────────────────────────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ReactFlow's Handle/Position require a ReactFlowProvider context.
|
||||
// We stub the xyflow module so EngineNode can render without the provider.
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = (await vi.importActual("@xyflow/react")) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
Handle: (_props: Record<string, unknown>) => null,
|
||||
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
|
||||
};
|
||||
});
|
||||
|
||||
// ── Import after mocks ─────────────────────────────────────────────────────
|
||||
|
||||
const { EngineNode } =
|
||||
await import("@/app/(dashboard)/dashboard/compression/studio/nodes/EngineNode");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Sample data ───────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_ENGINE_DATA = {
|
||||
id: "engine-0",
|
||||
type: "engine",
|
||||
position: { x: 200, y: 0 },
|
||||
data: {
|
||||
engine: "caveman",
|
||||
originalTokens: 900,
|
||||
compressedTokens: 729,
|
||||
savingsPercent: 19.0,
|
||||
techniquesUsed: ["filler-drop", "dedup"],
|
||||
durationMs: 5.3,
|
||||
stepState: "done" as const,
|
||||
label: "caveman",
|
||||
},
|
||||
};
|
||||
|
||||
// NodeProps-compatible shim
|
||||
const nodeProps = {
|
||||
...SAMPLE_ENGINE_DATA,
|
||||
selected: false,
|
||||
isConnectable: true,
|
||||
dragging: false,
|
||||
zIndex: 0,
|
||||
positionAbsoluteX: 200,
|
||||
positionAbsoluteY: 0,
|
||||
width: 180,
|
||||
height: 80,
|
||||
} as unknown as Parameters<typeof EngineNode>[0];
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EngineNode", () => {
|
||||
it("renders the engine name", () => {
|
||||
const container = mount(<EngineNode {...nodeProps} />);
|
||||
expect(container.textContent).toContain("caveman");
|
||||
});
|
||||
|
||||
it("shows the savings percentage", () => {
|
||||
const container = mount(<EngineNode {...nodeProps} />);
|
||||
const el = container.querySelector("[data-testid='savings-percent']");
|
||||
expect(el).toBeTruthy();
|
||||
expect(el?.textContent).toContain("19.0");
|
||||
});
|
||||
|
||||
it("shows technique names", () => {
|
||||
const container = mount(<EngineNode {...nodeProps} />);
|
||||
expect(container.textContent).toContain("filler-drop");
|
||||
});
|
||||
|
||||
it("marks a skipped node (tokensIn === tokensOut)", () => {
|
||||
const skippedProps = {
|
||||
...nodeProps,
|
||||
data: {
|
||||
...SAMPLE_ENGINE_DATA.data,
|
||||
compressedTokens: SAMPLE_ENGINE_DATA.data.originalTokens,
|
||||
savingsPercent: 0,
|
||||
stepState: "skipped" as const,
|
||||
},
|
||||
};
|
||||
const container = mount(<EngineNode {...skippedProps} />);
|
||||
expect(container.textContent).toContain("skip");
|
||||
});
|
||||
|
||||
it("renders layer pills for rtk engine", () => {
|
||||
const rtkProps = {
|
||||
...nodeProps,
|
||||
data: {
|
||||
...SAMPLE_ENGINE_DATA.data,
|
||||
engine: "rtk",
|
||||
stepState: "done" as const,
|
||||
},
|
||||
};
|
||||
const container = mount(<EngineNode {...rtkProps} />);
|
||||
// rtk maps to L3, L4
|
||||
expect(container.textContent).toContain("L3");
|
||||
expect(container.textContent).toContain("L4");
|
||||
});
|
||||
});
|
||||
161
tests/unit/ui/fleetAggregation.test.ts
Normal file
161
tests/unit/ui/fleetAggregation.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* tests/unit/ui/fleetAggregation.test.ts
|
||||
*
|
||||
* TDD for `aggregateComboEventsToSets` — fleet aggregation for Tela B U2.
|
||||
* Run: node --import tsx/esm --test tests/unit/ui/fleetAggregation.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { aggregateComboEventsToSets } from "../../../src/app/(dashboard)/dashboard/combos/live/fleetAggregation.ts";
|
||||
import type { ComboEventInput } from "../../../src/app/(dashboard)/dashboard/combos/live/comboFlowModel.ts";
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const WINDOW_MS = 10_000; // 10 seconds
|
||||
const NOW = 1_000_000; // fixed "now" for deterministic tests
|
||||
|
||||
function ev(
|
||||
type: "attempt" | "succeeded" | "failed",
|
||||
provider: string,
|
||||
timestampOffset: number // relative to NOW (negative = in the past)
|
||||
): ComboEventInput {
|
||||
return {
|
||||
comboName: "fleet-combo",
|
||||
targetIndex: 0,
|
||||
provider,
|
||||
model: "m",
|
||||
type,
|
||||
timestamp: NOW + timestampOffset,
|
||||
};
|
||||
}
|
||||
|
||||
// ── aggregateComboEventsToSets ────────────────────────────────────────────
|
||||
|
||||
describe("aggregateComboEventsToSets — basic categorization", () => {
|
||||
it("puts a recently failed provider in error set", () => {
|
||||
const events: ComboEventInput[] = [ev("failed", "openai", -1000)]; // 1s ago, within 10s window
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(error.has("openai"), "openai should be in error set");
|
||||
assert.ok(!active.has("openai"), "openai should not be in active set");
|
||||
assert.ok(!last.has("openai"), "openai should not be in last set");
|
||||
});
|
||||
|
||||
it("puts a recent attempt provider in active set", () => {
|
||||
const events: ComboEventInput[] = [ev("attempt", "anthropic", -500)];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(active.has("anthropic"), "anthropic should be in active set");
|
||||
assert.ok(!error.has("anthropic"));
|
||||
assert.ok(!last.has("anthropic"));
|
||||
});
|
||||
|
||||
it("puts a recent succeeded provider in active set", () => {
|
||||
const events: ComboEventInput[] = [ev("succeeded", "gemini", -2000)];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(active.has("gemini"), "gemini should be in active set");
|
||||
assert.ok(!error.has("gemini"));
|
||||
assert.ok(!last.has("gemini"));
|
||||
});
|
||||
|
||||
it("puts an old event provider in last set (outside window)", () => {
|
||||
const events: ComboEventInput[] = [ev("attempt", "cohere", -(WINDOW_MS + 1))]; // just outside window
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(last.has("cohere"), "cohere should be in last set");
|
||||
assert.ok(!active.has("cohere"));
|
||||
assert.ok(!error.has("cohere"));
|
||||
});
|
||||
|
||||
it("puts an old failed provider in last set (not error)", () => {
|
||||
const events: ComboEventInput[] = [ev("failed", "mistral", -(WINDOW_MS + 5000))];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(last.has("mistral"), "old failed should be last, not error");
|
||||
assert.ok(!error.has("mistral"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboEventsToSets — multiple providers and events", () => {
|
||||
it("handles multiple providers independently", () => {
|
||||
const events: ComboEventInput[] = [
|
||||
ev("failed", "openai", -500), // recent → error
|
||||
ev("succeeded", "gemini", -1000), // recent → active
|
||||
ev("attempt", "cohere", -(WINDOW_MS + 1)), // old → last
|
||||
];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(error.has("openai"));
|
||||
assert.ok(active.has("gemini"));
|
||||
assert.ok(last.has("cohere"));
|
||||
});
|
||||
|
||||
it("latest event for provider wins (error beats active for same provider)", () => {
|
||||
// Two events for openai: succeeded first (older), then failed (newer)
|
||||
const events: ComboEventInput[] = [
|
||||
ev("succeeded", "openai", -3000), // older, within window
|
||||
ev("failed", "openai", -500), // newer → should win
|
||||
];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(error.has("openai"), "latest event (failed) should win");
|
||||
assert.ok(!active.has("openai"));
|
||||
});
|
||||
|
||||
it("latest event for provider wins (active beats old failure when newer event is success)", () => {
|
||||
const events: ComboEventInput[] = [
|
||||
ev("failed", "openai", -3000), // older failed, within window
|
||||
ev("succeeded", "openai", -500), // newer success → should win
|
||||
];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.ok(active.has("openai"), "newer succeeded should win over older failed");
|
||||
assert.ok(!error.has("openai"));
|
||||
});
|
||||
|
||||
it("returns empty sets for empty events list", () => {
|
||||
const { active, error, last } = aggregateComboEventsToSets([], WINDOW_MS, NOW);
|
||||
|
||||
assert.equal(active.size, 0);
|
||||
assert.equal(error.size, 0);
|
||||
assert.equal(last.size, 0);
|
||||
});
|
||||
|
||||
it("a provider appears in at most one set", () => {
|
||||
const events: ComboEventInput[] = [
|
||||
ev("failed", "openai", -500),
|
||||
ev("attempt", "openai", -1000),
|
||||
];
|
||||
const { active, error, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
const inSets =
|
||||
(active.has("openai") ? 1 : 0) + (error.has("openai") ? 1 : 0) + (last.has("openai") ? 1 : 0);
|
||||
assert.equal(inSets, 1, "provider should be in exactly one set");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboEventsToSets — window boundary", () => {
|
||||
it("includes event exactly at the window boundary (now - windowMs) as 'last'", () => {
|
||||
const events: ComboEventInput[] = [ev("attempt", "boundary", -WINDOW_MS)];
|
||||
const { active, last } = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
// timestamp === NOW - WINDOW_MS: age === WINDOW_MS → not within (age < windowMs)
|
||||
assert.ok(
|
||||
last.has("boundary") || !active.has("boundary"),
|
||||
"boundary event should be last or absent from active"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call Date.now() — pure function (same output with same now)", () => {
|
||||
const events: ComboEventInput[] = [ev("failed", "openai", -500)];
|
||||
const r1 = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
const r2 = aggregateComboEventsToSets(events, WINDOW_MS, NOW);
|
||||
|
||||
assert.deepEqual([...r1.error], [...r2.error]);
|
||||
assert.deepEqual([...r1.active], [...r2.active]);
|
||||
assert.deepEqual([...r1.last], [...r2.last]);
|
||||
});
|
||||
});
|
||||
63
tests/unit/ui/flowCanvas.test.tsx
Normal file
63
tests/unit/ui/flowCanvas.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { FlowCanvas } from "@/shared/components/flow/FlowCanvas";
|
||||
|
||||
beforeAll(() => {
|
||||
// ReactFlow relies on ResizeObserver, which jsdom does not implement.
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const nodes = [
|
||||
{ id: "a", position: { x: 0, y: 0 }, data: { label: "A" } },
|
||||
{ id: "b", position: { x: 120, y: 0 }, data: { label: "B" } },
|
||||
];
|
||||
const edges = [{ id: "a-b", source: "a", target: "b" }];
|
||||
|
||||
describe("FlowCanvas (U0 — shared ReactFlow wrapper)", () => {
|
||||
it("renders the canvas with Controls and hides the attribution", () => {
|
||||
const container = mount(<FlowCanvas nodes={nodes} edges={edges} fitKey="x" />);
|
||||
expect(container.querySelector(".react-flow")).toBeTruthy();
|
||||
expect(container.querySelector(".react-flow__controls")).toBeTruthy();
|
||||
// proOptions.hideAttribution => the attribution element must not render.
|
||||
expect(container.querySelector(".react-flow__attribution")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies the provided container className for sizing/theming", () => {
|
||||
const container = mount(
|
||||
<FlowCanvas nodes={nodes} edges={edges} className="omni-test-canvas h-[300px]" />
|
||||
);
|
||||
expect(container.querySelector(".omni-test-canvas")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
184
tests/unit/ui/providerCascadeNode.test.tsx
Normal file
184
tests/unit/ui/providerCascadeNode.test.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// ── Polyfill ResizeObserver (required by ReactFlow) ───────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Stub @xyflow/react so ReactFlow renders without canvas/DOM measurement APIs
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = (await vi.importActual("@xyflow/react")) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
Handle: (_props: Record<string, unknown>) => null,
|
||||
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
|
||||
};
|
||||
});
|
||||
|
||||
// Stub next/image to avoid Next.js internals in jsdom
|
||||
vi.mock("next/image", () => ({
|
||||
default: (props: Record<string, unknown>) =>
|
||||
React.createElement("img", { src: props.src as string, alt: props.alt as string }),
|
||||
}));
|
||||
|
||||
// ── Import after mocks ─────────────────────────────────────────────────────
|
||||
|
||||
const { ProviderCascadeNode } =
|
||||
await import("@/app/(dashboard)/dashboard/combos/live/nodes/ProviderCascadeNode");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Helper: build NodeProps-compatible data ───────────────────────────────
|
||||
|
||||
function makeNodeProps(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: "test-node",
|
||||
type: "target",
|
||||
selected: false,
|
||||
selectable: true,
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
isConnectable: true,
|
||||
zIndex: 0,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
data: {
|
||||
label: "openai/gpt-4o",
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
state: "idle",
|
||||
targetIndex: 0,
|
||||
...overrides,
|
||||
},
|
||||
} as unknown as Parameters<typeof ProviderCascadeNode>[0];
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ProviderCascadeNode", () => {
|
||||
it("renders the provider name in idle state", () => {
|
||||
const container = mount(<ProviderCascadeNode {...makeNodeProps({ state: "idle" })} />);
|
||||
expect(container.querySelector("[data-testid='provider-name']")?.textContent).toBe("openai");
|
||||
});
|
||||
|
||||
it("renders the model name", () => {
|
||||
const container = mount(<ProviderCascadeNode {...makeNodeProps({ state: "idle" })} />);
|
||||
expect(container.querySelector("[data-testid='model-name']")?.textContent).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("renders the node wrapper in any state", () => {
|
||||
const container = mount(<ProviderCascadeNode {...makeNodeProps({ state: "succeeded" })} />);
|
||||
expect(container.querySelector("[data-testid='provider-cascade-node-0']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the failKind badge when state is failed and failKind is set", () => {
|
||||
const container = mount(
|
||||
<ProviderCascadeNode
|
||||
{...makeNodeProps({ state: "failed", failKind: "rate-limit", error: "429 rate limit" })}
|
||||
/>
|
||||
);
|
||||
const badge = container.querySelector("[data-testid='fail-kind-badge']");
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge?.textContent).toContain("rate-limit");
|
||||
});
|
||||
|
||||
it("shows circuit-open badge for circuit-open failKind", () => {
|
||||
const container = mount(
|
||||
<ProviderCascadeNode
|
||||
{...makeNodeProps({
|
||||
state: "failed",
|
||||
failKind: "circuit-open",
|
||||
error: "circuit open",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
const badge = container.querySelector("[data-testid='fail-kind-badge']");
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge?.textContent).toContain("circuit-open");
|
||||
});
|
||||
|
||||
it("shows cooldown badge for cooldown failKind", () => {
|
||||
const container = mount(
|
||||
<ProviderCascadeNode
|
||||
{...makeNodeProps({ state: "failed", failKind: "cooldown", error: "cooldown" })}
|
||||
/>
|
||||
);
|
||||
const badge = container.querySelector("[data-testid='fail-kind-badge']");
|
||||
expect(badge?.textContent).toContain("cooldown");
|
||||
});
|
||||
|
||||
it("does NOT show failKind badge in succeeded state", () => {
|
||||
const container = mount(
|
||||
<ProviderCascadeNode {...makeNodeProps({ state: "succeeded", failKind: "rate-limit" })} />
|
||||
);
|
||||
expect(container.querySelector("[data-testid='fail-kind-badge']")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show failKind badge in attempting state", () => {
|
||||
const container = mount(<ProviderCascadeNode {...makeNodeProps({ state: "attempting" })} />);
|
||||
expect(container.querySelector("[data-testid='fail-kind-badge']")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show failKind badge when state is failed but failKind is absent", () => {
|
||||
const container = mount(
|
||||
<ProviderCascadeNode {...makeNodeProps({ state: "failed", error: "some error" })} />
|
||||
);
|
||||
expect(container.querySelector("[data-testid='fail-kind-badge']")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders latency when provided", () => {
|
||||
const container = mount(
|
||||
<ProviderCascadeNode {...makeNodeProps({ state: "succeeded", latencyMs: 320 })} />
|
||||
);
|
||||
expect(container.textContent).toContain("320ms");
|
||||
});
|
||||
|
||||
it("renders a different provider correctly", () => {
|
||||
const container = mount(
|
||||
<ProviderCascadeNode
|
||||
{...makeNodeProps({ provider: "anthropic", model: "claude-3-opus", state: "idle" })}
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("[data-testid='provider-name']")?.textContent).toBe("anthropic");
|
||||
expect(container.querySelector("[data-testid='model-name']")?.textContent).toBe(
|
||||
"claude-3-opus"
|
||||
);
|
||||
});
|
||||
});
|
||||
58
tests/unit/ui/studio-pages.test.tsx
Normal file
58
tests/unit/ui/studio-pages.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import CompressionStudioPage from "@/app/(dashboard)/dashboard/compression/studio/page";
|
||||
import ComboLiveStudioPage from "@/app/(dashboard)/dashboard/combos/live/page";
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
// The live hooks may construct a WebSocket; a no-op keeps the smoke test offline.
|
||||
globalThis.WebSocket = class {
|
||||
readyState = 0;
|
||||
close() {}
|
||||
send() {}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
} as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) containers.pop()?.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("Studio route pages (Tela A / Tela B wiring)", () => {
|
||||
it("Compression Studio page mounts and shows the empty state without live data", () => {
|
||||
const container = mount(<CompressionStudioPage />);
|
||||
expect(container.querySelector('[data-testid="compression-cockpit-empty"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Combo Live Studio page mounts the studio shell without live data", () => {
|
||||
const container = mount(<ComboLiveStudioPage />);
|
||||
expect(container.querySelector('[data-testid="combo-live-studio"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
145
tests/unit/ui/waterfallInspector.test.tsx
Normal file
145
tests/unit/ui/waterfallInspector.test.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type { CompressionRunModel } from "@/app/(dashboard)/dashboard/compression/studio/compressionFlowModel";
|
||||
|
||||
// ── Import ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { WaterfallInspector } =
|
||||
await import("@/app/(dashboard)/dashboard/compression/studio/WaterfallInspector");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ── Sample run ────────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_RUN: CompressionRunModel = {
|
||||
requestId: "req-waterfall-001",
|
||||
comboId: "stacked-combo",
|
||||
mode: "stacked",
|
||||
originalTokens: 10000,
|
||||
compressedTokens: 5500,
|
||||
savingsPercent: 45.0,
|
||||
timestamp: 1718000001000,
|
||||
steps: [
|
||||
{
|
||||
engine: "rtk",
|
||||
originalTokens: 10000,
|
||||
compressedTokens: 8000,
|
||||
savingsPercent: 20.0,
|
||||
techniquesUsed: ["tool-output-trim"],
|
||||
durationMs: 2.1,
|
||||
},
|
||||
{
|
||||
engine: "caveman",
|
||||
originalTokens: 8000,
|
||||
compressedTokens: 6500,
|
||||
savingsPercent: 18.75,
|
||||
techniquesUsed: ["filler-drop", "dedup"],
|
||||
durationMs: 1.2,
|
||||
},
|
||||
{
|
||||
engine: "headroom",
|
||||
originalTokens: 6500,
|
||||
compressedTokens: 5500,
|
||||
savingsPercent: 15.38,
|
||||
techniquesUsed: ["json-compact"],
|
||||
durationMs: 0.8,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("WaterfallInspector", () => {
|
||||
it("renders the inspector root element", () => {
|
||||
const container = mount(<WaterfallInspector run={SAMPLE_RUN} />);
|
||||
expect(container.querySelector("[data-testid='waterfall-inspector']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders one row per step", () => {
|
||||
const container = mount(<WaterfallInspector run={SAMPLE_RUN} />);
|
||||
const rows = container.querySelectorAll("[data-testid='waterfall-step-row']");
|
||||
expect(rows.length).toBe(SAMPLE_RUN.steps.length);
|
||||
});
|
||||
|
||||
it("shows each engine name", () => {
|
||||
const container = mount(<WaterfallInspector run={SAMPLE_RUN} />);
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("rtk");
|
||||
expect(text).toContain("caveman");
|
||||
expect(text).toContain("headroom");
|
||||
});
|
||||
|
||||
it("shows savings text for each step", () => {
|
||||
const container = mount(<WaterfallInspector run={SAMPLE_RUN} />);
|
||||
const savingsEls = container.querySelectorAll("[data-testid='waterfall-savings-text']");
|
||||
// One savings element per non-skipped step
|
||||
expect(savingsEls.length).toBe(SAMPLE_RUN.steps.length);
|
||||
// First step savings
|
||||
expect(savingsEls[0]?.textContent).toContain("20.0");
|
||||
});
|
||||
|
||||
it("shows the total savings in the footer", () => {
|
||||
const container = mount(<WaterfallInspector run={SAMPLE_RUN} />);
|
||||
const totalEl = container.querySelector("[data-testid='waterfall-total-savings']");
|
||||
expect(totalEl).toBeTruthy();
|
||||
expect(totalEl?.textContent).toContain("45.0");
|
||||
});
|
||||
|
||||
it("marks a skipped step (tokensIn === tokensOut) with 'skip' label", () => {
|
||||
const runWithSkip: CompressionRunModel = {
|
||||
...SAMPLE_RUN,
|
||||
steps: [
|
||||
{
|
||||
engine: "llmlingua",
|
||||
originalTokens: 5500,
|
||||
compressedTokens: 5500, // no change — skipped
|
||||
savingsPercent: 0,
|
||||
techniquesUsed: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const container = mount(<WaterfallInspector run={runWithSkip} />);
|
||||
expect(container.textContent).toContain("skip");
|
||||
// No savings text for a skipped step
|
||||
const savingsEls = container.querySelectorAll("[data-testid='waterfall-savings-text']");
|
||||
expect(savingsEls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("shows the comboId in the summary bar", () => {
|
||||
const container = mount(<WaterfallInspector run={SAMPLE_RUN} />);
|
||||
expect(container.textContent).toContain("stacked-combo");
|
||||
});
|
||||
|
||||
it("shows the mode in the summary bar", () => {
|
||||
const container = mount(<WaterfallInspector run={SAMPLE_RUN} />);
|
||||
expect(container.textContent).toContain("stacked");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user