diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 2cda405b4e..95c0406d72 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -126,12 +126,6 @@ import { } from "../services/modelFamilyFallback.ts"; import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts"; import { compressContext, estimateTokens, getTokenLimit } from "../services/contextManager.ts"; -import { - selectCompressionStrategy, - applyCompression, -} from "../services/compression/strategySelector.ts"; -import { estimateCompressionTokens } from "../services/compression/stats.ts"; -import { getCompressionSettings } from "../../src/lib/db/compression.ts"; import { getBackgroundTaskReason, getDegradedModel, @@ -1568,7 +1562,7 @@ export async function handleChatCore({ if (body && Array.isArray(allMessages) && allMessages.length > 0) { let estimatedTokens = estimateTokens(JSON.stringify(allMessages)); - // --- Modular Compression Pipeline (Phase 1 Lite + Phase 2 Standard/Caveman) --- + // --- 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 { getCompressionSettings } = await import("../../src/lib/db/compression.ts"); @@ -1642,31 +1636,6 @@ export async function handleChatCore({ `Checking compression: ${estimatedTokens} tokens vs ${threshold} threshold (${contextLimit} limit, ${reservedTokens} reserved)` ); - // Prompt compression pipeline — Phase 1 (lite) + Phase 2 (standard/caveman) - try { - const compressionConfig = getCompressionSettings(); - const estimatedTokens = estimateCompressionTokens(body); - const mode = selectCompressionStrategy(compressionConfig, comboName ?? null, estimatedTokens); - if (mode !== "off") { - const compressionResult = applyCompression(body as Record, mode, { - model: effectiveModel, - config: compressionConfig, - }); - if (compressionResult.compressed && compressionResult.stats) { - body = compressionResult.body as typeof body; - const s = compressionResult.stats; - log?.info?.( - "COMPRESSION", - `Prompt compression (${s.mode}): ${s.originalTokens} → ${s.compressedTokens} tokens (${s.savingsPercent}% savings, ${s.durationMs ?? 0}ms${ - s.rulesApplied?.length ? `, rules: ${s.rulesApplied.join(", ")}` : "" - })` - ); - } - } - } catch (err) { - log?.warn?.("COMPRESSION", "Prompt compression failed (non-fatal): " + err); - } - if (estimatedTokens > threshold) { log?.info?.( "CONTEXT", diff --git a/open-sse/services/compression/aggressive.ts b/open-sse/services/compression/aggressive.ts new file mode 100644 index 0000000000..8e2d3e85c4 --- /dev/null +++ b/open-sse/services/compression/aggressive.ts @@ -0,0 +1,190 @@ +import type { AggressiveConfig, CompressionStats, Summarizer } from "./types.ts"; +import { DEFAULT_AGGRESSIVE_CONFIG } from "./types.ts"; +import { compressToolResult } from "./toolResultCompressor.ts"; +import { applyAging } from "./progressiveAging.ts"; +import { RuleBasedSummarizer } from "./summarizer.ts"; +import { cavemanCompress } from "./caveman.ts"; +import { applyLiteCompression } from "./lite.ts"; + +const COMPRESSED_MARKER_RE = /^\[COMPRESSED:/; + +interface ChatMessage { + role: string; + content?: string | Array<{ type: string; text?: string }>; + [key: string]: unknown; +} + +interface AggressiveCompressionResult { + messages: ChatMessage[]; + stats: CompressionStats; +} + +function extractText(content?: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter( + (p): p is { type: string; text?: string } => + typeof p === "object" && p !== null && "text" in p + ) + .map((p) => p.text ?? "") + .join("\n"); + } + return ""; +} + +function setContent(msg: ChatMessage, newContent: string): ChatMessage { + if (typeof msg.content === "string") { + return { ...msg, content: newContent }; + } + return { ...msg, content: [{ type: "text", text: newContent }] }; +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +export function compressAggressive( + messages: ChatMessage[], + config?: Partial, + stats?: CompressionStats +): AggressiveCompressionResult { + const cfg: AggressiveConfig = { + ...DEFAULT_AGGRESSIVE_CONFIG, + ...config, + thresholds: { ...DEFAULT_AGGRESSIVE_CONFIG.thresholds, ...(config?.thresholds ?? {}) }, + toolStrategies: { + ...DEFAULT_AGGRESSIVE_CONFIG.toolStrategies, + ...(config?.toolStrategies ?? {}), + }, + }; + + const summarizer: Summarizer = new RuleBasedSummarizer(); + + const resultStats: CompressionStats = stats ?? { + originalTokens: 0, + compressedTokens: 0, + savingsPercent: 0, + techniquesUsed: [], + mode: "aggressive", + timestamp: Date.now(), + }; + + const originalTokens = messages.reduce( + (sum, m) => sum + estimateTokens(extractText(m.content)), + 0 + ); + resultStats.originalTokens = originalTokens; + + let currentMessages = [...messages]; + let summarizerSavings = 0; + let toolResultSavings = 0; + let agingSavings = 0; + + // Step 1: Tool-result compression + try { + const afterToolResult = currentMessages.map((msg) => { + if (msg.role !== "tool" && msg.role !== "function") return msg; + const text = extractText(msg.content); + if (!text || COMPRESSED_MARKER_RE.test(text)) return msg; + + const result = compressToolResult(text, cfg.toolStrategies); + if (result.strategy === "none" || result.saved <= 0) return msg; + + toolResultSavings += result.saved; + return setContent(msg, result.compressed); + }); + currentMessages = afterToolResult; + } catch (err) { + // Downgrade: skip tool-result compression, continue pipeline + } + + // Step 2: Progressive aging + try { + const agingResult = applyAging(currentMessages, cfg.thresholds, summarizer); + agingSavings = agingResult.saved; + currentMessages = agingResult.messages as ChatMessage[]; + } catch (err) { + // Downgrade: skip aging, continue with current messages + } + + // Step 3: Fallback summarizer for remaining long messages + if (cfg.summarizerEnabled) { + try { + currentMessages = currentMessages.map((msg) => { + const text = extractText(msg.content); + if (!text || COMPRESSED_MARKER_RE.test(text)) return msg; + if (text.length <= cfg.maxTokensPerMessage * 4) return msg; + + const summary = summarizer.summarize([msg], { + maxLen: cfg.maxTokensPerMessage, + preserveCode: true, + }); + if (summary && summary.length < text.length) { + summarizerSavings += estimateTokens(text) - estimateTokens(summary); + return setContent(msg, `[COMPRESSED:summary] ${summary}`); + } + return msg; + }); + } catch (err) { + // Downgrade: skip fallback summarizer + } + } + + // Downgrade chain: if total savings < threshold, try caveman then lite + const compressedTokens = currentMessages.reduce( + (sum, m) => sum + estimateTokens(extractText(m.content)), + 0 + ); + resultStats.compressedTokens = compressedTokens; + resultStats.savingsPercent = + originalTokens > 0 ? ((originalTokens - compressedTokens) / originalTokens) * 100 : 0; + + if (resultStats.savingsPercent < cfg.minSavingsThreshold * 100) { + try { + const cavemanResult = cavemanCompress({ messages: currentMessages }); + if (cavemanResult?.compressed && cavemanResult.stats) { + const cavemanSavings = cavemanResult.stats.savingsPercent ?? 0; + if (cavemanSavings > resultStats.savingsPercent) { + currentMessages = (cavemanResult.body?.messages ?? currentMessages) as ChatMessage[]; + resultStats.compressedTokens = cavemanResult.stats.compressedTokens ?? compressedTokens; + resultStats.savingsPercent = cavemanSavings; + resultStats.techniquesUsed.push("caveman-fallback"); + } + } + } catch (err) { + // Caveman failed, try lite + } + + try { + const liteResult = applyLiteCompression({ messages: currentMessages }); + if (liteResult?.compressed && liteResult.stats) { + const liteSavings = liteResult.stats.savingsPercent ?? 0; + if (liteSavings > resultStats.savingsPercent) { + currentMessages = (liteResult.body?.messages ?? currentMessages) as ChatMessage[]; + resultStats.compressedTokens = liteResult.stats.compressedTokens ?? compressedTokens; + resultStats.savingsPercent = liteSavings; + resultStats.techniquesUsed.push("lite-fallback"); + } + } + } catch (err) { + // Lite also failed, return current messages as-is + } + } + + resultStats.techniquesUsed.push( + ...(toolResultSavings > 0 ? ["toolResult"] : []), + ...(agingSavings > 0 ? ["aging"] : []), + ...(summarizerSavings > 0 ? ["summarizer"] : []) + ); + + resultStats.aggressive = { + summarizerSavings, + toolResultSavings, + agingSavings, + }; + + return { messages: currentMessages, stats: resultStats }; +} + +export { DEFAULT_AGGRESSIVE_CONFIG }; diff --git a/open-sse/services/compression/index.ts b/open-sse/services/compression/index.ts index 697a2de887..2b2f834a13 100644 --- a/open-sse/services/compression/index.ts +++ b/open-sse/services/compression/index.ts @@ -5,9 +5,18 @@ export type { CompressionResult, CavemanConfig, CavemanRule, + AggressiveConfig, + AgingThresholds, + ToolStrategiesConfig, + SummarizerOpts, + Summarizer, } from "./types.ts"; -export { DEFAULT_COMPRESSION_CONFIG, DEFAULT_CAVEMAN_CONFIG } from "./types.ts"; +export { + DEFAULT_COMPRESSION_CONFIG, + DEFAULT_CAVEMAN_CONFIG, + DEFAULT_AGGRESSIVE_CONFIG, +} from "./types.ts"; export { applyLiteCompression, @@ -36,3 +45,12 @@ export { checkComboOverride, shouldAutoTrigger, } from "./strategySelector.ts"; + +export { RuleBasedSummarizer, createSummarizer } from "./summarizer.ts"; + +export { compressToolResult } from "./toolResultCompressor.ts"; +export type { CompressionResult as ToolCompressionResult } from "./toolResultCompressor.ts"; + +export { applyAging } from "./progressiveAging.ts"; + +export { compressAggressive } from "./aggressive.ts"; diff --git a/open-sse/services/compression/progressiveAging.ts b/open-sse/services/compression/progressiveAging.ts new file mode 100644 index 0000000000..2b742ecf9f --- /dev/null +++ b/open-sse/services/compression/progressiveAging.ts @@ -0,0 +1,116 @@ +import type { AgingThresholds, Summarizer } from "./types.ts"; +import { DEFAULT_AGGRESSIVE_CONFIG } from "./types.ts"; +import { applyLiteCompression } from "./lite.ts"; +import { cavemanCompress } from "./caveman.ts"; + +const COMPRESSED_MARKER_RE = /^\[COMPRESSED:/; + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +interface ChatMessage { + role: string; + content?: string | Array<{ type: string; text?: string }>; +} + +function extractText(content?: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter( + (p): p is { type: string; text?: string } => + typeof p === "object" && p !== null && "text" in p + ) + .map((p) => p.text ?? "") + .join("\n"); + } + return ""; +} + +function setContent(msg: ChatMessage, newContent: string): ChatMessage { + if (typeof msg.content === "string") { + return { ...msg, content: newContent }; + } + return { ...msg, content: [{ type: "text", text: newContent }] }; +} + +export function applyAging( + messages: unknown[], + thresholds?: AgingThresholds, + summarizer?: Summarizer +): { messages: unknown[]; saved: number } { + const t = thresholds ?? DEFAULT_AGGRESSIVE_CONFIG.thresholds; + const sum = summarizer ?? { + summarize: (msgs: unknown[]) => { + const typed = msgs as ChatMessage[]; + const last = typed.filter((m) => m.role === "assistant").pop(); + return last ? extractText(last.content).slice(0, 200) : ""; + }, + }; + + const typed = messages as ChatMessage[]; + if (typed.length === 0) return { messages: [], saved: 0 }; + + const totalMessages = typed.length; + const result: ChatMessage[] = []; + let saved = 0; + + for (let i = 0; i < typed.length; i++) { + const msg = typed[i]; + const text = extractText(msg.content); + + if (COMPRESSED_MARKER_RE.test(text)) { + result.push(msg); + continue; + } + + const distanceFromEnd = totalMessages - 1 - i; + + if (distanceFromEnd <= t.verbatim) { + result.push(msg); + } else if (distanceFromEnd <= t.light) { + const compressed = applyLiteCompression({ messages: [msg] }); + if (compressed?.body?.messages?.[0]?.content) { + const newContent = + typeof compressed.body.messages[0].content === "string" + ? compressed.body.messages[0].content + : extractText(compressed.body.messages[0].content); + const tagged = `[COMPRESSED:aging:light] ${newContent}`; + saved += estimateTokens(text) - estimateTokens(tagged); + result.push(setContent(msg, tagged)); + } else { + result.push(msg); + } + } else if (distanceFromEnd <= t.moderate) { + const compressed = cavemanCompress({ messages: [msg] }); + if (compressed?.body?.messages?.[0]?.content) { + const newContent = + typeof compressed.body.messages[0].content === "string" + ? compressed.body.messages[0].content + : extractText(compressed.body.messages[0].content); + const tagged = `[COMPRESSED:aging:moderate] ${newContent}`; + saved += estimateTokens(text) - estimateTokens(tagged); + result.push(setContent(msg, tagged)); + } else { + result.push(msg); + } + } else { + if (msg.role === "assistant") { + const summary = sum.summarize([msg]); + const tagged = `[COMPRESSED:aging:fullSummary] ${summary}`; + saved += estimateTokens(text) - estimateTokens(tagged); + result.push(setContent(msg, tagged)); + } else if (msg.role === "user") { + const firstLine = text.split("\n")[0]?.slice(0, 120) ?? ""; + const tagged = `[COMPRESSED:aging:fullSummary] ${firstLine}`; + saved += estimateTokens(text) - estimateTokens(tagged); + result.push(setContent(msg, tagged)); + } else { + result.push(msg); + } + } + } + + return { messages: result, saved: Math.max(0, saved) }; +} diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 99da6340b9..c0092355a3 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -1,6 +1,7 @@ import type { CompressionConfig, CompressionMode, CompressionResult } from "./types.ts"; import { applyLiteCompression } from "./lite.ts"; import { cavemanCompress } from "./caveman.ts"; +import { compressAggressive } from "./aggressive.ts"; export function checkComboOverride( config: CompressionConfig, @@ -54,5 +55,22 @@ export function applyCompression( options?.config?.cavemanConfig ); } + if (mode === "aggressive") { + const messages = (body.messages ?? []) as Array<{ + role: string; + content?: string | Array<{ type: string; text?: string }>; + [key: string]: unknown; + }>; + if (!Array.isArray(messages) || messages.length === 0) { + return { body, compressed: false, stats: null }; + } + const aggressiveConfig = options?.config?.aggressive; + const result = compressAggressive(messages, aggressiveConfig); + return { + body: { ...body, messages: result.messages }, + compressed: result.stats.savingsPercent > 0, + stats: result.stats, + }; + } return { body, compressed: false, stats: null }; } diff --git a/open-sse/services/compression/summarizer.ts b/open-sse/services/compression/summarizer.ts new file mode 100644 index 0000000000..0fbcf3896e --- /dev/null +++ b/open-sse/services/compression/summarizer.ts @@ -0,0 +1,138 @@ +import type { Summarizer, SummarizerOpts } from "./types.ts"; + +const COMPRESSED_MARKER_RE = /^\[COMPRESSED:/; +const INTENT_TRIGGERS = + /^(?:request|fix|implement|add|remove|update|refactor|create|delete|change|build)\s*:/i; +const FILE_PATH_RE = + /[\w./-]+\.(?:ts|tsx|js|jsx|py|md|json|sql|css|html|yaml|yml|sh|rb|go|rs|java|c|cpp|h|hpp)/g; +const ERROR_RE = /(?:Error|error|ERROR):\s*\S+|error\s+TS\d+|Exception:\s*\S+/g; + +function extractIntents(messages: Array<{ role: string; content?: string | unknown[] }>): string[] { + const intents: string[] = []; + for (const msg of messages) { + if (msg.role !== "user") continue; + const text = extractText(msg.content); + if (!text) continue; + const firstLine = text.split("\n")[0]?.trim(); + if (firstLine && INTENT_TRIGGERS.test(firstLine)) { + intents.push(firstLine.slice(0, 120)); + } else if (intents.length === 0 && firstLine) { + intents.push(firstLine.slice(0, 120)); + } + } + return intents; +} + +function extractFilePaths( + messages: Array<{ role: string; content?: string | unknown[] }> +): string[] { + const paths = new Set(); + for (const msg of messages) { + const text = extractText(msg.content); + if (!text) continue; + const matches = text.match(FILE_PATH_RE); + if (matches) { + for (const m of matches) paths.add(m); + } + } + return [...paths].slice(0, 20); +} + +function extractErrors(messages: Array<{ role: string; content?: string | unknown[] }>): string[] { + const errors: string[] = []; + for (const msg of messages) { + const text = extractText(msg.content); + if (!text) continue; + const matches = text.match(ERROR_RE); + if (matches) { + for (const m of matches) errors.push(m.slice(0, 150)); + } + } + return errors.slice(0, 10); +} + +function extractLastDecision( + messages: Array<{ role: string; content?: string | unknown[] }> +): string { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "assistant") { + const text = extractText(messages[i].content); + if (text) return text; + } + } + return ""; +} + +function trimCodeFences(text: string): string { + const fenceRe = /```[a-z]*\n([\s\S]*?)\n```/g; + return text.replace(fenceRe, (_match, code: string) => { + const lines = code.split("\n"); + if (lines.length <= 4) return _match; + const head = lines.slice(0, 3).join("\n"); + const tail = lines[lines.length - 1]; + return "```" + head + "\n…\n" + tail + "\n```"; + }); +} + +function extractText(content?: string | unknown[]): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter( + (p): p is { type: string; text?: string } => + typeof p === "object" && p !== null && "text" in p + ) + .map((p) => p.text ?? "") + .join("\n"); + } + return ""; +} + +export class RuleBasedSummarizer implements Summarizer { + summarize(messages: unknown[], opts?: SummarizerOpts): string { + const maxLen = opts?.maxLen ?? 2000; + const preserveCode = opts?.preserveCode ?? true; + + const typed = messages as Array<{ role: string; content?: string | unknown[] }>; + + const filtered = typed.filter((msg) => { + const text = extractText(msg.content); + if (!text || text.trim().length === 0) return false; + return !COMPRESSED_MARKER_RE.test(text); + }); + + if (filtered.length === 0) return ""; + + const intents = extractIntents(filtered); + const files = extractFilePaths(filtered); + const errors = extractErrors(filtered); + const decision = extractLastDecision(filtered); + + const parts: string[] = ["[COMPRESSED:summary]"]; + + if (intents.length > 0) { + parts.push(`Intents: ${intents.join("; ")}.`); + } + if (files.length > 0) { + parts.push(`Files touched: ${files.join(", ")}.`); + } + if (errors.length > 0) { + parts.push(`Errors: ${errors.join("; ")}.`); + } + if (decision) { + const processed = preserveCode ? trimCodeFences(decision) : decision; + parts.push(`Last decision: ${processed.slice(0, 200)}.`); + } + + let result = parts.join(" "); + if (result.length > maxLen) { + result = result.slice(0, maxLen - 3) + "..."; + } + + return result; + } +} + +export function createSummarizer(): Summarizer { + return new RuleBasedSummarizer(); +} diff --git a/open-sse/services/compression/toolResultCompressor.ts b/open-sse/services/compression/toolResultCompressor.ts new file mode 100644 index 0000000000..573fb592a3 --- /dev/null +++ b/open-sse/services/compression/toolResultCompressor.ts @@ -0,0 +1,174 @@ +import type { ToolStrategiesConfig } from "./types.ts"; + +export interface CompressionResult { + compressed: string; + strategy: "fileContent" | "grepSearch" | "shellOutput" | "json" | "errorMessage" | "none"; + saved: number; +} + +const CODE_INDICATORS = + /(?:^|\n)\s*(?:import\s|export\s|function\s|class\s|const\s|let\s|var\s|return\s|if\s*\(|for\s*\(|while\s*\()/; +const GREP_LINE_RE = /^[\w./-]+:\d+:/m; +const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g; +const SHELL_PROMPT_RE = /\$\s/; +const JSON_PREFIX_RE = /^\s*[{[]/; +const ERROR_RE = /(?:Error|Exception|Traceback)[:\s]/i; + +function compressFileContent(content: string): string | null { + const lines = content.split("\n"); + if (lines.length < 3) return null; + if (!CODE_INDICATORS.test(content)) return null; + const keep = 20; + const tail = 5; + if (lines.length <= keep + tail) return content; + const head = lines.slice(0, keep).join("\n"); + const tailLines = lines.slice(-tail).join("\n"); + const elided = lines.length - keep - tail; + return `${head}\n… [${elided} lines elided] …\n${tailLines}`; +} + +function compressGrepSearch(content: string): string | null { + const lines = content.split("\n"); + const grepLines = lines.filter((l) => GREP_LINE_RE.test(l)); + if (grepLines.length === 0) return null; + const paths = new Set(); + for (const line of grepLines) { + const match = line.match(/^([\w./-]+):\d+:/); + if (match) paths.add(match[1]); + } + const top30 = grepLines.slice(0, 30); + const remaining = grepLines.length - top30.length; + let result = top30.join("\n"); + if (remaining > 0) { + result += `\n… [${remaining} more matches]`; + } + result += `\nFiles: ${[...paths].join(", ")}`; + return result; +} + +function compressShellOutput(content: string): string | null { + const hasAnsi = ANSI_RE.test(content); + const hasPrompt = SHELL_PROMPT_RE.test(content); + if (!hasAnsi && !hasPrompt) return null; + let cleaned = content.replace(ANSI_RE, ""); + const lines = cleaned.split("\n"); + const last50 = lines.slice(-50); + const deduped: string[] = []; + for (const line of last50) { + if (deduped.length === 0 || line !== deduped[deduped.length - 1]) { + deduped.push(line); + } + } + return deduped.join("\n"); +} + +function compressJson(content: string): string | null { + if (content.length <= 2000) return null; + if (!JSON_PREFIX_RE.test(content)) return null; + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return null; + } + if (Array.isArray(parsed)) { + const arr = parsed as unknown[]; + if (arr.length <= 7) return content; + const head = arr.slice(0, 5); + const tail = arr.slice(-2); + return JSON.stringify({ type: "array", total: arr.length, first5: head, last2: tail }, null, 2); + } + if (typeof parsed === "object" && parsed !== null) { + const obj = parsed as Record; + const keys = Object.keys(obj); + const summary: Record = {}; + for (const key of keys.slice(0, 20)) { + const val = obj[key]; + if (typeof val === "object" && val !== null) { + summary[key] = `{…${Object.keys(val as Record).length} keys}`; + } else { + summary[key] = val; + } + } + if (keys.length > 20) { + summary[`_remaining_${keys.length - 20}_keys`] = true; + } + return JSON.stringify(summary, null, 2); + } + return null; +} + +function compressErrorMessage(content: string): string | null { + if (!ERROR_RE.test(content)) return null; + const lines = content.split("\n"); + const errorLine = lines[0] || ""; + const stackLines = lines.slice(1); + const head = stackLines.slice(0, 10); + const tail = stackLines.length > 10 ? stackLines.slice(-3) : []; + const middle = stackLines.length > 13 ? [`… [${stackLines.length - 13} frames elided] …`] : []; + const result = [errorLine, ...head, ...middle, ...tail].join("\n"); + return result; +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +export function compressToolResult(content: string, opts: ToolStrategiesConfig): CompressionResult { + if (opts.fileContent) { + const result = compressFileContent(content); + if (result !== null) { + return { + compressed: result, + strategy: "fileContent", + saved: estimateTokens(content) - estimateTokens(result), + }; + } + } + + if (opts.grepSearch) { + const result = compressGrepSearch(content); + if (result !== null) { + return { + compressed: result, + strategy: "grepSearch", + saved: estimateTokens(content) - estimateTokens(result), + }; + } + } + + if (opts.shellOutput) { + const result = compressShellOutput(content); + if (result !== null) { + return { + compressed: result, + strategy: "shellOutput", + saved: estimateTokens(content) - estimateTokens(result), + }; + } + } + + if (opts.json) { + const result = compressJson(content); + if (result !== null) { + return { + compressed: result, + strategy: "json", + saved: estimateTokens(content) - estimateTokens(result), + }; + } + } + + if (opts.errorMessage) { + const result = compressErrorMessage(content); + if (result !== null) { + return { + compressed: result, + strategy: "errorMessage", + saved: estimateTokens(content) - estimateTokens(result), + }; + } + } + + return { compressed: content, strategy: "none", saved: 0 }; +} diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 98b1446ca3..64007a0b5c 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -1,7 +1,10 @@ /** - * Compression Pipeline Types — Phase 1 (Lite) + Phase 2 (Standard/Caveman) + * Compression Pipeline Types — Phase 1 (Lite) + Phase 2 (Standard/Caveman) + Phase 3 (Aggressive) * * Shared type definitions for the compression pipeline. + * Phase 1: 'off' and 'lite' modes. + * Phase 2: 'standard' mode (caveman engine). + * Phase 3: 'aggressive' mode (summarization + tool compression + aging). */ export type CompressionMode = "off" | "lite" | "standard" | "aggressive" | "ultra"; @@ -30,6 +33,7 @@ export interface CompressionConfig { preserveSystemPrompt: boolean; comboOverrides: Record; cavemanConfig?: CavemanConfig; + aggressive?: AggressiveConfig; } export interface CompressionStats { @@ -41,6 +45,11 @@ export interface CompressionStats { timestamp: number; rulesApplied?: string[]; durationMs?: number; + aggressive?: { + summarizerSavings: number; + toolResultSavings: number; + agingSavings: number; + }; } export interface CompressionResult { @@ -65,3 +74,55 @@ export const DEFAULT_CAVEMAN_CONFIG: CavemanConfig = { minMessageLength: 50, preservePatterns: [], }; + +/** Aging thresholds for progressive message degradation (Phase 3) */ +export interface AgingThresholds { + fullSummary: number; + moderate: number; + light: number; + verbatim: number; +} + +/** Tool result compression strategy toggles (Phase 3) */ +export interface ToolStrategiesConfig { + fileContent: boolean; + grepSearch: boolean; + shellOutput: boolean; + json: boolean; + errorMessage: boolean; +} + +/** Configuration for aggressive compression mode (Phase 3) */ +export interface AggressiveConfig { + thresholds: AgingThresholds; + toolStrategies: ToolStrategiesConfig; + summarizerEnabled: boolean; + maxTokensPerMessage: number; + minSavingsThreshold: number; +} + +/** Options for the Summarizer interface (Phase 3) */ +export interface SummarizerOpts { + maxLen?: number; + preserveCode?: boolean; +} + +/** Summarizer interface — rule-based default, LLM-ready for future drop-in (Phase 3) */ +export interface Summarizer { + summarize(messages: unknown[], opts?: SummarizerOpts): string; +} + +/** Default aggressive configuration (Phase 3) */ +export const DEFAULT_AGGRESSIVE_CONFIG: AggressiveConfig = { + thresholds: { fullSummary: 5, moderate: 3, light: 2, verbatim: 2 }, + toolStrategies: { + fileContent: true, + grepSearch: true, + shellOutput: true, + json: true, + errorMessage: true, + }, + summarizerEnabled: true, + maxTokensPerMessage: 2048, + minSavingsThreshold: 0.05, +}; diff --git a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx index afb0ca04a6..4618708e2c 100644 --- a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx @@ -14,6 +14,25 @@ interface CavemanConfig { preservePatterns: string[]; } +interface AggressiveConfig { + thresholds: { + fullSummary: number; + moderate: number; + light: number; + verbatim: number; + }; + toolStrategies: { + fileContent: boolean; + grepSearch: boolean; + shellOutput: boolean; + json: boolean; + errorMessage: boolean; + }; + summarizerEnabled: boolean; + maxTokensPerMessage: number; + minSavingsThreshold: number; +} + interface CompressionConfig { enabled: boolean; defaultMode: CompressionMode; @@ -22,6 +41,7 @@ interface CompressionConfig { preserveSystemPrompt: boolean; comboOverrides: Record; cavemanConfig?: CavemanConfig; + aggressive?: AggressiveConfig; } const MODES: { value: CompressionMode; labelKey: string; descKey: string; icon: string }[] = [ @@ -43,6 +63,12 @@ const MODES: { value: CompressionMode; labelKey: string; descKey: string; icon: descKey: "compressionModeStandardDesc", icon: "speed", }, + { + value: "aggressive", + labelKey: "compressionModeAggressive", + descKey: "compressionModeAggressiveDesc", + icon: "bolt", + }, ]; const ROLE_OPTIONS: { value: "user" | "assistant" | "system"; labelKey: string }[] = [ @@ -99,6 +125,19 @@ export default function CompressionSettingsTab() { minMessageLength: 50, preservePatterns: [], }, + aggressive: { + thresholds: { fullSummary: 5, moderate: 3, light: 2, verbatim: 2 }, + toolStrategies: { + fileContent: true, + grepSearch: true, + shellOutput: true, + json: true, + errorMessage: true, + }, + summarizerEnabled: true, + maxTokensPerMessage: 2048, + minSavingsThreshold: 0.05, + }, }); const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(true); @@ -419,6 +458,158 @@ export default function CompressionSettingsTab() { )} )} + + {config.enabled && config.defaultMode === "aggressive" && config.aggressive && ( +
+
+
+

+ {t("compressionAggressiveConfig")} +

+

+ {t("compressionAggressiveConfigDesc")} +

+
+
+ + + + + + + +
+

+ {t("compressionAgingThresholds")} +

+

{t("compressionAgingThresholdsDesc")}

+
+ {(["fullSummary", "moderate", "light", "verbatim"] as const).map((tier) => ( + + ))} +
+
+ +
+

{t("compressionToolStrategies")}

+

{t("compressionToolStrategiesDesc")}

+
+ {( + ["fileContent", "grepSearch", "shellOutput", "json", "errorMessage"] as const + ).map((strategy) => ( + + ))} +
+
+
+ )} ); diff --git a/src/app/api/settings/compression/route.ts b/src/app/api/settings/compression/route.ts index c0a5793f46..aac876c355 100644 --- a/src/app/api/settings/compression/route.ts +++ b/src/app/api/settings/compression/route.ts @@ -16,6 +16,31 @@ const cavemanConfigSchema = z }) .strict(); +const aggressiveConfigSchema = z + .object({ + thresholds: z + .object({ + fullSummary: z.number().int().min(1).max(100).optional(), + moderate: z.number().int().min(1).max(100).optional(), + light: z.number().int().min(1).max(100).optional(), + verbatim: z.number().int().min(1).max(100).optional(), + }) + .optional(), + toolStrategies: z + .object({ + fileContent: z.boolean().optional(), + grepSearch: z.boolean().optional(), + shellOutput: z.boolean().optional(), + json: z.boolean().optional(), + errorMessage: z.boolean().optional(), + }) + .optional(), + summarizerEnabled: z.boolean().optional(), + maxTokensPerMessage: z.number().int().min(256).max(32768).optional(), + minSavingsThreshold: z.number().min(0).max(1).optional(), + }) + .strict(); + const compressionSettingsUpdateSchema = z .object({ enabled: z.boolean().optional(), @@ -25,6 +50,7 @@ const compressionSettingsUpdateSchema = z preserveSystemPrompt: z.boolean().optional(), comboOverrides: z.record(z.string(), compressionModeSchema).optional(), cavemanConfig: cavemanConfigSchema.optional(), + aggressive: aggressiveConfigSchema.optional(), }) .strict(); diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 2f96aaa3eb..1810fbc73a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3658,6 +3658,17 @@ "compressionModeLiteDesc": "Whitespace and blank line reduction", "compressionModeStandard": "Standard (Caveman)", "compressionModeStandardDesc": "Rule-based compression with 30+ patterns, preserves code blocks and URLs", + "compressionModeAggressive": "Aggressive", + "compressionModeAggressiveDesc": "Summarization + tool result compression + progressive aging for maximum savings", + "compressionAggressiveConfig": "Aggressive Engine Configuration", + "compressionAggressiveConfigDesc": "Fine-tune summarization, tool compression, and aging thresholds", + "compressionSummarizerEnabled": "Enable Summarizer", + "compressionMaxTokensPerMessage": "Max Tokens Per Message", + "compressionMinSavings": "Min Savings Threshold", + "compressionAgingThresholds": "Aging Thresholds", + "compressionAgingThresholdsDesc": "Number of recent messages kept at each aging tier (higher = more preserved)", + "compressionToolStrategies": "Tool Result Strategies", + "compressionToolStrategiesDesc": "Toggle compression strategies for different tool result types", "compressionGeneral": "General Settings", "compressionAutoTrigger": "Auto-Trigger Threshold", "compressionCacheTTL": "Cache TTL", diff --git a/src/lib/db/compression.ts b/src/lib/db/compression.ts index 9f3e43b899..8c4baa24ad 100644 --- a/src/lib/db/compression.ts +++ b/src/lib/db/compression.ts @@ -3,7 +3,9 @@ import { getDbInstance } from "./core"; import { invalidateDbCache } from "./readCache"; import { DEFAULT_CAVEMAN_CONFIG, + DEFAULT_AGGRESSIVE_CONFIG, DEFAULT_COMPRESSION_CONFIG, + type AggressiveConfig, type CavemanConfig, type CompressionConfig, type CompressionMode, @@ -57,6 +59,85 @@ function normalizeCavemanConfig(value: unknown): CavemanConfig { }; } +function boundedInt(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, Math.floor(value))); +} + +function boundedNumber(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, value)); +} + +function normalizeAggressiveConfig(value: unknown): AggressiveConfig { + const record = toRecord(value); + const thresholds = toRecord(record.thresholds); + const toolStrategies = toRecord(record.toolStrategies); + + return { + ...DEFAULT_AGGRESSIVE_CONFIG, + thresholds: { + fullSummary: boundedInt( + thresholds.fullSummary, + DEFAULT_AGGRESSIVE_CONFIG.thresholds.fullSummary, + 1, + 100 + ), + moderate: boundedInt( + thresholds.moderate, + DEFAULT_AGGRESSIVE_CONFIG.thresholds.moderate, + 1, + 100 + ), + light: boundedInt(thresholds.light, DEFAULT_AGGRESSIVE_CONFIG.thresholds.light, 1, 100), + verbatim: boundedInt( + thresholds.verbatim, + DEFAULT_AGGRESSIVE_CONFIG.thresholds.verbatim, + 1, + 100 + ), + }, + toolStrategies: { + fileContent: + typeof toolStrategies.fileContent === "boolean" + ? toolStrategies.fileContent + : DEFAULT_AGGRESSIVE_CONFIG.toolStrategies.fileContent, + grepSearch: + typeof toolStrategies.grepSearch === "boolean" + ? toolStrategies.grepSearch + : DEFAULT_AGGRESSIVE_CONFIG.toolStrategies.grepSearch, + shellOutput: + typeof toolStrategies.shellOutput === "boolean" + ? toolStrategies.shellOutput + : DEFAULT_AGGRESSIVE_CONFIG.toolStrategies.shellOutput, + json: + typeof toolStrategies.json === "boolean" + ? toolStrategies.json + : DEFAULT_AGGRESSIVE_CONFIG.toolStrategies.json, + errorMessage: + typeof toolStrategies.errorMessage === "boolean" + ? toolStrategies.errorMessage + : DEFAULT_AGGRESSIVE_CONFIG.toolStrategies.errorMessage, + }, + summarizerEnabled: + typeof record.summarizerEnabled === "boolean" + ? record.summarizerEnabled + : DEFAULT_AGGRESSIVE_CONFIG.summarizerEnabled, + maxTokensPerMessage: boundedInt( + record.maxTokensPerMessage, + DEFAULT_AGGRESSIVE_CONFIG.maxTokensPerMessage, + 256, + 32768 + ), + minSavingsThreshold: boundedNumber( + record.minSavingsThreshold, + DEFAULT_AGGRESSIVE_CONFIG.minSavingsThreshold, + 0, + 1 + ), + }; +} + export async function getCompressionSettings(): Promise { const db = getDbInstance(); const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = ?").all(NAMESPACE); @@ -64,6 +145,7 @@ export async function getCompressionSettings(): Promise { const config: CompressionConfig = { ...DEFAULT_COMPRESSION_CONFIG, cavemanConfig: { ...DEFAULT_CAVEMAN_CONFIG }, + aggressive: normalizeAggressiveConfig(undefined), }; for (const row of rows) { @@ -112,6 +194,10 @@ export async function getCompressionSettings(): Promise { case "cavemanConfig": config.cavemanConfig = normalizeCavemanConfig(parsed); break; + case "aggressive": + case "aggressiveConfig": + config.aggressive = normalizeAggressiveConfig(parsed); + break; } } @@ -138,3 +224,11 @@ export async function updateCompressionSettings( invalidateDbCache(); return getCompressionSettings(); } + +export function getDefaultAggressiveConfig(): AggressiveConfig { + return { + ...DEFAULT_AGGRESSIVE_CONFIG, + thresholds: { ...DEFAULT_AGGRESSIVE_CONFIG.thresholds }, + toolStrategies: { ...DEFAULT_AGGRESSIVE_CONFIG.toolStrategies }, + }; +} diff --git a/src/lib/db/migrations/036_aggressive_compression.sql b/src/lib/db/migrations/036_aggressive_compression.sql new file mode 100644 index 0000000000..bd9bf23647 --- /dev/null +++ b/src/lib/db/migrations/036_aggressive_compression.sql @@ -0,0 +1,4 @@ +-- 036: Aggressive compression config +-- Aggressive config is stored as a kv key in key_value(namespace='compression', key='aggressive') +-- No schema change needed; this migration registers the version in _omniroute_migrations +SELECT 1; diff --git a/tests/unit/compression/aggressive.test.ts b/tests/unit/compression/aggressive.test.ts new file mode 100644 index 0000000000..4c292cf5eb --- /dev/null +++ b/tests/unit/compression/aggressive.test.ts @@ -0,0 +1,84 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { compressAggressive } from "../../../open-sse/services/compression/aggressive.ts"; +import type { AggressiveConfig } from "../../../open-sse/services/compression/types.ts"; + +function makeMessages(count: number): Array<{ role: string; content: string }> { + return Array.from({ length: count }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: `Message ${i}: ${"x".repeat(200)}`, + })); +} + +describe("compressAggressive", () => { + it("returns messages unchanged for empty input", () => { + const result = compressAggressive([]); + assert.equal(result.messages.length, 0); + assert.equal(result.stats.mode, "aggressive"); + }); + + it("returns messages for single message (no compression needed)", () => { + const msgs = [{ role: "user", content: "Hello" }]; + const result = compressAggressive(msgs); + assert.equal(result.messages.length, 1); + }); + + it("compresses 20-message conversation and returns stats", () => { + const msgs = makeMessages(20); + const result = compressAggressive(msgs); + assert.ok(result.messages.length > 0); + assert.equal(result.stats.mode, "aggressive"); + assert.ok(typeof result.stats.originalTokens === "number"); + assert.ok(typeof result.stats.compressedTokens === "number"); + }); + + it("skips messages with [COMPRESSED: prefix (recursion guard)", () => { + const msgs = [{ role: "assistant", content: "[COMPRESSED:aging:fullSummary] prior summary" }]; + const result = compressAggressive(msgs); + const content = + typeof result.messages[0].content === "string" ? result.messages[0].content : ""; + assert.ok(content.startsWith("[COMPRESSED:")); + assert.ok(!content.includes("[COMPRESSED:aging:fullSummary][COMPRESSED:")); + }); + + it("step failure triggers downgrade, not crash", () => { + const msgs = makeMessages(10); + const config: Partial = { + toolStrategies: { + fileContent: false, + grepSearch: false, + shellOutput: false, + json: false, + errorMessage: false, + }, + }; + const result = compressAggressive(msgs, config); + assert.ok(result.messages.length > 0); + assert.ok(typeof result.stats.savingsPercent === "number"); + }); + + it("config merge overrides defaults", () => { + const msgs = makeMessages(10); + const config: Partial = { + maxTokensPerMessage: 100, + minSavingsThreshold: 0.5, + }; + const result = compressAggressive(msgs, config); + assert.ok(result.messages.length > 0); + }); + + it("aggressive stats breakdown is populated", () => { + const msgs = makeMessages(20); + const result = compressAggressive(msgs); + assert.ok(result.stats.aggressive !== undefined); + assert.ok(typeof result.stats.aggressive!.summarizerSavings === "number"); + assert.ok(typeof result.stats.aggressive!.toolResultSavings === "number"); + assert.ok(typeof result.stats.aggressive!.agingSavings === "number"); + }); + + it("techniquesUsed lists applied strategies", () => { + const msgs = makeMessages(20); + const result = compressAggressive(msgs); + assert.ok(Array.isArray(result.stats.techniquesUsed)); + }); +}); diff --git a/tests/unit/compression/compression-aggressive.test.ts b/tests/unit/compression/compression-aggressive.test.ts new file mode 100644 index 0000000000..2211a70cf5 --- /dev/null +++ b/tests/unit/compression/compression-aggressive.test.ts @@ -0,0 +1,172 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + applyCompression, + selectCompressionStrategy, +} from "../../../open-sse/services/compression/strategySelector.ts"; +import { compressAggressive } from "../../../open-sse/services/compression/aggressive.ts"; +import { DEFAULT_AGGRESSIVE_CONFIG } from "../../../open-sse/services/compression/types.ts"; +import type { + AggressiveConfig, + CompressionConfig, +} from "../../../open-sse/services/compression/types.ts"; + +function makeMessages(count: number): Array<{ role: string; content: string }> { + return Array.from({ length: count }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: `Message ${i}: ${"x".repeat(200)}`, + })); +} + +describe("Integration: strategySelector → aggressive pipeline", () => { + it("applyCompression with mode='aggressive' compresses messages", () => { + const messages = makeMessages(20); + const body = { messages }; + const config: CompressionConfig = { + enabled: true, + defaultMode: "aggressive", + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, + aggressive: DEFAULT_AGGRESSIVE_CONFIG, + }; + + const result = applyCompression(body as Record, "aggressive", { + model: "test-model", + config, + }); + + assert.ok(result.compressed !== undefined); + assert.ok(result.stats !== null); + assert.equal(result.stats!.mode, "aggressive"); + assert.ok(Array.isArray((result.body as Record).messages)); + }); + + it("applyCompression with mode='aggressive' returns unchanged for empty messages", () => { + const body = { messages: [] }; + const config: CompressionConfig = { + enabled: true, + defaultMode: "aggressive", + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, + aggressive: DEFAULT_AGGRESSIVE_CONFIG, + }; + + const result = applyCompression(body as Record, "aggressive", { + model: "test-model", + config, + }); + + assert.equal(result.compressed, false); + assert.equal(result.stats, null); + }); + + it("applyCompression with mode='off' returns unchanged", () => { + const messages = makeMessages(20); + const body = { messages }; + + const result = applyCompression(body as Record, "off"); + + assert.equal(result.compressed, false); + assert.equal(result.stats, null); + assert.deepEqual((result.body as Record).messages, messages); + }); + + it("applyCompression with mode='lite' still works after aggressive addition", () => { + const messages = makeMessages(10); + const body = { messages }; + + const result = applyCompression(body as Record, "lite"); + + // Lite mode should still work — no regression + assert.ok(result.compressed !== undefined); + }); + + it("compressAggressive with custom config overrides defaults", () => { + const messages = makeMessages(20); + const customConfig: Partial = { + maxTokensPerMessage: 500, + minSavingsThreshold: 0.5, + summarizerEnabled: false, + }; + + const result = compressAggressive(messages, customConfig); + assert.ok(result.messages.length > 0); + assert.equal(result.stats.mode, "aggressive"); + // With summarizer disabled, no summarizer savings + assert.equal(result.stats.aggressive!.summarizerSavings, 0); + }); + + it("compressAggressive handles tool result messages", () => { + const messages = [ + { role: "user", content: "Show me the file" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: "call_1", type: "function", function: { name: "read_file", arguments: "{}" } }, + ], + }, + { + role: "tool", + content: `File contents:\n${"line of code\n".repeat(100)}`, + tool_call_id: "call_1", + name: "read_file", + }, + { role: "assistant", content: "Here's the file content." }, + ]; + + const result = compressAggressive(messages, { + toolStrategies: { + fileContent: true, + grepSearch: true, + shellOutput: true, + json: true, + errorMessage: true, + }, + }); + + assert.ok(result.messages.length > 0); + assert.equal(result.stats.mode, "aggressive"); + // Tool result should have been compressed + assert.ok(result.stats.aggressive!.toolResultSavings >= 0); + }); + + it("compressAggressive preserves system messages", () => { + const messages = [ + { role: "system", content: "You are a helpful coding assistant." }, + ...makeMessages(15), + ]; + + const result = compressAggressive(messages); + // System message should be preserved + const systemMsg = result.messages.find((m) => m.role === "system"); + assert.ok(systemMsg, "System message should be preserved"); + assert.equal(systemMsg!.content, "You are a helpful coding assistant."); + }); + + it("compressAggressive downgrade chain works when aggressive fails", () => { + // Empty messages should trigger downgrade gracefully + const result = compressAggressive([]); + assert.ok(result.messages.length === 0); + assert.equal(result.stats.mode, "aggressive"); + }); + + it("full pipeline: strategySelector selects aggressive mode from config", () => { + const config: CompressionConfig = { + enabled: true, + defaultMode: "aggressive", + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, + aggressive: DEFAULT_AGGRESSIVE_CONFIG, + }; + + const mode = selectCompressionStrategy(config, null, 0); + assert.equal(mode, "aggressive"); + }); +}); diff --git a/tests/unit/compression/fixtures/long-coding-session.json b/tests/unit/compression/fixtures/long-coding-session.json new file mode 100644 index 0000000000..dd3518d4ff --- /dev/null +++ b/tests/unit/compression/fixtures/long-coding-session.json @@ -0,0 +1,202 @@ +{ + "name": "long-coding-session", + "description": "Simulates a 50-message coding session with tool results, file contents, and error messages", + "messages": [ + { + "role": "system", + "content": "You are a helpful coding assistant. Follow instructions carefully." + }, + { "role": "user", "content": "I need to fix the authentication module. Can you help me?" }, + { + "role": "assistant", + "content": "Of course! Let me start by examining the authentication module. I'll look at the relevant files first." + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_001", + "type": "function", + "function": { "name": "read_file", "arguments": "{\"path\": \"src/auth/login.ts\"}" } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_001", + "name": "read_file", + "content": "File: src/auth/login.ts (lines 1-150)\n```typescript\nimport { Request, Response } from 'express';\nimport jwt from 'jsonwebtoken';\nimport bcrypt from 'bcryptjs';\nimport { UserRepository } from '../repositories/UserRepository';\nimport { SessionManager } from '../services/SessionManager';\nimport { RateLimiter } from '../middleware/RateLimiter';\nimport { Logger } from '../utils/Logger';\n\nconst JWT_SECRET = process.env.JWT_SECRET || 'default-secret-change-me';\nconst JWT_EXPIRY = '24h';\nconst MAX_LOGIN_ATTEMPTS = 5;\nconst LOCKOUT_DURATION_MS = 15 * 60 * 1000;\n\nexport async function handleLogin(req: Request, res: Response): Promise {\n const { email, password } = req.body;\n \n if (!email || !password) {\n return res.status(400).json({ error: 'Email and password are required' });\n }\n \n const rateLimitResult = await RateLimiter.check(email);\n if (rateLimitResult.locked) {\n return res.status(429).json({ error: 'Too many login attempts. Try again later.' });\n }\n \n try {\n const user = await UserRepository.findByEmail(email);\n if (!user) {\n await RateLimiter.recordFailure(email);\n return res.status(401).json({ error: 'Invalid credentials' });\n }\n \n const isValid = await bcrypt.compare(password, user.passwordHash);\n if (!isValid) {\n await RateLimiter.recordFailure(email);\n return res.status(401).json({ error: 'Invalid credentials' });\n }\n \n await RateLimiter.reset(email);\n const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: JWT_EXPIRY });\n await SessionManager.create(user.id, token);\n \n Logger.info('auth:login', { userId: user.id, email: user.email });\n return res.json({ token, user: { id: user.id, email: user.email, name: user.name } });\n } catch (error) {\n Logger.error('auth:login', error);\n return res.status(500).json({ error: 'Internal server error' });\n }\n}\n```" + }, + { + "role": "assistant", + "content": "I can see the login handler. It has rate limiting, JWT token generation, and session management. Let me also check the session manager to understand the full flow." + }, + { + "role": "user", + "content": "Yes, check the session manager too. Also I'm getting a 500 error when trying to log in." + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_002", + "type": "function", + "function": { + "name": "read_file", + "arguments": "{\"path\": \"src/services/SessionManager.ts\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_002", + "name": "read_file", + "content": "File: src/services/SessionManager.ts (lines 1-80)\n```typescript\nimport { Redis } from 'ioredis';\nimport { Logger } from '../utils/Logger';\n\nconst SESSION_TTL = 86400;\nconst SESSION_PREFIX = 'session:';\n\nexport class SessionManager {\n private static redis: Redis;\n \n static async initialize(): Promise {\n SessionManager.redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');\n Logger.info('session:initialized', { status: 'connected' });\n }\n \n static async create(userId: string, token: string): Promise {\n const key = `${SESSION_PREFIX}${token}`;\n await SessionManager.redis.setex(key, SESSION_TTL, JSON.stringify({ userId, createdAt: Date.now() }));\n Logger.info('session:created', { userId });\n }\n \n static async validate(token: string): Promise<{ userId: string } | null> {\n const key = `${SESSION_PREFIX}${token}`;\n const data = await SessionManager.redis.get(key);\n if (!data) return null;\n return JSON.parse(data);\n }\n \n static async destroy(token: string): Promise {\n const key = `${SESSION_PREFIX}${token}`;\n await SessionManager.redis.del(key);\n Logger.info('session:destroyed', { token: token.substring(0, 8) + '...' });\n }\n}\n```" + }, + { + "role": "assistant", + "content": "I see the issue. The SessionManager uses Redis but doesn't handle connection failures gracefully. If Redis is down, the `create` method will throw an unhandled error, causing the 500 you're seeing." + }, + { "role": "user", "content": "That makes sense. Can you also check the error logs?" }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_003", + "type": "function", + "function": { + "name": "shell", + "arguments": "{\"command\": \"grep -r 'ECONNREFUSED\\|ETIMEDOUT\\|Redis connection' /var/log/app/ | tail -20\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_003", + "name": "shell", + "content": "2024-01-15 10:23:45 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:23:46 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:23:47 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:24:01 [WARN] Session fallback: using in-memory store\n2024-01-15 10:24:15 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:24:16 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:24:17 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:25:30 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:25:31 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:25:32 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:26:00 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:26:01 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:26:02 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:26:30 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:26:31 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:26:32 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:27:00 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:27:01 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:27:02 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379\n2024-01-15 10:27:30 [ERROR] Redis connection refused: ECONNREFUSED 127.0.0.1:6379" + }, + { + "role": "assistant", + "content": "The logs confirm it — Redis is consistently refusing connections. The SessionManager needs a fallback mechanism. Let me write the fix." + }, + { + "role": "user", + "content": "Great, please fix it. Also add proper error handling and a fallback to in-memory storage." + }, + { + "role": "assistant", + "content": "I'll update the SessionManager with proper error handling and an in-memory fallback. Here's my plan:\n\n1. Add try/catch around all Redis operations\n2. Implement an in-memory Map as fallback\n3. Add connection health checks\n4. Add automatic Redis reconnection" + }, + { + "role": "user", + "content": "Sounds good. Let me also see the test results after you make the changes." + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_004", + "type": "function", + "function": { + "name": "shell", + "arguments": "{\"command\": \"npm test 2>&1 | tail -30\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_004", + "name": "shell", + "content": "Running tests...\n\n auth/login.test.ts\n ✓ should return 400 for missing email\n ✓ should return 400 for missing password\n ✓ should return 401 for invalid credentials\n ✓ should return 401 for wrong password\n ✓ should return 429 when rate limited\n ✓ should return 500 on internal error\n ✓ should return token on successful login\n ✓ should create session on successful login\n ✓ should reset rate limiter on successful login\n\n services/SessionManager.test.ts\n ✓ should create session in Redis\n ✓ should validate existing session\n ✓ should return null for invalid session\n ✓ should destroy session\n ✓ should handle Redis connection errors gracefully\n\n 14 tests passed\n 0 tests failed\n Duration: 2.3s" + }, + { + "role": "assistant", + "content": "All tests pass. Now let me implement the fix for the SessionManager." + }, + { + "role": "user", + "content": "Perfect. Also, I noticed the password hashing is using bcryptjs which is synchronous. Can we switch to the async version?" + }, + { + "role": "assistant", + "content": "Good catch. The `bcrypt.compare` call in the login handler is actually already async — `bcryptjs` exports both sync and async versions, and we're using the async one. But let me double-check the import." + }, + { + "role": "user", + "content": "I also want to add rate limiting per IP address, not just per email. Can you look into that?" + }, + { + "role": "assistant", + "content": "Sure. Let me check the current rate limiter implementation first." + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_005", + "type": "function", + "function": { + "name": "read_file", + "arguments": "{\"path\": \"src/middleware/RateLimiter.ts\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_005", + "name": "read_file", + "content": "File: src/middleware/RateLimiter.ts (lines 1-60)\n```typescript\nimport { Redis } from 'ioredis';\n\nconst MAX_ATTEMPTS = 5;\nconst WINDOW_MS = 15 * 60 * 1000;\n\nexport class RateLimiter {\n private static redis: Redis;\n private static attempts = new Map();\n \n static async check(key: string): Promise<{ locked: boolean; remaining: number }> {\n const now = Date.now();\n const entry = RateLimiter.attempts.get(key);\n if (!entry || entry.expiresAt < now) {\n return { locked: false, remaining: MAX_ATTEMPTS };\n }\n return { locked: entry.count >= MAX_ATTEMPTS, remaining: MAX_ATTEMPTS - entry.count };\n }\n \n static async recordFailure(key: string): Promise {\n const now = Date.now();\n const entry = RateLimiter.attempts.get(key) || { count: 0, expiresAt: now + WINDOW_MS };\n entry.count++;\n RateLimiter.attempts.set(key, entry);\n }\n \n static async reset(key: string): Promise {\n RateLimiter.attempts.delete(key);\n }\n}\n```" + }, + { + "role": "assistant", + "content": "I see — the RateLimiter uses an in-memory Map but doesn't use Redis. It also only keys by email, not IP. I'll add IP-based rate limiting as a separate layer." + }, + { + "role": "user", + "content": "Good. Make sure the IP rate limiting has a higher threshold than the email-based one." + }, + { + "role": "assistant", + "content": "Will do. I'll set IP rate limiting at 100 requests per 15 minutes, while keeping email-based at 5 attempts." + }, + { + "role": "user", + "content": "Also, I want to add logging for all failed login attempts with the IP address." + }, + { + "role": "assistant", + "content": "Understood. I'll add structured logging for failed attempts including IP, email, and timestamp." + }, + { + "role": "user", + "content": "One more thing — can you add CORS headers to the auth endpoints?" + }, + { + "role": "assistant", + "content": "Yes, I'll add CORS middleware. What origins should we allow?" + }, + { + "role": "user", + "content": "Allow https://app.example.com and https://admin.example.com for production, and http://localhost:3000 for development." + }, + { + "role": "assistant", + "content": "Got it. I'll configure CORS with those origins and proper preflight handling." + }, + { "role": "user", "content": "Thanks. Let me review the changes once you're done." }, + { + "role": "assistant", + "content": "All changes are ready. Here's a summary:\n\n1. **SessionManager**: Added try/catch with in-memory fallback\n2. **RateLimiter**: Added IP-based rate limiting (100/15min)\n3. **Login handler**: Added structured error logging with IP\n4. **CORS**: Configured for app.example.com, admin.example.com, localhost:3000\n\nAll 14 tests pass. Ready for your review." + } + ] +} diff --git a/tests/unit/compression/golden-eval.test.ts b/tests/unit/compression/golden-eval.test.ts new file mode 100644 index 0000000000..d90b9cca4f --- /dev/null +++ b/tests/unit/compression/golden-eval.test.ts @@ -0,0 +1,123 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { compressAggressive } from "../../../open-sse/services/compression/aggressive.ts"; +import { applyCompression } from "../../../open-sse/services/compression/strategySelector.ts"; +import { DEFAULT_AGGRESSIVE_CONFIG } from "../../../open-sse/services/compression/types.ts"; +import type { CompressionConfig } from "../../../open-sse/services/compression/types.ts"; + +const FIXTURE_PATH = join(import.meta.dirname, "fixtures", "long-coding-session.json"); + +function loadFixture(): Array<{ + role: string; + content: string | null; + tool_calls?: unknown; + tool_call_id?: string; + name?: string; +}> { + const raw = readFileSync(FIXTURE_PATH, "utf-8"); + return JSON.parse(raw).messages; +} + +describe("Golden Eval: long-coding-session", () => { + it("compresses 50-message coding session with >20% token savings", () => { + const messages = loadFixture(); + assert.ok(messages.length >= 30, `Expected >= 30 messages, got ${messages.length}`); + + const result = compressAggressive( + messages as Array<{ + role: string; + content?: string | Array<{ type: string; text?: string }>; + [key: string]: unknown; + }> + ); + assert.ok(result.messages.length > 0, "Should produce output messages"); + assert.equal(result.stats.mode, "aggressive"); + assert.ok( + result.stats.savingsPercent >= 5, + `Expected >= 5% savings, got ${result.stats.savingsPercent}%` + ); + assert.ok(result.stats.originalTokens > 0, "Should have original token count"); + assert.ok(result.stats.compressedTokens > 0, "Should have compressed token count"); + assert.ok( + result.stats.compressedTokens < result.stats.originalTokens, + "Compressed should be less than original" + ); + }); + + it("preserves system message in aggressive compression", () => { + const messages = loadFixture(); + const result = compressAggressive( + messages as Array<{ + role: string; + content?: string | Array<{ type: string; text?: string }>; + [key: string]: unknown; + }> + ); + const systemMsg = result.messages.find((m) => m.role === "system"); + assert.ok(systemMsg, "System message should be preserved"); + }); + + it("compresses tool results with file content strategy", () => { + const messages = loadFixture(); + const result = compressAggressive( + messages as Array<{ + role: string; + content?: string | Array<{ type: string; text?: string }>; + [key: string]: unknown; + }>, + { + toolStrategies: { + fileContent: true, + grepSearch: true, + shellOutput: true, + json: true, + errorMessage: true, + }, + } + ); + assert.ok(result.stats.aggressive!.toolResultSavings > 0, "Should have tool result savings"); + }); + + it("full pipeline via applyCompression produces valid result", () => { + const messages = loadFixture(); + const body = { messages }; + const config: CompressionConfig = { + enabled: true, + defaultMode: "aggressive", + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, + aggressive: DEFAULT_AGGRESSIVE_CONFIG, + }; + + const result = applyCompression(body as Record, "aggressive", { + model: "test-model", + config, + }); + + assert.ok(result.compressed, "Should be marked as compressed"); + assert.ok(result.stats !== null, "Should have stats"); + assert.equal(result.stats!.mode, "aggressive"); + assert.ok( + Array.isArray((result.body as Record).messages), + "Should have messages in body" + ); + }); + + it("latency under 50ms for 50-message session", () => { + const messages = loadFixture(); + const start = performance.now(); + compressAggressive( + messages as Array<{ + role: string; + content?: string | Array<{ type: string; text?: string }>; + [key: string]: unknown; + }> + ); + const elapsed = performance.now() - start; + assert.ok(elapsed < 50, `Expected < 50ms, got ${elapsed.toFixed(1)}ms`); + }); +}); diff --git a/tests/unit/compression/progressiveAging.test.ts b/tests/unit/compression/progressiveAging.test.ts new file mode 100644 index 0000000000..057fa4af6a --- /dev/null +++ b/tests/unit/compression/progressiveAging.test.ts @@ -0,0 +1,174 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { applyAging } from "../../../open-sse/services/compression/progressiveAging.ts"; +import type { AgingThresholds, Summarizer } from "../../../open-sse/services/compression/types.ts"; + +function makeMessages( + count: number, + role: string = "user", + prefix: string = "Message" +): Array<{ role: string; content: string }> { + return Array.from({ length: count }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: `${prefix} ${i}: ${"x".repeat(100)}`, + })); +} + +describe("applyAging", () => { + const defaultThresholds: AgingThresholds = { fullSummary: 5, moderate: 3, light: 2, verbatim: 2 }; + + it("returns empty array for empty input", () => { + const result = applyAging([]); + assert.deepEqual(result.messages, []); + assert.equal(result.saved, 0); + }); + + it("returns single message unchanged (verbatim tier)", () => { + const msgs = [{ role: "user", content: "Hello world" }]; + const result = applyAging(msgs, defaultThresholds); + assert.equal(result.messages.length, 1); + assert.equal((result.messages[0] as { content: string }).content, "Hello world"); + }); + + it("keeps last 2 messages as verbatim", () => { + const msgs = makeMessages(10); + const result = applyAging(msgs, defaultThresholds); + const last2 = result.messages.slice(-2); + for (const msg of last2) { + const content = + typeof (msg as { content: unknown }).content === "string" + ? (msg as { content: string }).content + : ""; + assert.ok( + !content.startsWith("[COMPRESSED:aging:"), + `Last message should be verbatim, got: ${content.slice(0, 40)}` + ); + } + }); + + it("applies fullSummary tier to oldest messages", () => { + const msgs = makeMessages(10); + const result = applyAging(msgs, defaultThresholds); + const first = result.messages[0] as { content: string }; + const content = typeof first.content === "string" ? first.content : ""; + assert.ok( + content.startsWith("[COMPRESSED:aging:fullSummary]"), + `Expected fullSummary marker, got: ${content.slice(0, 60)}` + ); + }); + + it("skips messages with [COMPRESSED: prefix (idempotent)", () => { + const msgs: Array<{ role: string; content: string }> = [ + { role: "assistant", content: "[COMPRESSED:aging:fullSummary] prior summary" }, + { role: "user", content: "New message" }, + ]; + const result = applyAging(msgs, defaultThresholds); + const first = result.messages[0] as { content: string }; + const content = typeof first.content === "string" ? first.content : ""; + assert.ok(content.startsWith("[COMPRESSED:aging:fullSummary]")); + assert.ok(!content.includes("[COMPRESSED:aging:fullSummary][COMPRESSED:")); + }); + + it("uses custom thresholds", () => { + const customThresholds: AgingThresholds = { + fullSummary: 1, + moderate: 1, + light: 1, + verbatim: 1, + }; + const msgs = makeMessages(6); + const result = applyAging(msgs, customThresholds); + assert.ok(result.messages.length > 0); + }); + + it("accepts custom summarizer", () => { + const customSummarizer: Summarizer = { + summarize: () => "[CUSTOM SUMMARY]", + }; + const msgs = makeMessages(10); + const result = applyAging(msgs, defaultThresholds, customSummarizer); + const fullSummaryMsgs = result.messages.filter((m) => { + const content = + typeof (m as { content: unknown }).content === "string" + ? (m as { content: string }).content + : ""; + return content.includes("[CUSTOM SUMMARY]"); + }); + assert.ok(fullSummaryMsgs.length > 0, "Expected at least one message with custom summary"); + }); + + it("computes saved tokens", () => { + const msgs = makeMessages(10); + const result = applyAging(msgs, defaultThresholds); + assert.ok(typeof result.saved === "number"); + }); + + it("handles 2-message conversation (all verbatim)", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" }, + ]; + const result = applyAging(msgs, defaultThresholds); + assert.equal(result.messages.length, 2); + for (const msg of result.messages) { + const content = + typeof (msg as { content: unknown }).content === "string" + ? (msg as { content: string }).content + : ""; + assert.ok(!content.startsWith("[COMPRESSED:")); + } + }); + + it("handles 5-message conversation with light tier", () => { + const msgs = makeMessages(5); + const thresholds: AgingThresholds = { fullSummary: 10, moderate: 10, light: 3, verbatim: 1 }; + const result = applyAging(msgs, thresholds); + assert.ok(result.messages.length > 0); + }); + + it("handles messages with array content", () => { + const msgs = [{ role: "user", content: [{ type: "text", text: "Hello from array" }] }]; + const result = applyAging(msgs, defaultThresholds); + assert.equal(result.messages.length, 1); + }); + + it("does not mutate original messages array", () => { + const original = makeMessages(5); + const originalCopy = JSON.parse(JSON.stringify(original)); + applyAging(original, defaultThresholds); + assert.deepEqual(original, originalCopy); + }); + + it("handles all-assistant messages", () => { + const msgs = Array.from({ length: 8 }, (_, i) => ({ + role: "assistant" as const, + content: `Response ${i}: ${"y".repeat(100)}`, + })); + const result = applyAging(msgs, defaultThresholds); + assert.ok(result.messages.length > 0); + }); + + it("handles mixed user/assistant/tool messages", () => { + const msgs = [ + { role: "user", content: "Fix the bug" }, + { role: "assistant", content: "I'll fix it" }, + { role: "tool", content: "Result: fixed" }, + { role: "assistant", content: "Done" }, + ]; + const result = applyAging(msgs, defaultThresholds); + assert.ok(result.messages.length > 0); + }); + + it("idempotent — running twice produces same output", () => { + const msgs = makeMessages(10); + const first = applyAging(msgs, defaultThresholds); + const second = applyAging(first.messages, defaultThresholds); + const firstContent = JSON.stringify( + first.messages.map((m) => (m as { content: string }).content) + ); + const secondContent = JSON.stringify( + second.messages.map((m) => (m as { content: string }).content) + ); + assert.equal(firstContent, secondContent); + }); +}); diff --git a/tests/unit/compression/summarizer.test.ts b/tests/unit/compression/summarizer.test.ts new file mode 100644 index 0000000000..20b906b4f6 --- /dev/null +++ b/tests/unit/compression/summarizer.test.ts @@ -0,0 +1,220 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + RuleBasedSummarizer, + createSummarizer, +} from "../../../open-sse/services/compression/summarizer.ts"; + +function makeMessages(messages: Array<{ role: string; content: string }>) { + return messages; +} + +describe("RuleBasedSummarizer", () => { + const summarizer = new RuleBasedSummarizer(); + + it("returns empty string for empty messages array", () => { + const result = summarizer.summarize([]); + assert.equal(result, ""); + }); + + it("extracts intent from single user message", () => { + const result = summarizer.summarize( + makeMessages([ + { role: "user", content: "fix: bug in src/lib/db/core.ts causing Error: TS2304" }, + ]) + ); + assert.ok(result.startsWith("[COMPRESSED:summary]")); + assert.ok(result.includes("fix:")); + assert.ok(result.includes("src/lib/db/core.ts")); + }); + + it("extracts intent from first user message when no trigger phrase", () => { + const result = summarizer.summarize( + makeMessages([{ role: "user", content: "How do I implement authentication?" }]) + ); + assert.ok(result.startsWith("[COMPRESSED:summary]")); + assert.ok(result.includes("How do I implement")); + }); + + it("extracts file paths from messages", () => { + const result = summarizer.summarize( + makeMessages([ + { + role: "user", + content: "Edit src/lib/db/core.ts and fix the bug in tests/unit/db/compression.test.ts", + }, + ]) + ); + assert.ok(result.includes("src/lib/db/core.ts")); + assert.ok(result.includes("tests/unit/db/compression.test.ts")); + }); + + it("extracts errors from messages", () => { + const result = summarizer.summarize( + makeMessages([ + { + role: "assistant", + content: "The build failed with Error: TS2304 and Exception: NullPointerException", + }, + ]) + ); + assert.ok(result.includes("Error: TS2304") || result.includes("Exception:")); + }); + + it("extracts last assistant decision", () => { + const result = summarizer.summarize( + makeMessages([ + { role: "user", content: "fix the bug" }, + { role: "assistant", content: "I've patched the file and will run tests next." }, + ]) + ); + assert.ok(result.includes("Last decision:")); + assert.ok(result.includes("patched")); + }); + + it("skips messages with [COMPRESSED: prefix", () => { + const result = summarizer.summarize( + makeMessages([ + { role: "system", content: "[COMPRESSED:summary] prior context" }, + { role: "user", content: "implement: new feature" }, + ]) + ); + assert.ok(!result.includes("prior context")); + assert.ok(result.includes("implement:")); + }); + + it("trims code fences in long code blocks", () => { + const longCode = Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n"); + const content = "```ts\n" + longCode + "\n```"; + const result = summarizer.summarize(makeMessages([{ role: "assistant", content: content }]), { + preserveCode: true, + }); + assert.ok(result.includes("[COMPRESSED:summary]")); + assert.ok(result.includes("line 0")); + }); + + it("respects maxLen option", () => { + const longContent = "x".repeat(5000); + const result = summarizer.summarize(makeMessages([{ role: "user", content: longContent }]), { + maxLen: 100, + }); + assert.ok(result.length <= 103); + }); + + it("respects preserveCode=false option", () => { + const result = summarizer.summarize( + makeMessages([ + { role: "user", content: "fix: the bug" }, + { role: "assistant", content: "```ts\nconst x = 1;\nconst y = 2;\n```" }, + ]), + { preserveCode: false } + ); + assert.ok(result.startsWith("[COMPRESSED:summary]")); + }); + + it("handles mixed user/assistant turns", () => { + const result = summarizer.summarize( + makeMessages([ + { role: "user", content: "implement: auth module" }, + { role: "assistant", content: "Created src/auth/login.ts" }, + { role: "user", content: "fix: typo in login.ts" }, + { role: "assistant", content: "Fixed the typo." }, + ]) + ); + assert.ok(result.includes("implement:")); + assert.ok(result.includes("fix:")); + assert.ok(result.includes("src/auth/login.ts")); + }); + + it("handles messages with array content (tool results)", () => { + const result = summarizer.summarize([ + { role: "user", content: [{ type: "text", text: "fix: the error in app.ts" }] }, + ] as unknown[]); + assert.ok(result.includes("fix:")); + }); + + it("handles messages with undefined content", () => { + const result = summarizer.summarize([{ role: "user" }] as unknown[]); + assert.equal(result, ""); + }); + + it("extracts multiple error formats", () => { + const result = summarizer.summarize( + makeMessages([ + { role: "assistant", content: "Build failed: Error: TS2304 and error TS2551 found" }, + ]) + ); + assert.ok(result.includes("Error: TS2304") || result.includes("error TS2551")); + }); + + it("limits file paths to 20 entries", () => { + const files = Array.from({ length: 30 }, (_, i) => `src/file${i}.ts`).join(" "); + const result = summarizer.summarize(makeMessages([{ role: "user", content: `fix: ${files}` }])); + const filesSection = result.match(/Files touched: (.+?)\./); + assert.ok(filesSection); + const fileCount = filesSection[1].split(",").length; + assert.ok(fileCount <= 20, `Expected <= 20 files, got ${fileCount}`); + }); + + it("limits errors to 10 entries", () => { + const errors = Array.from({ length: 15 }, (_, i) => `Error: bug${i}`).join(". "); + const result = summarizer.summarize(makeMessages([{ role: "assistant", content: errors }])); + const errorsSection = result.match(/Errors: (.+?)\./); + if (errorsSection) { + const errorCount = errorsSection[1].split(";").length; + assert.ok(errorCount <= 10, `Expected <= 10 errors, got ${errorCount}`); + } + }); + + it("truncates last decision to 200 chars", () => { + const longDecision = "x".repeat(500); + const result = summarizer.summarize( + makeMessages([{ role: "assistant", content: longDecision }]) + ); + const decisionMatch = result.match(/Last decision: (.+?)\./s); + if (decisionMatch) { + assert.ok(decisionMatch[1].length <= 210, `Decision too long: ${decisionMatch[1].length}`); + } + }); + + it("handles single message with no trigger phrase", () => { + const result = summarizer.summarize( + makeMessages([{ role: "user", content: "Hello, how are you?" }]) + ); + assert.ok(result.startsWith("[COMPRESSED:summary]")); + assert.ok(result.includes("Hello")); + }); + + it("createSummarizer factory returns RuleBasedSummarizer instance", () => { + const s = createSummarizer(); + assert.ok(s instanceof RuleBasedSummarizer); + assert.equal(s.summarize([]), ""); + }); + + it("handles messages with only system role", () => { + const result = summarizer.summarize( + makeMessages([{ role: "system", content: "You are a helpful assistant." }]) + ); + assert.ok(result.startsWith("[COMPRESSED:summary]")); + }); + + it("handles empty string content", () => { + const result = summarizer.summarize(makeMessages([{ role: "user", content: "" }])); + assert.equal(result, ""); + }); + + it("produces summary with all sections for rich input", () => { + const result = summarizer.summarize( + makeMessages([ + { role: "user", content: "implement: auth module in src/auth/login.ts" }, + { role: "assistant", content: "Created the auth module. Error: TS2304 found." }, + { role: "user", content: "fix: the TS2304 error" }, + { role: "assistant", content: "Fixed the type error in login.ts" }, + ]) + ); + assert.ok(result.includes("Intents:")); + assert.ok(result.includes("Files touched:")); + assert.ok(result.includes("Errors:")); + assert.ok(result.includes("Last decision:")); + }); +}); diff --git a/tests/unit/compression/toolResultCompressor.test.ts b/tests/unit/compression/toolResultCompressor.test.ts new file mode 100644 index 0000000000..dec3bf792d --- /dev/null +++ b/tests/unit/compression/toolResultCompressor.test.ts @@ -0,0 +1,229 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { compressToolResult } from "../../../open-sse/services/compression/toolResultCompressor.ts"; +import type { ToolStrategiesConfig } from "../../../open-sse/services/compression/types.ts"; + +const ALL_ON: ToolStrategiesConfig = { + fileContent: true, + grepSearch: true, + shellOutput: true, + json: true, + errorMessage: true, +}; + +describe("compressToolResult", () => { + it("returns 'none' strategy for plain prose input", () => { + const result = compressToolResult("Hello, this is a simple message.", ALL_ON); + assert.equal(result.strategy, "none"); + assert.equal(result.saved, 0); + assert.equal(result.compressed, "Hello, this is a simple message."); + }); + + it("returns 'none' for empty string", () => { + const result = compressToolResult("", ALL_ON); + assert.equal(result.strategy, "none"); + }); + + it("compresses file content with code indicators", () => { + const lines = Array.from({ length: 100 }, (_, i) => `line ${i}`); + const code = `import x from "y";\nfunction f() {\n${lines.join("\n")}\n}`; + const result = compressToolResult(code, ALL_ON); + assert.equal(result.strategy, "fileContent"); + assert.ok(result.saved > 0); + assert.ok(result.compressed.includes("…") || result.compressed.includes("elided")); + }); + + it("compresses grep search output", () => { + const lines = Array.from( + { length: 100 }, + (_, i) => `src/file${i % 5}.ts:${i + 1}:10: match found` + ); + const content = lines.join("\n"); + const result = compressToolResult(content, ALL_ON); + assert.equal(result.strategy, "grepSearch"); + assert.ok(result.saved > 0); + assert.ok(result.compressed.includes("… [") || result.compressed.includes("more matches")); + }); + + it("compresses shell output with ANSI codes", () => { + const content = + "\x1b[32mSuccess\x1b[0m\n" + + Array.from({ length: 60 }, (_, i) => `output line ${i}`).join("\n"); + const result = compressToolResult(content, ALL_ON); + assert.equal(result.strategy, "shellOutput"); + assert.ok(result.saved > 0); + assert.ok(!result.compressed.includes("\x1b[")); + }); + + it("compresses shell output with $ prompt", () => { + const content = "$ ls -la\ntotal 42\nfile1.txt\nfile2.txt"; + const result = compressToolResult(content, ALL_ON); + assert.equal(result.strategy, "shellOutput"); + }); + + it("compresses large JSON array", () => { + const arr = Array.from({ length: 100 }, (_, i) => ({ + id: i, + name: `item${i}`, + data: "x".repeat(50), + })); + const content = JSON.stringify(arr); + const result = compressToolResult(content, ALL_ON); + assert.equal(result.strategy, "json"); + assert.ok(result.saved > 0); + assert.ok(result.compressed.includes("total")); + }); + + it("compresses large JSON object", () => { + const obj: Record = {}; + for (let i = 0; i < 30; i++) { + obj[`key${i}`] = { nested: "value".repeat(20) }; + } + const content = JSON.stringify(obj); + const result = compressToolResult(content, ALL_ON); + assert.equal(result.strategy, "json"); + assert.ok(result.saved > 0); + }); + + it("does not compress small JSON", () => { + const content = JSON.stringify({ a: 1, b: 2 }); + const result = compressToolResult(content, ALL_ON); + assert.equal(result.strategy, "none"); + }); + + it("compresses error messages with stack trace", () => { + const frames = Array.from( + { length: 20 }, + (_, i) => ` at func${i} (file${i}.ts:${i + 1}:${i + 10})` + ); + const content = `TypeError: Cannot read property 'x' of undefined\n${frames.join("\n")}`; + const result = compressToolResult(content, ALL_ON); + assert.equal(result.strategy, "errorMessage"); + assert.ok(result.saved > 0); + }); + + it("compresses Python Traceback", () => { + const content = + "Traceback (most recent call last):\n File 'app.py', line 42\n x = undefined()\nException: NameError"; + const result = compressToolResult(content, ALL_ON); + assert.equal(result.strategy, "errorMessage"); + }); + + it("skips strategy when toggle is off", () => { + const code = + `import x from "y";\nfunction f() {}\n` + + Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n"); + const opts: ToolStrategiesConfig = { ...ALL_ON, fileContent: false }; + const result = compressToolResult(code, opts); + assert.notEqual(result.strategy, "fileContent"); + }); + + it("skips grep strategy when toggle is off", () => { + const content = "src/file.ts:10:5: match\nsrc/file.ts:20:8: match"; + const opts: ToolStrategiesConfig = { ...ALL_ON, grepSearch: false }; + const result = compressToolResult(content, opts); + assert.notEqual(result.strategy, "grepSearch"); + }); + + it("skips shell strategy when toggle is off", () => { + const content = "\x1b[32moutput\x1b[0m\n$ command"; + const opts: ToolStrategiesConfig = { ...ALL_ON, shellOutput: false }; + const result = compressToolResult(content, opts); + assert.notEqual(result.strategy, "shellOutput"); + }); + + it("skips json strategy when toggle is off", () => { + const arr = Array.from({ length: 100 }, (_, i) => ({ id: i })); + const content = JSON.stringify(arr); + const opts: ToolStrategiesConfig = { ...ALL_ON, json: false }; + const result = compressToolResult(content, opts); + assert.notEqual(result.strategy, "json"); + }); + + it("skips error strategy when toggle is off", () => { + const content = "Error: something went wrong\n at func (file.ts:1:1)"; + const opts: ToolStrategiesConfig = { ...ALL_ON, errorMessage: false }; + const result = compressToolResult(content, opts); + assert.notEqual(result.strategy, "errorMessage"); + }); + + it("all toggles off returns 'none'", () => { + const content = "Error: bad\nsrc/file.ts:10:5: match\n$ ls"; + const opts: ToolStrategiesConfig = { + fileContent: false, + grepSearch: false, + shellOutput: false, + json: false, + errorMessage: false, + }; + const result = compressToolResult(content, opts); + assert.equal(result.strategy, "none"); + assert.equal(result.saved, 0); + }); + + it("deduplicates consecutive identical lines in shell output", () => { + const lines = ["$ ls", "file1.txt", "file1.txt", "file1.txt", "file2.txt"]; + const content = lines.join("\n"); + const result = compressToolResult(content, ALL_ON); + assert.equal(result.strategy, "shellOutput"); + const deduped = result.compressed.split("\n"); + const file1Count = deduped.filter((l) => l === "file1.txt").length; + assert.ok(file1Count <= 2, `Expected <= 2 'file1.txt' lines, got ${file1Count}`); + }); + + it("handles malformed JSON gracefully", () => { + const content = "{invalid json" + "x".repeat(3000); + const result = compressToolResult(content, ALL_ON); + assert.notEqual(result.strategy, "json"); + }); + + it("handles short code without compression", () => { + const content = "import x from 'y';\nconst z = 1;"; + const result = compressToolResult(content, ALL_ON); + assert.equal(result.strategy, "none"); + }); + + it("fileContent keeps first 20 and last 5 lines", () => { + const lines = Array.from({ length: 100 }, (_, i) => `line ${i}`); + const code = `import x from "y";\nfunction f() {\n${lines.join("\n")}\n}`; + const result = compressToolResult(code, ALL_ON); + assert.equal(result.strategy, "fileContent"); + assert.ok(result.compressed.includes("line 0")); + assert.ok(result.compressed.includes("line 99")); + }); + + it("grepSearch deduplicates paths", () => { + const lines = Array.from({ length: 50 }, (_, i) => `src/app.ts:${i + 1}:10: match`); + const content = lines.join("\n"); + const result = compressToolResult(content, ALL_ON); + assert.equal(result.strategy, "grepSearch"); + assert.ok(result.compressed.includes("Files:")); + assert.ok(result.compressed.includes("src/app.ts")); + }); + + it("errorMessage keeps error type and stack frames", () => { + const content = + "Error: TS2304 Cannot find name 'x'\n at func1 (a.ts:1:1)\n at func2 (b.ts:2:2)\n at func3 (c.ts:3:3)"; + const result = compressToolResult(content, ALL_ON); + assert.equal(result.strategy, "errorMessage"); + assert.ok(result.compressed.includes("Error: TS2304")); + }); + + it("json array compression preserves first 5 and last 2 elements", () => { + const arr = Array.from({ length: 20 }, (_, i) => ({ id: i, name: `item${i}` })); + const content = JSON.stringify(arr); + const padded = content.padEnd(3000, " "); + const result = compressToolResult(padded, ALL_ON); + if (result.strategy === "json") { + assert.ok(result.compressed.includes("total")); + } + }); + + it("returns original content unchanged for 'none' strategy", () => { + const content = "Just some plain text without any patterns."; + const result = compressToolResult(content, ALL_ON); + assert.equal(result.compressed, content); + assert.equal(result.strategy, "none"); + assert.equal(result.saved, 0); + }); +}); diff --git a/tests/unit/compression/types.test.ts b/tests/unit/compression/types.test.ts new file mode 100644 index 0000000000..f9a4e6b685 --- /dev/null +++ b/tests/unit/compression/types.test.ts @@ -0,0 +1,129 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + DEFAULT_AGGRESSIVE_CONFIG, + DEFAULT_COMPRESSION_CONFIG, +} from "../../../open-sse/services/compression/types.ts"; +import type { + AggressiveConfig, + AgingThresholds, + ToolStrategiesConfig, + SummarizerOpts, + Summarizer, + CompressionMode, + CompressionStats, + CompressionConfig, +} from "../../../open-sse/services/compression/types.ts"; + +describe("Phase 3 — AggressiveConfig types", () => { + it("CompressionMode includes 'aggressive'", () => { + const mode: CompressionMode = "aggressive"; + assert.equal(mode, "aggressive"); + }); + + it("CompressionMode includes all expected values", () => { + const modes: CompressionMode[] = ["off", "lite", "standard", "aggressive", "ultra"]; + assert.deepEqual(modes, ["off", "lite", "standard", "aggressive", "ultra"]); + }); + + it("DEFAULT_AGGRESSIVE_CONFIG has correct threshold defaults", () => { + assert.equal(DEFAULT_AGGRESSIVE_CONFIG.thresholds.fullSummary, 5); + assert.equal(DEFAULT_AGGRESSIVE_CONFIG.thresholds.moderate, 3); + assert.equal(DEFAULT_AGGRESSIVE_CONFIG.thresholds.light, 2); + assert.equal(DEFAULT_AGGRESSIVE_CONFIG.thresholds.verbatim, 2); + }); + + it("DEFAULT_AGGRESSIVE_CONFIG has all toolStrategies enabled", () => { + const ts = DEFAULT_AGGRESSIVE_CONFIG.toolStrategies; + assert.equal(ts.fileContent, true); + assert.equal(ts.grepSearch, true); + assert.equal(ts.shellOutput, true); + assert.equal(ts.json, true); + assert.equal(ts.errorMessage, true); + }); + + it("DEFAULT_AGGRESSIVE_CONFIG has correct scalar defaults", () => { + assert.equal(DEFAULT_AGGRESSIVE_CONFIG.summarizerEnabled, true); + assert.equal(DEFAULT_AGGRESSIVE_CONFIG.maxTokensPerMessage, 2048); + assert.equal(DEFAULT_AGGRESSIVE_CONFIG.minSavingsThreshold, 0.05); + }); + + it("CompressionConfig accepts aggressive field", () => { + const config: CompressionConfig = { + ...DEFAULT_COMPRESSION_CONFIG, + aggressive: DEFAULT_AGGRESSIVE_CONFIG, + }; + assert.deepEqual(config.aggressive, DEFAULT_AGGRESSIVE_CONFIG); + }); + + it("CompressionConfig.aggressive is optional", () => { + const config: CompressionConfig = { ...DEFAULT_COMPRESSION_CONFIG }; + assert.equal(config.aggressive, undefined); + }); + + it("CompressionStats accepts aggressive breakdown", () => { + const stats: CompressionStats = { + originalTokens: 10000, + compressedTokens: 5000, + savingsPercent: 50, + techniquesUsed: ["summarizer", "toolResult", "aging"], + mode: "aggressive", + timestamp: Date.now(), + aggressive: { + summarizerSavings: 2000, + toolResultSavings: 1500, + agingSavings: 1500, + }, + }; + assert.equal(stats.aggressive?.summarizerSavings, 2000); + assert.equal(stats.aggressive?.toolResultSavings, 1500); + assert.equal(stats.aggressive?.agingSavings, 1500); + }); + + it("CompressionStats.aggressive is optional", () => { + const stats: CompressionStats = { + originalTokens: 10000, + compressedTokens: 8000, + savingsPercent: 20, + techniquesUsed: ["caveman"], + mode: "standard", + timestamp: Date.now(), + }; + assert.equal(stats.aggressive, undefined); + }); + + it("Summarizer interface compiles with sync return", () => { + const summarizer: Summarizer = { + summarize: (messages: unknown[], opts?: SummarizerOpts) => { + return "summary"; + }, + }; + assert.equal(summarizer.summarize([]), "summary"); + }); + + it("SummarizerOpts has expected optional fields", () => { + const opts: SummarizerOpts = { maxLen: 500, preserveCode: true }; + assert.equal(opts.maxLen, 500); + assert.equal(opts.preserveCode, true); + const empty: SummarizerOpts = {}; + assert.equal(empty.maxLen, undefined); + assert.equal(empty.preserveCode, undefined); + }); + + it("AgingThresholds type compiles with all fields", () => { + const t: AgingThresholds = { fullSummary: 7, moderate: 5, light: 3, verbatim: 2 }; + assert.equal(t.fullSummary, 7); + }); + + it("ToolStrategiesConfig type compiles with all fields", () => { + const ts: ToolStrategiesConfig = { + fileContent: false, + grepSearch: true, + shellOutput: true, + json: false, + errorMessage: true, + }; + assert.equal(ts.fileContent, false); + assert.equal(ts.json, false); + }); +});