diff --git a/tests/integration/chatcore-compression-integration.test.ts b/tests/integration/chatcore-compression-integration.test.ts deleted file mode 100644 index 13adcd92e6..0000000000 --- a/tests/integration/chatcore-compression-integration.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-compression-")); -process.env.DATA_DIR = TEST_DATA_DIR; -process.env.REQUIRE_API_KEY = "false"; -process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-compression-secret"; - -const core = await import("../../src/lib/db/core.ts"); -const providersDb = await import("../../src/lib/db/providers.ts"); -const readCacheDb = await import("../../src/lib/db/readCache.ts"); -const combosDb = await import("../../src/lib/db/combos.ts"); -const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); -const { estimateTokens, getTokenLimit } = await import("../../open-sse/services/contextManager.ts"); -const { resetAllAvailability } = await import("../../src/domain/modelAvailability.ts"); -const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); - -const originalFetch = globalThis.fetch; - -async function resetStorage() { - globalThis.fetch = originalFetch; - resetAllAvailability(); - resetAllCircuitBreakers(); - readCacheDb.invalidateDbCache(); - await new Promise((resolve) => setTimeout(resolve, 20)); - core.resetDbInstance(); - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); - fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); -} - -test.beforeEach(async () => { - await resetStorage(); -}); - -test.after(async () => { - globalThis.fetch = originalFetch; - core.closeDbInstance(); - try { - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); - } catch {} -}); - -test("chatCore integration: compressContext called proactively when context exceeds 85% threshold", async () => { - const provider = "openai"; - const model = "gpt-4"; - - // Create multiple messages with history that can be compressed - // Use the same pattern as test 3 which successfully tests compression - const body = { - model, - messages: [ - { role: "system", content: "You are helpful." }, - { role: "user", content: "x".repeat(50000) }, - { role: "assistant", content: "Response 1" }, - { role: "user", content: "x".repeat(50000) }, - { role: "assistant", content: "Response 2" }, - { role: "user", content: "x".repeat(50000) }, - { role: "assistant", content: "Response 3" }, - { role: "user", content: "Final question" }, - ], - }; - - // Create provider connection - const connectionId = await providersDb.createProviderConnection({ - provider, - apiKey: "test-key", - isActive: true, - }); - - // Mock fetch to capture the request - let capturedBody: any = null; - globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) => { - if (init?.body) { - capturedBody = JSON.parse(init.body as string); - } - return new Response( - JSON.stringify({ - choices: [{ message: { role: "assistant", content: "test" } }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }), - { - status: 200, - headers: { "content-type": "application/json" }, - } - ); - }; - - try { - const result = await handleChatCore({ - body, - modelInfo: { provider, model }, - credentials: { apiKey: "test-key" }, - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }, - clientRawRequest: { endpoint: "/v1/chat/completions", headers: new Map() }, - connectionId, - }); - - assert.ok(result.success, "Request should succeed"); - assert.ok(capturedBody, "Fetch should have been called"); - - // Verify that compression preserved the message structure - assert.ok(Array.isArray(capturedBody.messages), "Messages should remain an array"); - assert.ok(capturedBody.messages.length > 0, "Messages should not be empty"); - - // Verify that the final question was preserved (compression keeps recent messages) - const lastMessage = capturedBody.messages[capturedBody.messages.length - 1]; - assert.equal(lastMessage.content, "Final question", "Last user message should be preserved"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("chatCore integration: compressContext NOT called when context is below 85% threshold", async () => { - const provider = "openai"; - const model = "gpt-4"; - const contextLimit = getTokenLimit(provider, model); - const threshold = Math.floor(contextLimit * 0.85); - - const smallMessage = "Hello, how are you?"; - const body = { - model, - messages: [ - { role: "system", content: "You are helpful." }, - { role: "user", content: smallMessage }, - ], - }; - - const estimatedTokens = estimateTokens(JSON.stringify(body.messages)); - assert.ok( - estimatedTokens < threshold, - `Expected ${estimatedTokens} to be below threshold ${threshold}` - ); - - // Create provider connection - const connectionId = await providersDb.createProviderConnection({ - provider, - apiKey: "test-key", - isActive: true, - }); - - // Mock fetch to capture the request - let capturedBody: any = null; - globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) => { - if (init?.body) { - capturedBody = JSON.parse(init.body as string); - } - return new Response( - JSON.stringify({ - choices: [{ message: { role: "assistant", content: "test" } }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }), - { - status: 200, - headers: { "content-type": "application/json" }, - } - ); - }; - - try { - const result = await handleChatCore({ - body, - modelInfo: { provider, model }, - credentials: { apiKey: "test-key" }, - log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, - clientRawRequest: { endpoint: "/v1/chat/completions", headers: new Map() }, - connectionId, - }); - - assert.ok(result.success, "Request should succeed"); - assert.ok(capturedBody, "Fetch should have been called"); - - // Verify NO compression occurred - const originalTokens = estimateTokens(JSON.stringify(body.messages)); - const finalTokens = estimateTokens(JSON.stringify(capturedBody.messages)); - - assert.equal( - finalTokens, - originalTokens, - `Context should NOT be compressed: ${finalTokens} === ${originalTokens}` - ); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("chatCore integration: compression preserves message structure", async () => { - const provider = "openai"; - const model = "gpt-4"; - - const body = { - model, - messages: [ - { role: "system", content: "You are helpful." }, - { role: "user", content: "x".repeat(50000) }, - { role: "assistant", content: "Response 1" }, - { role: "user", content: "x".repeat(50000) }, - { role: "assistant", content: "Response 2" }, - { role: "user", content: "Final question" }, - ], - }; - - // Create provider connection - const connectionId = await providersDb.createProviderConnection({ - provider, - apiKey: "test-key", - isActive: true, - }); - - // Mock fetch to capture the request - let capturedBody: any = null; - globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) => { - if (init?.body) { - capturedBody = JSON.parse(init.body as string); - } - return new Response( - JSON.stringify({ - choices: [{ message: { role: "assistant", content: "test" } }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }), - { - status: 200, - headers: { "content-type": "application/json" }, - } - ); - }; - - try { - const result = await handleChatCore({ - body, - modelInfo: { provider, model }, - credentials: { apiKey: "test-key" }, - log: { - debug: (tag: string, msg: string) => console.log(`[DEBUG] ${tag}: ${msg}`), - info: (tag: string, msg: string) => console.log(`[INFO] ${tag}: ${msg}`), - warn: (tag: string, msg: string) => console.log(`[WARN] ${tag}: ${msg}`), - error: (tag: string, msg: string) => console.log(`[ERROR] ${tag}: ${msg}`), - }, - clientRawRequest: { endpoint: "/v1/chat/completions", headers: new Map() }, - connectionId, - }); - - assert.ok(result.success, "Request should succeed"); - assert.ok(capturedBody, "Fetch should have been called"); - assert.ok(Array.isArray(capturedBody.messages), "Messages should remain an array"); - assert.ok(capturedBody.messages.length > 0, "Messages should not be empty"); - - const hasSystem = capturedBody.messages.some((m: any) => m.role === "system"); - assert.ok(hasSystem, "System message should be preserved"); - - const lastMessage = capturedBody.messages[capturedBody.messages.length - 1]; - assert.equal(lastMessage.content, "Final question", "Last user message should be preserved"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("chatCore integration: compression handles tool messages", async () => { - const provider = "openai"; - const model = "gpt-4"; - - const longToolOutput = "x".repeat(10000); - const body = { - model, - messages: [ - { role: "system", content: "You are helpful." }, - { role: "user", content: "Run the tool" }, - { role: "assistant", content: "Running tool", tool_calls: [{ id: "t1", type: "function" }] }, - { role: "tool", content: longToolOutput, tool_call_id: "t1" }, - { role: "user", content: "What's the result?" }, - ], - }; - - // Create provider connection - const connectionId = await providersDb.createProviderConnection({ - provider, - apiKey: "test-key", - isActive: true, - }); - - // Mock fetch to capture the request - let capturedBody: any = null; - globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) => { - if (init?.body) { - capturedBody = JSON.parse(init.body as string); - } - return new Response( - JSON.stringify({ - choices: [{ message: { role: "assistant", content: "test" } }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }), - { - status: 200, - headers: { "content-type": "application/json" }, - } - ); - }; - - try { - const result = await handleChatCore({ - body, - modelInfo: { provider, model }, - credentials: { apiKey: "test-key" }, - log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, - clientRawRequest: { endpoint: "/v1/chat/completions", headers: new Map() }, - connectionId, - }); - - assert.ok(result.success, "Request should succeed"); - assert.ok(capturedBody, "Fetch should have been called"); - - const toolMessage = capturedBody.messages.find((m: any) => m.role === "tool"); - assert.ok(toolMessage, "Tool message should exist"); - - // Tool message should be truncated if compression was triggered - if (toolMessage.content.length < longToolOutput.length) { - assert.ok( - toolMessage.content.includes("[truncated]"), - "Tool message should have truncation marker" - ); - } - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("chatCore integration: combo requests run proactive compression before Kiro translation", async () => { - const provider = "kiro"; - const model = "claude-sonnet-4.5"; - - const connectionId = await providersDb.createProviderConnection({ - provider, - apiKey: "test-key", - isActive: true, - }); - - await combosDb.createCombo({ - name: "test-kiro-compression-combo", - strategy: "priority", - models: [ - { - kind: "model", - model: `${provider}/${model}`, - connectionId, - }, - ], - }); - - const body = { - model: "combo/test-kiro-compression-combo", - stream: false, - messages: [ - { role: "system", content: "You are helpful." }, - { role: "user", content: "x".repeat(50000) }, - { role: "assistant", content: "Ack 1" }, - { role: "user", content: "x".repeat(50000) }, - { role: "assistant", content: "Ack 2" }, - { role: "user", content: "x".repeat(50000) }, - { role: "assistant", content: "Ack 3" }, - { role: "user", content: "Please summarize everything." }, - ], - }; - - let capturedTranslatedBody: Record | null = null; - globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => { - if (init?.body) { - capturedTranslatedBody = JSON.parse(init.body as string) as Record; - } - return new Response( - JSON.stringify({ - choices: [{ message: { role: "assistant", content: "ok" } }], - usage: { prompt_tokens: 11, completion_tokens: 5, total_tokens: 16 }, - }), - { - status: 200, - headers: { "content-type": "application/json" }, - } - ); - }; - - try { - const result = await handleChatCore({ - body, - modelInfo: { provider, model }, - credentials: { apiKey: "test-key" }, - log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, - clientRawRequest: { endpoint: "/v1/chat/completions", headers: new Map() }, - connectionId, - isCombo: true, - comboName: "test-kiro-compression-combo", - }); - - // Kiro response translation in this integration harness may fail depending on upstream - // payload shape, but the regression target is request-side behavior before translation. - assert.ok(result, "Handler should return a result object"); - assert.ok(capturedTranslatedBody, "Translated body should be sent upstream"); - - // Ensure request was translated to Kiro shape (messages are not sent directly upstream). - const conversationState = capturedTranslatedBody?.conversationState as - | Record - | undefined; - assert.ok(conversationState, "Kiro translated request should include conversationState"); - - const history = Array.isArray(conversationState?.history) - ? (conversationState.history as unknown[]) - : []; - assert.ok( - history.length < body.messages.length - 1, - "History should be reduced by proactive compression before translation" - ); - - const currentMessage = conversationState?.currentMessage as Record | undefined; - const userInputMessage = currentMessage?.userInputMessage as - | Record - | undefined; - const currentContent = - typeof userInputMessage?.content === "string" ? userInputMessage.content : ""; - assert.match(currentContent, /Please summarize everything\./); - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/tests/integration/compression-pipeline.test.ts b/tests/integration/compression-pipeline.test.ts new file mode 100644 index 0000000000..99b4702458 --- /dev/null +++ b/tests/integration/compression-pipeline.test.ts @@ -0,0 +1,120 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + selectCompressionStrategy, + getEffectiveMode, + applyCompression, +} from "../../open-sse/services/compression/strategySelector.ts"; +import { applyLiteCompression } from "../../open-sse/services/compression/lite.ts"; +import { + createCompressionStats, + estimateCompressionTokens, +} from "../../open-sse/services/compression/stats.ts"; +import type { CompressionConfig } from "../../open-sse/services/compression/types.ts"; + +const baseConfig: CompressionConfig = { + enabled: true, + defaultMode: "lite", + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, +}; + +describe("Full Compression Pipeline", () => { + it("end-to-end: lite mode compresses whitespace and tracks stats", () => { + const body = { + messages: [ + { role: "system", content: "You are helpful." }, + { role: "system", content: "You are helpful." }, + { role: "user", content: "hello\n\n\n\nworld" }, + ], + }; + + // Step 1: Select strategy + const mode = selectCompressionStrategy(baseConfig, null, 100); + assert.equal(mode, "lite"); + + // Step 2: Apply compression + const result = applyCompression(body, mode); + assert.equal(result.compressed, true); + assert.ok(result.stats); + + // Step 3: Verify stats + assert.ok(result.stats!.originalTokens > 0); + assert.ok(result.stats!.compressedTokens > 0); + assert.ok(result.stats!.compressedTokens <= result.stats!.originalTokens); + assert.ok(result.stats!.savingsPercent >= 0); + assert.equal(result.stats!.mode, "lite"); + }); + + it("end-to-end: off mode returns unchanged body", () => { + const body = { messages: [{ role: "user", content: "test" }] }; + const offConfig = { ...baseConfig, enabled: false }; + const mode = selectCompressionStrategy(offConfig, null, 100); + assert.equal(mode, "off"); + + const result = applyCompression(body, mode); + assert.equal(result.compressed, false); + assert.equal(result.stats, null); + }); + + it("end-to-end: combo override selects lite for specific combo", () => { + const config: CompressionConfig = { + ...baseConfig, + defaultMode: "off" as const, + comboOverrides: { "my-combo": "lite" as const }, + }; + const body = { messages: [{ role: "user", content: "test\n\n\n\nmessage" }] }; + + const mode = selectCompressionStrategy(config, "my-combo", 100); + assert.equal(mode, "lite"); + + const result = applyCompression(body, mode); + assert.equal(result.compressed, true); + }); + + it("end-to-end: auto-trigger activates compression", () => { + const config: CompressionConfig = { + ...baseConfig, + defaultMode: "off" as const, + autoTriggerTokens: 50, + }; + const body = { messages: [{ role: "user", content: "x".repeat(1000) }] }; + + // Below threshold → off + const mode1 = getEffectiveMode(config, null, 10); + assert.equal(mode1, "off"); + + // Above threshold → lite + const mode2 = getEffectiveMode(config, null, 100); + assert.equal(mode2, "lite"); + }); + + it("lite compression + stats pipeline works together", () => { + const body = { + messages: [ + { role: "system", content: "Be helpful." }, + { role: "system", content: "Be helpful." }, + { role: "user", content: "hello\n\n\n\nworld" }, + { role: "user", content: "hello\n\n\n\nworld" }, + ], + }; + + const result = applyLiteCompression(body); + assert.equal(result.compressed, true); + assert.ok(result.stats); + assert.ok(result.stats.savingsPercent > 0); + assert.ok(result.stats.techniquesUsed.length >= 1); + }); + + it("token estimation is consistent", () => { + const text = "hello world this is a test"; + const tokens = estimateCompressionTokens(text); + assert.equal(tokens, Math.ceil(text.length / 4)); + + const obj = { messages: [{ role: "user", content: text }] }; + const objTokens = estimateCompressionTokens(obj); + assert.equal(objTokens, Math.ceil(JSON.stringify(obj).length / 4)); + }); +}); diff --git a/tests/unit/chatcore-compression-integration.test.ts b/tests/unit/chatcore-compression-integration.test.ts deleted file mode 100644 index a34ee21408..0000000000 --- a/tests/unit/chatcore-compression-integration.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -test("chatCore integration: compressContext called proactively when context exceeds 85% threshold", async () => { - const { compressContext, estimateTokens, getTokenLimit } = - await import("../../open-sse/services/contextManager.ts"); - - const provider = "openai"; - const model = "gpt-4"; - const contextLimit = 8192; // Hardcoded to 8192 to avoid DB dependency causing 128k evaluation - const threshold = Math.floor(contextLimit * 0.85); - - const history = Array.from({ length: 24 }, (_, index) => [ - { role: "user", content: `Question ${index}: ${"context ".repeat(80)}` }, - { role: "assistant", content: `Answer ${index}: ${"history ".repeat(80)}` }, - ]).flat(); - const body = { - model, - messages: [ - { role: "system", content: "You are helpful." }, - ...history, - { role: "user", content: "Final question?" }, - ], - }; - - const estimatedTokens = estimateTokens(JSON.stringify(body.messages)); - assert.ok( - estimatedTokens > threshold, - `Expected ${estimatedTokens} to exceed threshold ${threshold}` - ); - - const result = compressContext(body, { provider, model, maxTokens: contextLimit }); - - assert.ok(result.compressed, "Context should be compressed"); - assert.ok( - result.stats.final < result.stats.original, - "Final tokens should be less than original" - ); - assert.ok( - result.stats.final <= contextLimit, - `Final tokens ${result.stats.final} should fit within limit ${contextLimit}` - ); - assert.equal( - result.body.messages[result.body.messages.length - 1].content, - "Final question?", - "Latest user turn should be preserved after compression" - ); -}); - -test("chatCore integration: compressContext NOT called when context is below 85% threshold", async () => { - const { compressContext, estimateTokens, getTokenLimit } = - await import("../../open-sse/services/contextManager.ts"); - - const provider = "openai"; - const model = "gpt-4"; - const contextLimit = 8192; - const threshold = Math.floor(contextLimit * 0.85); - - const smallMessage = "Hello, how are you?"; - const body = { - model, - messages: [ - { role: "system", content: "You are helpful." }, - { role: "user", content: smallMessage }, - ], - }; - - const estimatedTokens = estimateTokens(JSON.stringify(body.messages)); - assert.ok( - estimatedTokens < threshold, - `Expected ${estimatedTokens} to be below threshold ${threshold}` - ); - - const result = compressContext(body, { provider, model, maxTokens: contextLimit }); - - assert.equal(result.compressed, false, "Context should NOT be compressed"); -}); - -test("chatCore integration: compression preserves message structure", async () => { - const { compressContext, getTokenLimit } = - await import("../../open-sse/services/contextManager.ts"); - - const provider = "claude"; - const model = "claude-sonnet-4"; - const contextLimit = 200000; - - const body = { - model, - messages: [ - { role: "system", content: "You are helpful." }, - { role: "user", content: "x".repeat(500000) }, - { role: "assistant", content: "Response 1" }, - { role: "user", content: "x".repeat(500000) }, - { role: "assistant", content: "Response 2" }, - { role: "user", content: "Final question" }, - ], - }; - - const result = compressContext(body, { provider, model, maxTokens: contextLimit }); - - assert.ok(result.compressed, "Context should be compressed"); - assert.ok(Array.isArray(result.body.messages), "Messages should remain an array"); - assert.ok(result.body.messages.length > 0, "Messages should not be empty"); - - const hasSystem = result.body.messages.some((m: any) => m.role === "system"); - assert.ok(hasSystem, "System message should be preserved"); - - const lastMessage = result.body.messages[result.body.messages.length - 1]; - assert.equal(lastMessage.content, "Final question", "Last user message should be preserved"); -}); - -test("chatCore integration: compression handles tool messages", async () => { - const { compressContext, getTokenLimit } = - await import("../../open-sse/services/contextManager.ts"); - - const provider = "openai"; - const model = "gpt-4"; - const contextLimit = 8192; - - const longToolOutput = "x".repeat(50000); - const body = { - model, - messages: [ - { role: "system", content: "You are helpful." }, - { role: "user", content: "Run the tool" }, - { role: "assistant", content: "Running tool", tool_calls: [{ id: "t1", type: "function" }] }, - { role: "tool", content: longToolOutput, tool_call_id: "t1" }, - { role: "user", content: "What's the result?" }, - ], - }; - - const result = compressContext(body, { provider, model, maxTokens: 5000, reserveTokens: 1000 }); - - assert.ok(result.compressed, "Context should be compressed"); - - const toolMessage = result.body.messages.find((m: any) => m.role === "tool"); - assert.ok(toolMessage, "Tool message should exist"); - assert.ok(toolMessage.content.length < longToolOutput.length, "Tool message should be truncated"); - assert.ok( - toolMessage.content.includes("[truncated]"), - "Tool message should have truncation marker" - ); -}); diff --git a/tests/unit/compression/db.test.ts b/tests/unit/compression/db.test.ts new file mode 100644 index 0000000000..134a4c19f0 --- /dev/null +++ b/tests/unit/compression/db.test.ts @@ -0,0 +1,86 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + getCompressionSettings, + updateCompressionSettings, +} from "../../../src/lib/db/compression.ts"; + +describe("getCompressionSettings", () => { + it("returns default settings structure", async () => { + const settings = await getCompressionSettings(); + assert.equal(typeof settings.enabled, "boolean"); + assert.equal(typeof settings.defaultMode, "string"); + assert.equal(typeof settings.autoTriggerTokens, "number"); + assert.equal(typeof settings.cacheMinutes, "number"); + assert.equal(typeof settings.preserveSystemPrompt, "boolean"); + assert.equal(typeof settings.comboOverrides, "object"); + }); + + it("has correct default values", async () => { + const settings = await getCompressionSettings(); + assert.equal(settings.enabled, false); + assert.equal(settings.defaultMode, "off"); + assert.equal(settings.autoTriggerTokens, 0); + assert.equal(settings.cacheMinutes, 5); + assert.equal(settings.preserveSystemPrompt, true); + assert.deepEqual(settings.comboOverrides, {}); + }); +}); + +describe("updateCompressionSettings", () => { + it("updates enabled flag", async () => { + await updateCompressionSettings({ enabled: true } as any); + const settings = await getCompressionSettings(); + assert.equal(settings.enabled, true); + // Reset + await updateCompressionSettings({ enabled: false } as any); + }); + + it("updates defaultMode", async () => { + await updateCompressionSettings({ defaultMode: "lite" } as any); + const settings = await getCompressionSettings(); + assert.equal(settings.defaultMode, "lite"); + // Reset + await updateCompressionSettings({ defaultMode: "off" } as any); + }); + + it("updates autoTriggerTokens", async () => { + await updateCompressionSettings({ autoTriggerTokens: 5000 } as any); + const settings = await getCompressionSettings(); + assert.equal(settings.autoTriggerTokens, 5000); + // Reset + await updateCompressionSettings({ autoTriggerTokens: 0 } as any); + }); + + it("updates multiple settings at once", async () => { + await updateCompressionSettings({ + enabled: true, + defaultMode: "lite", + autoTriggerTokens: 1000, + cacheMinutes: 10, + } as any); + const settings = await getCompressionSettings(); + assert.equal(settings.enabled, true); + assert.equal(settings.defaultMode, "lite"); + assert.equal(settings.autoTriggerTokens, 1000); + assert.equal(settings.cacheMinutes, 10); + // Reset all + await updateCompressionSettings({ + enabled: false, + defaultMode: "off", + autoTriggerTokens: 0, + cacheMinutes: 5, + } as any); + }); + + it("preserves unmodified settings", async () => { + const before = await getCompressionSettings(); + await updateCompressionSettings({ enabled: true } as any); + const after = await getCompressionSettings(); + assert.equal(after.enabled, true); + assert.equal(after.defaultMode, before.defaultMode); + assert.equal(after.cacheMinutes, before.cacheMinutes); + // Reset + await updateCompressionSettings({ enabled: false } as any); + }); +}); diff --git a/tests/unit/compression/lite.test.ts b/tests/unit/compression/lite.test.ts new file mode 100644 index 0000000000..c3119f2c5e --- /dev/null +++ b/tests/unit/compression/lite.test.ts @@ -0,0 +1,209 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + applyLiteCompression, + collapseWhitespace, + dedupSystemPrompt, + compressToolResults, + removeRedundantContent, + replaceImageUrls, +} from "../../../open-sse/services/compression/lite.ts"; + +describe("collapseWhitespace", () => { + it("collapses 3+ newlines to 2", () => { + const body = { messages: [{ role: "user", content: "hello\n\n\n\nworld" }] }; + const result = collapseWhitespace(body); + assert.equal(result.applied, true); + assert.equal(result.body.messages![0].content as string, "hello\n\nworld"); + }); + + it("does not modify already-normal whitespace", () => { + const body = { messages: [{ role: "user", content: "hello\n\nworld" }] }; + const result = collapseWhitespace(body); + assert.equal(result.applied, false); + }); + + it("trims trailing spaces", () => { + const body = { messages: [{ role: "user", content: "hello " }] }; + const result = collapseWhitespace(body); + assert.equal(result.applied, true); + assert.equal(result.body.messages![0].content as string, "hello"); + }); + + it("returns unchanged when no messages", () => { + const body = {}; + const result = collapseWhitespace(body); + assert.equal(result.applied, false); + }); + + it("skips non-string content", () => { + const body = { messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }] }; + const result = collapseWhitespace(body); + assert.equal(result.applied, false); + }); +}); + +describe("dedupSystemPrompt", () => { + it("removes duplicate system prompts", () => { + const body = { + messages: [ + { role: "system", content: "You are helpful." }, + { role: "system", content: "You are helpful." }, + { role: "user", content: "hi" }, + ], + }; + const result = dedupSystemPrompt(body); + assert.equal(result.applied, true); + assert.equal(result.body.messages!.length, 2); + }); + + it("keeps different system prompts", () => { + const body = { + messages: [ + { role: "system", content: "You are helpful." }, + { role: "system", content: "Be concise." }, + ], + }; + const result = dedupSystemPrompt(body); + assert.equal(result.applied, false); + assert.equal(result.body.messages!.length, 2); + }); + + it("returns unchanged when no messages", () => { + const result = dedupSystemPrompt({}); + assert.equal(result.applied, false); + }); +}); + +describe("compressToolResults", () => { + it("truncates long tool results", () => { + const longContent = "x".repeat(3000); + const body = { messages: [{ role: "tool", content: longContent }] }; + const result = compressToolResults(body); + assert.equal(result.applied, true); + const content = result.body.messages![0].content as string; + assert.ok(content.length < 3000); + assert.ok(content.includes("[truncated]")); + }); + + it("keeps short tool results unchanged", () => { + const body = { messages: [{ role: "tool", content: "short result" }] }; + const result = compressToolResults(body); + assert.equal(result.applied, false); + }); + + it("skips non-tool messages", () => { + const body = { messages: [{ role: "user", content: "x".repeat(3000) }] }; + const result = compressToolResults(body); + assert.equal(result.applied, false); + }); +}); + +describe("removeRedundantContent", () => { + it("removes consecutive duplicate messages", () => { + const body = { + messages: [ + { role: "user", content: "hello" }, + { role: "user", content: "hello" }, + ], + }; + const result = removeRedundantContent(body); + assert.equal(result.applied, true); + assert.equal(result.body.messages!.length, 1); + }); + + it("keeps non-duplicate messages", () => { + const body = { + messages: [ + { role: "user", content: "hello" }, + { role: "user", content: "world" }, + ], + }; + const result = removeRedundantContent(body); + assert.equal(result.applied, false); + assert.equal(result.body.messages!.length, 2); + }); + + it("only removes same-role consecutive duplicates", () => { + const body = { + messages: [ + { role: "system", content: "hello" }, + { role: "user", content: "hello" }, + ], + }; + const result = removeRedundantContent(body); + assert.equal(result.applied, false); + }); +}); + +describe("replaceImageUrls", () => { + it("replaces base64 images for non-vision models", () => { + const body = { + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/png;base64,iVBOR" } }], + }, + ], + }; + const result = replaceImageUrls(body, "gpt-3.5-turbo"); + assert.equal(result.applied, true); + const content = result.body.messages![0].content as Array>; + assert.equal(content[0].type, "text"); + assert.ok((content[0].text as string).includes("[image:")); + }); + + it("keeps images for vision models", () => { + const body = { + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/png;base64,iVBOR" } }], + }, + ], + }; + const result = replaceImageUrls(body, "gpt-4o"); + assert.equal(result.applied, false); + }); + + it("skips non-image content", () => { + const body = { + messages: [{ role: "user", content: "just text" }], + }; + const result = replaceImageUrls(body, "gpt-3.5-turbo"); + assert.equal(result.applied, false); + }); +}); + +describe("applyLiteCompression", () => { + it("applies all techniques that match", () => { + const body = { + messages: [ + { role: "system", content: "You are helpful." }, + { role: "system", content: "You are helpful." }, + { role: "user", content: "hello\n\n\n\nworld" }, + { role: "user", content: "hello\n\n\n\nworld" }, + ], + }; + const result = applyLiteCompression(body); + assert.equal(result.compressed, true); + assert.ok(result.stats); + assert.ok(result.stats.techniquesUsed.length >= 2); + assert.ok(result.stats.savingsPercent > 0); + }); + + it("returns no compression for clean input", () => { + const body = { + messages: [{ role: "user", content: "clean message" }], + }; + const result = applyLiteCompression(body); + assert.equal(result.compressed, false); + assert.equal(result.stats, null); + }); + + it("handles empty messages array", () => { + const body = { messages: [] }; + const result = applyLiteCompression(body); + assert.equal(result.compressed, false); + }); +}); diff --git a/tests/unit/compression/stats.test.ts b/tests/unit/compression/stats.test.ts new file mode 100644 index 0000000000..203e766af3 --- /dev/null +++ b/tests/unit/compression/stats.test.ts @@ -0,0 +1,92 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + estimateCompressionTokens, + createCompressionStats, + trackCompressionStats, +} from "../../../open-sse/services/compression/stats.ts"; + +describe("estimateCompressionTokens", () => { + it("returns 0 for null", () => { + assert.equal(estimateCompressionTokens(null), 0); + }); + + it("returns 0 for undefined", () => { + assert.equal(estimateCompressionTokens(undefined), 0); + }); + + it("returns 0 for empty string", () => { + assert.equal(estimateCompressionTokens(""), 0); + }); + + it("estimates tokens from text (chars/4)", () => { + assert.equal(estimateCompressionTokens("hello world"), 3); + }); + + it("estimates tokens from object", () => { + const tokens = estimateCompressionTokens({ messages: [{ role: "user", content: "test" }] }); + assert.ok(tokens > 0); + }); + + it("handles long strings", () => { + const tokens = estimateCompressionTokens("x".repeat(400)); + assert.equal(tokens, 100); + }); +}); + +describe("createCompressionStats", () => { + it("calculates savings correctly", () => { + const original = { messages: [{ role: "user", content: "x".repeat(100) }] }; + const compressed = { messages: [{ role: "user", content: "x".repeat(80) }] }; + const origTokens = Math.ceil(JSON.stringify(original).length / 4); + const compTokens = Math.ceil(JSON.stringify(compressed).length / 4); + const expectedSavings = Math.round(((origTokens - compTokens) / origTokens) * 10000) / 100; + const stats = createCompressionStats(original, compressed, "lite", ["whitespace"]); + assert.equal(stats.originalTokens, origTokens); + assert.equal(stats.compressedTokens, compTokens); + assert.equal(stats.savingsPercent, expectedSavings); + assert.deepEqual(stats.techniquesUsed, ["whitespace"]); + assert.equal(stats.mode, "lite"); + assert.ok(stats.timestamp > 0); + }); + + it("handles zero original tokens", () => { + const original = {}; + const compressed = {}; + const stats = createCompressionStats(original, compressed, "off", []); + assert.equal(stats.savingsPercent, 0); + }); + + it("rounds savings to 2 decimal places", () => { + const original = { messages: [{ role: "user", content: "x".repeat(97) }] }; + const compressed = { messages: [{ role: "user", content: "x".repeat(80) }] }; + const stats = createCompressionStats(original, compressed, "lite", ["test"]); + assert.ok(Number.isFinite(stats.savingsPercent)); + }); +}); + +describe("trackCompressionStats", () => { + it("does not throw for zero tokens", () => { + const stats = { + originalTokens: 0, + compressedTokens: 0, + savingsPercent: 0, + techniquesUsed: [], + mode: "off" as const, + timestamp: Date.now(), + }; + assert.doesNotThrow(() => trackCompressionStats(stats)); + }); + + it("logs compression stats", () => { + const stats = { + originalTokens: 100, + compressedTokens: 80, + savingsPercent: 20, + techniquesUsed: ["whitespace"], + mode: "lite" as const, + timestamp: Date.now(), + }; + assert.doesNotThrow(() => trackCompressionStats(stats)); + }); +}); diff --git a/tests/unit/compression/strategySelector.test.ts b/tests/unit/compression/strategySelector.test.ts new file mode 100644 index 0000000000..510d27f827 --- /dev/null +++ b/tests/unit/compression/strategySelector.test.ts @@ -0,0 +1,125 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + selectCompressionStrategy, + getEffectiveMode, + applyCompression, + checkComboOverride, + shouldAutoTrigger, +} from "../../../open-sse/services/compression/strategySelector.ts"; +import type { CompressionConfig } from "../../../open-sse/services/compression/types.ts"; + +const baseConfig: CompressionConfig = { + enabled: true, + defaultMode: "lite", + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, +}; + +describe("checkComboOverride", () => { + it("returns null when comboId is null", () => { + assert.equal(checkComboOverride(baseConfig, null), null); + }); + + it("returns null when comboOverrides is empty", () => { + assert.equal(checkComboOverride(baseConfig, "my-combo"), null); + }); + + it("returns mode when combo override exists", () => { + const config = { ...baseConfig, comboOverrides: { "my-combo": "off" as const } }; + assert.equal(checkComboOverride(config, "my-combo"), "off"); + }); + + it("returns null for non-existent combo", () => { + const config = { ...baseConfig, comboOverrides: { "other-combo": "lite" as const } }; + assert.equal(checkComboOverride(config, "my-combo"), null); + }); +}); + +describe("shouldAutoTrigger", () => { + it("returns false when autoTriggerTokens is 0", () => { + assert.equal(shouldAutoTrigger(baseConfig, 5000), false); + }); + + it("returns false when tokens below threshold", () => { + const config = { ...baseConfig, autoTriggerTokens: 1000 }; + assert.equal(shouldAutoTrigger(config, 500), false); + }); + + it("returns true when tokens at threshold", () => { + const config = { ...baseConfig, autoTriggerTokens: 1000 }; + assert.equal(shouldAutoTrigger(config, 1000), true); + }); + + it("returns true when tokens above threshold", () => { + const config = { ...baseConfig, autoTriggerTokens: 1000 }; + assert.equal(shouldAutoTrigger(config, 1500), true); + }); +}); + +describe("getEffectiveMode", () => { + it("returns off when not enabled", () => { + const config = { ...baseConfig, enabled: false }; + assert.equal(getEffectiveMode(config, null, 100), "off"); + }); + + it("returns default mode when no overrides", () => { + assert.equal(getEffectiveMode(baseConfig, null, 100), "lite"); + }); + + it("returns combo override mode when present", () => { + const config = { + ...baseConfig, + defaultMode: "off" as const, + comboOverrides: { "my-combo": "lite" as const }, + }; + assert.equal(getEffectiveMode(config, "my-combo", 100), "lite"); + }); + + it("returns lite when auto-trigger threshold reached", () => { + const config = { ...baseConfig, defaultMode: "off" as const, autoTriggerTokens: 1000 }; + assert.equal(getEffectiveMode(config, null, 1500), "lite"); + }); + + it("combo override takes precedence over auto-trigger", () => { + const config = { + ...baseConfig, + defaultMode: "off" as const, + autoTriggerTokens: 100, + comboOverrides: { "my-combo": "off" as const }, + }; + assert.equal(getEffectiveMode(config, "my-combo", 500), "off"); + }); +}); + +describe("selectCompressionStrategy", () => { + it("returns effective mode", () => { + assert.equal(selectCompressionStrategy(baseConfig, null, 100), "lite"); + }); +}); + +describe("applyCompression", () => { + it("returns unchanged body for off mode", () => { + const body = { messages: [{ role: "user", content: "test" }] }; + const result = applyCompression(body, "off"); + assert.equal(result.compressed, false); + assert.equal(result.stats, null); + assert.deepEqual(result.body, body); + }); + + it("applies lite compression for lite mode", () => { + const body = { messages: [{ role: "user", content: "test\n\n\n\nmessage" }] }; + const result = applyCompression(body, "lite"); + assert.equal(result.compressed, true); + assert.ok(result.stats); + assert.equal(result.stats.mode, "lite"); + }); + + it("returns unchanged body for standard mode (Phase 2)", () => { + const body = { messages: [{ role: "user", content: "test" }] }; + const result = applyCompression(body, "standard"); + assert.equal(result.compressed, false); + }); +});