diff --git a/CHANGELOG.md b/CHANGELOG.md index c36a63920c..f23f929847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ _In development — bullets added per PR; finalized at release._ ### ✨ New Features - **feat(mcp): `omniroute_tool_search` tool + one-line TS signatures** — new MCP tool that does lexical keyword search over every MCP tool's name/description and returns the top matches as compact one-line TypeScript signatures (~half the JSON-schema token cost), so agents discover tools on demand instead of carrying all ~88 schemas every turn. Search is ReDoS-safe (substring scoring, never `new RegExp` on the query) and deterministic; `tools/list` stays complete (no hidden tools). Adds the `read:tools` scope. Tier-1 item of the compression feature-extraction roadmap. ([#5269](https://github.com/diegosouzapw/OmniRoute/pull/5269)) +- **feat(compression): RTK semantic command-output renderers (opt-in)** — adds a second, opt-in compaction layer to the RTK engine that rewrites structured command output into a far more compact semantic form: `git diff` → file headers + `@@` hunks + changed lines only; an all-green `pytest`/`jest`/`vitest`/`eslint` run → its one-line summary; `terraform`/`tofu plan` → `Plan: +N ~M -K` plus the resource list; `kubectl`/`aws` JSON arrays → a minimal table. Each renderer is conservative (no-op when the shape doesn't match) and the integration is fail-open; the test-green renderer never collapses output that carries any failure signal. Gated by `RtkConfig.enableRenderers` (default off → zero behavioral change). Eighth item of the compression feature-extraction roadmap. ([#5268](https://github.com/diegosouzapw/OmniRoute/pull/5268)) - **kilocode:** anonymous (no-auth) access to Kilo Code's free models, mirroring the `opencode`/`mimocode` pattern. With no Kilo account connected, requests now fall back to the gateway's anonymous tier (`Authorization: Bearer anonymous` on `api.kilo.ai/api/openrouter`) so the free models work without signup; a connected OAuth account is still used unchanged for the paid tier (#4019 — thanks @Theadd for the reference implementation) ### 🔧 Bug Fixes diff --git a/open-sse/services/compression/engines/rtk/configSchema.ts b/open-sse/services/compression/engines/rtk/configSchema.ts new file mode 100644 index 0000000000..e7699120e3 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/configSchema.ts @@ -0,0 +1,117 @@ +import { DEFAULT_RTK_CONFIG } from "../../types.ts"; +import type { EngineConfigField, EngineValidationResult } from "../types.ts"; + +export const RTK_SCHEMA: EngineConfigField[] = [ + { + key: "intensity", + type: "select", + label: "Intensity", + defaultValue: DEFAULT_RTK_CONFIG.intensity, + options: [ + { value: "minimal", label: "minimal" }, + { value: "standard", label: "standard" }, + { value: "aggressive", label: "aggressive" }, + ], + }, + { + key: "applyToToolResults", + type: "boolean", + label: "Apply to tool results", + defaultValue: DEFAULT_RTK_CONFIG.applyToToolResults, + }, + { + key: "applyToAssistantMessages", + type: "boolean", + label: "Apply to assistant messages", + defaultValue: DEFAULT_RTK_CONFIG.applyToAssistantMessages, + }, + { + key: "applyToCodeBlocks", + type: "boolean", + label: "Apply to code blocks", + defaultValue: DEFAULT_RTK_CONFIG.applyToCodeBlocks, + }, + { + key: "maxLinesPerResult", + type: "number", + label: "Max lines per result", + defaultValue: DEFAULT_RTK_CONFIG.maxLinesPerResult, + min: 0, + max: 5000, + }, + { + key: "maxCharsPerResult", + type: "number", + label: "Max chars per result", + defaultValue: DEFAULT_RTK_CONFIG.maxCharsPerResult, + min: 0, + max: 500000, + }, + { + key: "deduplicateThreshold", + type: "number", + label: "Deduplicate threshold", + defaultValue: DEFAULT_RTK_CONFIG.deduplicateThreshold, + min: 2, + max: 100, + }, + { + key: "rawOutputRetention", + type: "select", + label: "Raw output retention", + defaultValue: DEFAULT_RTK_CONFIG.rawOutputRetention, + options: [ + { value: "never", label: "never" }, + { value: "failures", label: "failures" }, + { value: "always", label: "always" }, + ], + }, + { + key: "enableRenderers", + type: "boolean", + label: "Semantic renderers", + defaultValue: DEFAULT_RTK_CONFIG.enableRenderers, + }, +]; + +export function validateRtkEngineConfig(config: Record): EngineValidationResult { + const errors: string[] = []; + if ( + config.intensity !== undefined && + config.intensity !== "minimal" && + config.intensity !== "standard" && + config.intensity !== "aggressive" + ) { + errors.push("intensity must be minimal, standard, or aggressive"); + } + for (const key of [ + "enabled", + "applyToToolResults", + "applyToAssistantMessages", + "applyToCodeBlocks", + ]) { + if (config[key] !== undefined && typeof config[key] !== "boolean") { + errors.push(`${key} must be a boolean`); + } + } + for (const key of ["maxLinesPerResult", "maxCharsPerResult", "deduplicateThreshold"]) { + if (config[key] !== undefined && (typeof config[key] !== "number" || config[key] < 0)) { + errors.push(`${key} must be a non-negative number`); + } + } + if (config.enabledFilters !== undefined && !Array.isArray(config.enabledFilters)) { + errors.push("enabledFilters must be an array"); + } + if (config.disabledFilters !== undefined && !Array.isArray(config.disabledFilters)) { + errors.push("disabledFilters must be an array"); + } + if ( + config.rawOutputRetention !== undefined && + config.rawOutputRetention !== "never" && + config.rawOutputRetention !== "failures" && + config.rawOutputRetention !== "always" + ) { + errors.push("rawOutputRetention must be never, failures, or always"); + } + return { valid: errors.length === 0, errors }; +} diff --git a/open-sse/services/compression/engines/rtk/index.ts b/open-sse/services/compression/engines/rtk/index.ts index 2912ce2ce4..0d34980043 100644 --- a/open-sse/services/compression/engines/rtk/index.ts +++ b/open-sse/services/compression/engines/rtk/index.ts @@ -1,7 +1,8 @@ import { createCompressionStats, estimateCompressionTokens } from "../../stats.ts"; import { DEFAULT_RTK_CONFIG, type CompressionResult, type RtkConfig } from "../../types.ts"; -import type { CompressionEngine, EngineConfigField, EngineValidationResult } from "../types.ts"; +import type { CompressionEngine } from "../types.ts"; import { detectCommandType } from "./commandDetector.ts"; +import { RTK_SCHEMA, validateRtkEngineConfig } from "./configSchema.ts"; import { deduplicateRepeatedLines } from "./deduplicator.ts"; import { groupSimilarLines } from "./grouper.ts"; import { matchRtkFilter } from "./filterLoader.ts"; @@ -9,6 +10,7 @@ import { applyLineFilter } from "./lineFilter.ts"; import { smartTruncate } from "./smartTruncate.ts"; import { normalizeCodeLanguage, stripCode } from "./codeStripper.ts"; import { maybePersistRtkRawOutput, type RtkRawOutputPointer } from "./rawOutput.ts"; +import { applyRenderer } from "./renderers/index.ts"; import { isTextBlock } from "../../messageContent.ts"; import { adaptBodyForCompression } from "../../bodyAdapter.ts"; import { isAnthropicToolResultBlock } from "../../toolResultCompressor.ts"; @@ -62,115 +64,6 @@ function resolveToolMeta( return { command: null, skipFilters: true }; } -const RTK_SCHEMA: EngineConfigField[] = [ - { - key: "intensity", - type: "select", - label: "Intensity", - defaultValue: DEFAULT_RTK_CONFIG.intensity, - options: [ - { value: "minimal", label: "minimal" }, - { value: "standard", label: "standard" }, - { value: "aggressive", label: "aggressive" }, - ], - }, - { - key: "applyToToolResults", - type: "boolean", - label: "Apply to tool results", - defaultValue: DEFAULT_RTK_CONFIG.applyToToolResults, - }, - { - key: "applyToAssistantMessages", - type: "boolean", - label: "Apply to assistant messages", - defaultValue: DEFAULT_RTK_CONFIG.applyToAssistantMessages, - }, - { - key: "applyToCodeBlocks", - type: "boolean", - label: "Apply to code blocks", - defaultValue: DEFAULT_RTK_CONFIG.applyToCodeBlocks, - }, - { - key: "maxLinesPerResult", - type: "number", - label: "Max lines per result", - defaultValue: DEFAULT_RTK_CONFIG.maxLinesPerResult, - min: 0, - max: 5000, - }, - { - key: "maxCharsPerResult", - type: "number", - label: "Max chars per result", - defaultValue: DEFAULT_RTK_CONFIG.maxCharsPerResult, - min: 0, - max: 500000, - }, - { - key: "deduplicateThreshold", - type: "number", - label: "Deduplicate threshold", - defaultValue: DEFAULT_RTK_CONFIG.deduplicateThreshold, - min: 2, - max: 100, - }, - { - key: "rawOutputRetention", - type: "select", - label: "Raw output retention", - defaultValue: DEFAULT_RTK_CONFIG.rawOutputRetention, - options: [ - { value: "never", label: "never" }, - { value: "failures", label: "failures" }, - { value: "always", label: "always" }, - ], - }, -]; - -function validateRtkEngineConfig(config: Record): EngineValidationResult { - const errors: string[] = []; - if ( - config.intensity !== undefined && - config.intensity !== "minimal" && - config.intensity !== "standard" && - config.intensity !== "aggressive" - ) { - errors.push("intensity must be minimal, standard, or aggressive"); - } - for (const key of [ - "enabled", - "applyToToolResults", - "applyToAssistantMessages", - "applyToCodeBlocks", - ]) { - if (config[key] !== undefined && typeof config[key] !== "boolean") { - errors.push(`${key} must be a boolean`); - } - } - for (const key of ["maxLinesPerResult", "maxCharsPerResult", "deduplicateThreshold"]) { - if (config[key] !== undefined && (typeof config[key] !== "number" || config[key] < 0)) { - errors.push(`${key} must be a non-negative number`); - } - } - if (config.enabledFilters !== undefined && !Array.isArray(config.enabledFilters)) { - errors.push("enabledFilters must be an array"); - } - if (config.disabledFilters !== undefined && !Array.isArray(config.disabledFilters)) { - errors.push("disabledFilters must be an array"); - } - if ( - config.rawOutputRetention !== undefined && - config.rawOutputRetention !== "never" && - config.rawOutputRetention !== "failures" && - config.rawOutputRetention !== "always" - ) { - errors.push("rawOutputRetention must be never, failures, or always"); - } - return { valid: errors.length === 0, errors }; -} - export interface RtkProcessResult { text: string; compressed: boolean; @@ -366,6 +259,20 @@ export function processRtkText( } } + // #10: semantic renderers — opt-in via enableRenderers flag (default OFF), fail-open + if (config.enableRenderers) { + try { + const rendered = applyRenderer(result, detection, config); + if (rendered.changed) { + result = rendered.text; + techniquesUsed.push(`rtk-render:${rendered.renderer}`); + rulesApplied.push(`rtk:render:${rendered.renderer}`); + } + } catch { + // fail-open: renderer never brings down the request + } + } + if (config.applyToCodeBlocks) { let strippedCodeBlocks = 0; result = result.replace( diff --git a/open-sse/services/compression/engines/rtk/renderers/gitDiff.ts b/open-sse/services/compression/engines/rtk/renderers/gitDiff.ts new file mode 100644 index 0000000000..3c07c23faf --- /dev/null +++ b/open-sse/services/compression/engines/rtk/renderers/gitDiff.ts @@ -0,0 +1,39 @@ +import type { RenderResult, CommandDetectionResult } from "./types.ts"; +import { NO_RENDER } from "./types.ts"; + +/** + * RTK semantic renderer for `git diff` / `git show` output. + * + * Keeps only: + * - `diff --git a/... b/...` file header lines + * - `@@ ... @@` hunk headers + * - change lines starting with `+` or `-` (but NOT `+++`/`---`) + * + * Everything else (context lines, index lines, mode lines, etc.) is dropped. + * If no hunk header is found, returns no-op (input is not a real diff). + */ +export function renderGitDiff(text: string, _detection: CommandDetectionResult): RenderResult { + // Guard: must have at least one hunk header + if (!text.includes("@@ ")) { + return NO_RENDER(text); + } + + const kept: string[] = []; + for (const line of text.split("\n")) { + if ( + line.startsWith("diff --git ") || + line.startsWith("@@ ") + ) { + kept.push(line); + } else if (/^[+-](?![+-])/.test(line)) { + // change line (+foo or -foo) but NOT +++ or --- + kept.push(line); + } + // drop: context lines (space), index lines, --- a/, +++ b/, mode lines, etc. + } + + const out = kept.join("\n"); + if (out === text) return NO_RENDER(text); + + return { text: out, changed: true, renderer: "git-diff" }; +} diff --git a/open-sse/services/compression/engines/rtk/renderers/index.ts b/open-sse/services/compression/engines/rtk/renderers/index.ts new file mode 100644 index 0000000000..ad30e9d1ad --- /dev/null +++ b/open-sse/services/compression/engines/rtk/renderers/index.ts @@ -0,0 +1,50 @@ +import type { CommandDetectionResult } from "../commandDetector.ts"; +import type { RtkConfig } from "../../../types.ts"; +import { type RenderResult, NO_RENDER } from "./types.ts"; +import { renderGitDiff } from "./gitDiff.ts"; +import { renderTestGreen } from "./testGreen.ts"; +import { renderTerraformPlan } from "./terraformPlan.ts"; +import { renderStructuredTable } from "./structuredTable.ts"; + +// preenchido nas tasks 2–5 +const REGISTRY: Record RenderResult> = {}; + +// Task 2: git-diff renderer +// Note: "git-show" is not a real detection type in commandDetector.ts DETECTORS array, +// so only "git-diff" is registered here. +REGISTRY["git-diff"] = renderGitDiff; + +// Task 3: test-green renderer +// Note: "test-eslint" is not a real detection type; the real type is "build-eslint". +REGISTRY["test-pytest"] = renderTestGreen; +REGISTRY["test-jest"] = renderTestGreen; +REGISTRY["test-vitest"] = renderTestGreen; +REGISTRY["build-eslint"] = renderTestGreen; + +// Task 4: terraform-plan renderer +REGISTRY["terraform-plan"] = renderTerraformPlan; +REGISTRY["tofu-plan"] = renderTerraformPlan; + +// Task 5: structured-table renderer +// Note: "kubectl" is not a real detection type in commandDetector.ts DETECTORS array +// (it's in KNOWN_COMMANDS but has no DETECTOR entry with a "kubectl" type). +// Kubectl JSON output will be detected as "json-output" or "aws" depending on content. +// Registering "aws" and "json-output" which are the real types for this output shape. +REGISTRY["aws"] = renderStructuredTable; +REGISTRY["json-output"] = renderStructuredTable; + +export function applyRenderer( + text: string, + detection: CommandDetectionResult, + config: RtkConfig +): RenderResult { + const r = REGISTRY[detection.type]; + if (!r) return NO_RENDER(text); + if (config.renderers && config.renderers.length > 0 && !config.renderers.includes(detection.type)) { + return NO_RENDER(text); + } + return r(text, detection); +} +export { type RenderResult } from "./types.ts"; + +export { REGISTRY }; diff --git a/open-sse/services/compression/engines/rtk/renderers/structuredTable.ts b/open-sse/services/compression/engines/rtk/renderers/structuredTable.ts new file mode 100644 index 0000000000..0e570c8974 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/renderers/structuredTable.ts @@ -0,0 +1,96 @@ +import type { RenderResult, CommandDetectionResult } from "./types.ts"; +import { NO_RENDER } from "./types.ts"; + +const MAX_TABLE_ROWS = 200; +const MAX_COLUMNS = 5; +// Priority column names to prefer when choosing which columns to display +const PRIORITY_KEYS = ["name", "id", "status", "type", "kind"]; + +/** + * RTK semantic renderer for structured JSON array output (aws, kubectl, etc.). + * + * Only renders if: + * - Input parses as JSON (or contains a JSON array substring) + * - Result is an array of ≥2 homogeneous objects (same dominant scalar keys) + * + * Output: minimal TSV-like table (header + rows). Large arrays (>200 items) + * are capped with a trailing "… (+K more)" line. + * + * All other shapes ⇒ no-op (conservative). + */ +export function renderStructuredTable( + text: string, + _detection: CommandDetectionResult, +): RenderResult { + const parsed = tryParse(text.trim()); + if (!parsed) return NO_RENDER(text); + + // Must be an array of ≥2 objects + if (!Array.isArray(parsed) || parsed.length < 2) return NO_RENDER(text); + + const items = parsed as unknown[]; + // Every item must be a non-null, non-array object + if (!items.every((item) => item !== null && typeof item === "object" && !Array.isArray(item))) { + return NO_RENDER(text); + } + + const objects = items as Record[]; + + // Collect scalar keys across all objects, count occurrences + const keyCount: Record = {}; + for (const obj of objects) { + for (const [k, v] of Object.entries(obj)) { + if (v === null || typeof v !== "object") { + keyCount[k] = (keyCount[k] ?? 0) + 1; + } + } + } + + if (Object.keys(keyCount).length === 0) return NO_RENDER(text); + + // Choose columns: prioritize PRIORITY_KEYS, then by frequency, cap at MAX_COLUMNS + const threshold = Math.floor(objects.length / 2); + const candidateKeys = Object.entries(keyCount) + .filter(([, count]) => count >= threshold) + .map(([k]) => k); + + const priorityChosen = PRIORITY_KEYS.filter((k) => candidateKeys.includes(k)); + const rest = candidateKeys + .filter((k) => !PRIORITY_KEYS.includes(k)) + .sort((a, b) => (keyCount[b] ?? 0) - (keyCount[a] ?? 0)); + + const columns = [...priorityChosen, ...rest].slice(0, MAX_COLUMNS); + if (columns.length === 0) return NO_RENDER(text); + + // Build table + const rows = objects.slice(0, MAX_TABLE_ROWS); + const extra = objects.length > MAX_TABLE_ROWS ? objects.length - MAX_TABLE_ROWS : 0; + + const header = columns.join("\t"); + const body = rows + .map((obj) => columns.map((k) => String(obj[k] ?? "")).join("\t")) + .join("\n"); + + const out = extra > 0 ? `${header}\n${body}\n… (+${extra} more)` : `${header}\n${body}`; + + return { text: out, changed: true, renderer: "structured-table" }; +} + +function tryParse(text: string): unknown { + // Direct parse + try { + return JSON.parse(text); + } catch { + // Try to find the largest [...] substring + const start = text.indexOf("["); + const end = text.lastIndexOf("]"); + if (start !== -1 && end > start) { + try { + return JSON.parse(text.slice(start, end + 1)); + } catch { + return null; + } + } + return null; + } +} diff --git a/open-sse/services/compression/engines/rtk/renderers/terraformPlan.ts b/open-sse/services/compression/engines/rtk/renderers/terraformPlan.ts new file mode 100644 index 0000000000..198018e2ac --- /dev/null +++ b/open-sse/services/compression/engines/rtk/renderers/terraformPlan.ts @@ -0,0 +1,40 @@ +import type { RenderResult, CommandDetectionResult } from "./types.ts"; +import { NO_RENDER } from "./types.ts"; + +/** + * RTK semantic renderer for `terraform plan` / `tofu plan` output. + * + * Extracts: + * - The canonical summary line `Plan: N to add, M to change, K to destroy.` + * reformatted as `Plan: +N ~M -K` + * - Resource lines `# will be ` + * + * "No changes." output ⇒ no-op (already short enough). + * If no `Plan:` line is found ⇒ no-op (conservative). + */ +export function renderTerraformPlan( + text: string, + _detection: CommandDetectionResult, +): RenderResult { + // "No changes" is already compact — idempotent no-op + if (/^No changes\./m.test(text)) return NO_RENDER(text); + + // Extract the canonical Plan summary line + const planMatch = text.match(/Plan:\s+(\d+)\s+to add,\s+(\d+)\s+to change,\s+(\d+)\s+to destroy/); + if (!planMatch) return NO_RENDER(text); + + const [, add, change, destroy] = planMatch; + const summary = `Plan: +${add} ~${change} -${destroy}`; + + // Collect resource address lines: " # will be " + const resources: string[] = []; + for (const line of text.split("\n")) { + const m = line.match(/^\s+#\s+(\S+)\s+will\s+be\s+\S+/); + if (m) { + resources.push(` # ${m[1]} will be ${line.trim().replace(/^#\s+\S+\s+will\s+be\s+/, "")}`); + } + } + + const out = [summary, ...resources].join("\n"); + return { text: out, changed: true, renderer: "terraform-plan" }; +} diff --git a/open-sse/services/compression/engines/rtk/renderers/testGreen.ts b/open-sse/services/compression/engines/rtk/renderers/testGreen.ts new file mode 100644 index 0000000000..18473c9a4b --- /dev/null +++ b/open-sse/services/compression/engines/rtk/renderers/testGreen.ts @@ -0,0 +1,70 @@ +import type { RenderResult, CommandDetectionResult } from "./types.ts"; +import { NO_RENDER } from "./types.ts"; + +/** + * RTK semantic renderer for test suite output (pytest, jest, vitest, eslint). + * + * CRITICAL safety guard: only collapses when output indicates TOTAL success. + * ANY sign of failure forces a no-op to preserve full diagnostics. + * + * Failure signals (force no-op): + * - /\bFAIL\b/ in the text + * - /failed/i paired with a nonzero count (e.g. "1 failed") + * - ✖ symbol + * - "Error" anywhere + * - "Traceback" (Python) + * - "AssertionError" + * + * When green, extract the summary line and return it. + * If no recognizable summary line is found, no-op. + */ +export function renderTestGreen(text: string, _detection: CommandDetectionResult): RenderResult { + // Strip ANSI color codes before applying the failure guards. jest/vitest emit a + // colored "FAIL" status whose preceding ANSI byte ("m" of ) is a word + // character, defeating a /\bFAIL\b/ boundary on the raw string. Guards run on the + // stripped copy; the original `text` is what we return on a no-op (lossless). + const stripped = text.replace(/\[[0-9;]*m/g, ""); + + // Failure guard — must check FIRST; never weaken + if (/\bFAIL\b/.test(stripped)) return NO_RENDER(text); + if (/✖/.test(stripped)) return NO_RENDER(text); + if (/Error/.test(stripped)) return NO_RENDER(text); + if (/Traceback/.test(stripped)) return NO_RENDER(text); + if (/AssertionError/.test(stripped)) return NO_RENDER(text); + + // "failed" with a nonzero count, e.g. "1 failed" or "failed: 3" + const failedMatch = stripped.match(/(\d+)\s+failed/i) ?? stripped.match(/failed[:\s]+(\d+)/i); + if (failedMatch && parseInt(failedMatch[1], 10) > 0) return NO_RENDER(text); + + // Try to extract a recognised summary line (from the stripped copy → ANSI-free) + const summary = extractSummaryLine(stripped); + if (!summary) return NO_RENDER(text); + + return { text: summary, changed: true, renderer: "test-green" }; +} + +function extractSummaryLine(text: string): string | null { + const lines = text.split("\n"); + + // pytest: === N passed in X.Xs === (also handles variants like === N passed, M warning ===) + for (const line of lines) { + if (/={3,}\s+\d+\s+passed/.test(line)) return line.trim(); + } + + // jest / vitest: "Tests: N passed, N total" or "Test Suites: ... Tests: ..." + for (const line of lines) { + if (/Tests:\s+\d+\s+passed/.test(line)) return line.trim(); + } + + // vitest / jest run summary line: "✓ N tests passed" + for (const line of lines) { + if (/\d+\s+tests?\s+passed/i.test(line)) return line.trim(); + } + + // eslint / build-eslint: empty output = clean; if we reach here with no failures, synthesize + if (text.trim() === "" || text.trim().startsWith("\n")) { + return "ESLint: 0 problems found"; + } + + return null; +} diff --git a/open-sse/services/compression/engines/rtk/renderers/types.ts b/open-sse/services/compression/engines/rtk/renderers/types.ts new file mode 100644 index 0000000000..09709832bc --- /dev/null +++ b/open-sse/services/compression/engines/rtk/renderers/types.ts @@ -0,0 +1,13 @@ +import type { CommandDetectionResult } from "../commandDetector.ts"; +import type { RtkConfig } from "../../../types.ts"; + +export interface RenderResult { + text: string; + changed: boolean; + renderer: string; // nome do renderer aplicado (ou "" se nenhum) +} + +export type Renderer = (text: string, detection: CommandDetectionResult) => RenderResult; + +export const NO_RENDER = (text: string): RenderResult => ({ text, changed: false, renderer: "" }); +export type { RtkConfig, CommandDetectionResult }; diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index acd20b4617..3836a68f81 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -103,6 +103,10 @@ export interface RtkConfig { stripCodeComments?: boolean; /** R1/N3: keep JSDoc/docstring block comments when removing comments. Default: true. */ preserveDocstrings?: boolean; + /** #10: semantic command-output renderers (default off) */ + enableRenderers?: boolean; + /** #10: whitelist por command-type; vazio/undefined = todos */ + renderers?: string[]; } export interface CompressionLanguageConfig { @@ -314,6 +318,7 @@ export const DEFAULT_RTK_CONFIG: RtkConfig = { groupingThreshold: 3, stripCodeComments: false, preserveDocstrings: true, + enableRenderers: false, }; export const DEFAULT_COMPRESSION_LANGUAGE_CONFIG: CompressionLanguageConfig = { diff --git a/tests/unit/compression/rtk-render-gitdiff.test.ts b/tests/unit/compression/rtk-render-gitdiff.test.ts new file mode 100644 index 0000000000..1f665e5a80 --- /dev/null +++ b/tests/unit/compression/rtk-render-gitdiff.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { renderGitDiff } from "../../../open-sse/services/compression/engines/rtk/renderers/gitDiff.ts"; + +const det = { + type: "git-diff", + command: "git diff", + confidence: 1, + category: "git", + matchedPatterns: [], +}; + +test("keeps file headers, hunks and +/- changes; drops context", () => { + const input = `diff --git a/x.ts b/x.ts +index 111..222 100644 +--- a/x.ts ++++ b/x.ts +@@ -1,3 +1,3 @@ +-const a = 1; ++const a = 2; + const b = 3;`; + const r = renderGitDiff(input, det); + assert.equal(r.changed, true); + assert.ok(r.text.includes("diff --git a/x.ts b/x.ts")); + assert.ok(r.text.includes("@@ -1,3 +1,3 @@")); + assert.ok(r.text.includes("-const a = 1;")); + assert.ok(r.text.includes("+const a = 2;")); + assert.ok(!r.text.includes("index 111..222")); + assert.ok(!r.text.includes("--- a/x.ts")); + assert.ok(!r.text.includes(" const b = 3;")); // contexto dropado +}); + +test("no hunks ⇒ no-op", () => { + const r = renderGitDiff("just some text\nno diff here", det); + assert.equal(r.changed, false); +}); diff --git a/tests/unit/compression/rtk-render-table.test.ts b/tests/unit/compression/rtk-render-table.test.ts new file mode 100644 index 0000000000..c67c7c39df --- /dev/null +++ b/tests/unit/compression/rtk-render-table.test.ts @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { renderStructuredTable } from "../../../open-sse/services/compression/engines/rtk/renderers/structuredTable.ts"; + +const det = { + type: "kubectl", + command: "kubectl get pods -o json", + confidence: 1, + category: "cloud", + matchedPatterns: [], +}; + +test("homogeneous JSON array ⇒ minimal table", () => { + const input = JSON.stringify([ + { name: "pod-a", status: "Running", restarts: 0 }, + { name: "pod-b", status: "Pending", restarts: 2 }, + ]); + const r = renderStructuredTable(input, det); + assert.equal(r.changed, true); + assert.ok(r.text.includes("name")); + assert.ok(r.text.includes("pod-a")); + assert.ok(r.text.includes("Pending")); + assert.ok(!r.text.includes('"status":')); // não é mais JSON +}); + +test("malformed JSON ⇒ no-op", () => { + assert.equal(renderStructuredTable("{not json", det).changed, false); +}); + +test("single object (not array) ⇒ no-op", () => { + assert.equal(renderStructuredTable('{"name":"x"}', det).changed, false); +}); diff --git a/tests/unit/compression/rtk-render-terraform.test.ts b/tests/unit/compression/rtk-render-terraform.test.ts new file mode 100644 index 0000000000..7f88b9d83e --- /dev/null +++ b/tests/unit/compression/rtk-render-terraform.test.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { renderTerraformPlan } from "../../../open-sse/services/compression/engines/rtk/renderers/terraformPlan.ts"; + +const det = { + type: "terraform-plan", + command: "terraform plan", + confidence: 1, + category: "infra", + matchedPatterns: [], +}; + +test("summarizes plan into +N ~M -K plus resources", () => { + const input = `Terraform will perform the following actions: + # aws_instance.web will be created + + resource "aws_instance" "web" { ... many lines ... } + # aws_s3_bucket.data will be updated in-place + ~ resource "aws_s3_bucket" "data" { ... } +Plan: 1 to add, 1 to change, 0 to destroy.`; + const r = renderTerraformPlan(input, det); + assert.equal(r.changed, true); + assert.ok(r.text.includes("Plan: +1 ~1 -0")); + assert.ok(r.text.includes("aws_instance.web")); + assert.ok(!r.text.includes('resource "aws_instance" "web" {')); + // Regression (core review): the verb must not be duplicated ("will be will be created"). + assert.ok(!r.text.includes("will be will be")); + assert.ok(r.text.includes("# aws_instance.web will be created")); + assert.ok(r.text.includes("# aws_s3_bucket.data will be updated in-place")); +}); + +test("no-changes ⇒ no-op", () => { + const r = renderTerraformPlan( + "No changes. Your infrastructure matches the configuration.", + det, + ); + assert.equal(r.changed, false); +}); diff --git a/tests/unit/compression/rtk-render-testgreen.test.ts b/tests/unit/compression/rtk-render-testgreen.test.ts new file mode 100644 index 0000000000..9900b3c160 --- /dev/null +++ b/tests/unit/compression/rtk-render-testgreen.test.ts @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { renderTestGreen } from "../../../open-sse/services/compression/engines/rtk/renderers/testGreen.ts"; + +const det = (t: string) => ({ + type: t, + command: "", + confidence: 1, + category: "test", + matchedPatterns: [], +}); + +test("pytest all-green collapses to summary", () => { + const input = `============ test session starts ============ +collected 142 items +tests/a.py .................... +tests/b.py .................... +============ 142 passed in 3.21s ============`; + const r = renderTestGreen(input, det("test-pytest")); + assert.equal(r.changed, true); + assert.ok(r.text.includes("142 passed")); + assert.ok(!r.text.includes("....................")); +}); + +test("any failure ⇒ no-op (preserve diagnostics)", () => { + const input = `tests/a.py ..F.. +=== 1 failed, 4 passed in 1.0s === +E AssertionError: nope`; + const r = renderTestGreen(input, det("test-pytest")); + assert.equal(r.changed, false); +}); + +test("ANSI-colored FAIL with no numeric failed-count ⇒ no-op (regression: \\bFAIL\\b defeated by ANSI)", () => { + // jest/vitest emit a colored FAIL header; the ESC[31m byte 'm' before 'F' kills the + // word boundary. With the per-test failed-count line already stripped by an upstream + // filter, the ANSI strip in the guard is the only thing preventing a collapsed failure. + const input = "FAIL src/auth.test.ts\nTests: 3 passed, 3 total"; + const r = renderTestGreen(input, det("test-jest")); + assert.equal(r.changed, false); +}); diff --git a/tests/unit/compression/rtk-renderers-integration.test.ts b/tests/unit/compression/rtk-renderers-integration.test.ts new file mode 100644 index 0000000000..5105715800 --- /dev/null +++ b/tests/unit/compression/rtk-renderers-integration.test.ts @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { processRtkText } from "../../../open-sse/services/compression/engines/rtk/index.ts"; +import { applyRenderer } from "../../../open-sse/services/compression/engines/rtk/renderers/index.ts"; // sanity import + +// keep unused import check quiet +void applyRenderer; + +const GIT_DIFF = `diff --git a/x.ts b/x.ts +index 111..222 100644 +--- a/x.ts ++++ b/x.ts +@@ -1,3 +1,3 @@ +-const a = 1; ++const a = 2; + const b = 3;`; + +test("enableRenderers default false ⇒ baseline unchanged", () => { + const off = processRtkText(GIT_DIFF, { command: "git diff", config: { enabled: true } }); + const explicitOff = processRtkText(GIT_DIFF, { + command: "git diff", + config: { enabled: true, enableRenderers: false }, + }); + assert.equal(off.text, explicitOff.text); // renderer não roda por default +}); + +test("enableRenderers true ⇒ git diff is rendered through processRtkText", () => { + const on = processRtkText(GIT_DIFF, { + command: "git diff", + config: { enabled: true, enableRenderers: true }, + }); + assert.ok(on.techniquesUsed.includes("rtk-render:git-diff")); + assert.ok(!on.text.includes("index ")); // contexto/metadata dropado +}); + +test("fail-open: renderer error keeps prior result", () => { + // entrada que detecta git-diff mas é benigna; renderer não deve lançar nem alterar incorretamente + const r = processRtkText("not really a diff", { + command: "git diff", + config: { enabled: true, enableRenderers: true }, + }); + assert.ok(typeof r.text === "string"); // nunca lança +});