diff --git a/CHANGELOG.md b/CHANGELOG.md index fceb932086..ca30677b27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - **memory (typed decay):** add **opt-in typed memory decay** (TV6) so the conversational memory store stops accumulating stale `episodic` noise. Each injected memory now tracks an `access_count` + `last_accessed_at` (always-on, non-destructive telemetry; migration `111_memory_typed_decay`), and an **opt-in, default-off** sweep (`MEMORY_TYPED_DECAY_ENABLED`, default `false`) deletes memories that are past a **per-type TTL** and not immune. Only `episodic` decays by default (30d, env-tunable); `factual`/`procedural`/`semantic` are immune, and any memory accessed `>= 3` times earns access immunity (mirroring "guardrail/convention/decision never decay"). The decay clock re-bases on the last access, so used memories survive. Deletions reuse `deleteMemory` (SQLite + sqlite-vec + Qdrant stay in sync) and fail open; an optional periodic sweep is doubly opt-in (also needs `MEMORY_TYPED_DECAY_SWEEP_INTERVAL>0`). With the flag off nothing is ever deleted (Rule #20 spirit). New `src/lib/memory/typedDecay.ts`. Regression guard: `tests/unit/memory/typed-decay.test.ts` (15). gaps v3.8.42 — T10/TV6. +- **dashboard (combos):** the named-combos editor now lets you **drag to reorder** the stacked-compression pipeline instead of only editing fixed-position steps. A new pure model (`src/shared/components/compression/compressionPipelineModel.ts`) owns add/remove/move/update with the engine→intensity invariant and a never-empty guarantee, and a `@dnd-kit/sortable` editor (`CompressionPipelineEditor.tsx`, matching the sidebar reorder pattern) replaces the inline list in `CompressionCombosPageClient`. Order persists through the existing combos endpoint. Regression guards: `tests/unit/compression-pipeline-model.test.ts` (11) + `tests/unit/ui/compression-pipeline-editor.test.tsx` (4). A dedicated `tests/e2e/compression-studio.spec.ts` (Tela A render + tab switch) closes the studios e2e gap the combo-live spec did not cover. gaps v3.8.42 — T06 + T03. + ### 🔧 Bug Fixes - **translator/chatcore (hardening):** re-apply two defensive review-fixes that were dropped in a branch rebuild before #5661 / #5662 landed. (1) `mergeConsecutiveSameRoleContents` (OpenAI→Gemini) now shallow-copies each entry and its `parts` array instead of pushing the input reference, so the consecutive-same-role merge never mutates the caller's objects. (2) `defaultClaudeToolType` (Claude tool defaults) now passes any non-object array entry (`null` / primitive) through unchanged instead of spreading it into a fabricated `{ type: "custom", … }` tool. No behavior change on real payloads (Gemini contents are freshly built; Claude tools are always objects); both properties are now locked by regression tests in `tests/unit/translator-gemini-consecutive-role-2191.test.ts` and `tests/unit/claude-tool-type-default-2195.test.ts`. diff --git a/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx b/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx index 8bdfc4703d..c20bef4aad 100644 --- a/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx +++ b/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from "react"; import { STACKED_PIPELINE_ENGINE_INTENSITIES } from "@/shared/validation/compressionConfigSchemas"; +import { CompressionPipelineEditor } from "@/shared/components/compression/CompressionPipelineEditor"; import CompressionHub from "./CompressionHub"; type PipelineStep = { engine: string; intensity?: string }; @@ -158,20 +159,6 @@ function NamedCombosManager() { if (res.ok) refresh(); }; - const updateStep = (index: number, patch: Partial) => { - setPipeline((current) => - current.map((step, stepIndex) => { - if (stepIndex !== index) return step; - const next = { ...step, ...patch }; - const allowed = ENGINE_INTENSITIES[next.engine] ?? ["standard"]; - return { - ...next, - intensity: allowed.includes(next.intensity ?? "") ? next.intensity : allowed[0], - }; - }) - ); - }; - const togglePack = (language: string, enabled: boolean) => { setSelectedPacks((current) => enabled @@ -211,51 +198,12 @@ function NamedCombosManager() { /> -
-
-

Pipeline

- -
- {pipeline.map((step, index) => ( -
- - - -
- ))} +
+
diff --git a/src/shared/components/compression/CompressionPipelineEditor.tsx b/src/shared/components/compression/CompressionPipelineEditor.tsx new file mode 100644 index 0000000000..c295f63ea3 --- /dev/null +++ b/src/shared/components/compression/CompressionPipelineEditor.tsx @@ -0,0 +1,175 @@ +"use client"; + +// T06 — drag-to-reorder editor for the stacked-compression pipeline (gaps v3.8.42). +// +// A controlled component: it renders `steps` and reports every edit back through `onChange` +// (parent owns the state + persistence). All mutations go through the pure +// `compressionPipelineModel` so invariants (valid intensity, non-empty pipeline) hold. +// +// Hydration note: this lives under the combos screen, which deliberately uses NO +// `useTranslations` (an earlier redesign failed to hydrate on the production build with a +// page-level `useTranslations`). Strings are literal English to match `CompressionHub`. + +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, + sortableKeyboardCoordinates, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + allowedIntensities, + addLayer, + moveLayer, + removeLayer, + updateLayer, + type EngineIntensities, + type PipelineStep, +} from "./compressionPipelineModel"; + +export type { PipelineStep } from "./compressionPipelineModel"; + +type Props = { + steps: PipelineStep[]; + onChange: (steps: PipelineStep[]) => void; + engineIntensities: EngineIntensities; +}; + +const SELECT_CLASS = + "rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main w-full"; + +function SortableRow(props: { + id: string; + index: number; + step: PipelineStep; + engines: string[]; + engineIntensities: EngineIntensities; + canRemove: boolean; + onPatch: (patch: Partial) => void; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: props.id, + }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.6 : 1, + }; + return ( +
+ + + + +
+ ); +} + +export function CompressionPipelineEditor({ steps, onChange, engineIntensities }: Props) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + // Index-based stable ids: a controlled list with no per-row id. Ids are stable within a + // render (0..n-1); reorder maps id→index before delegating to the pure model. + const ids = steps.map((_, index) => String(index)); + const engines = Object.keys(engineIntensities); + const firstEngine = engines[0] ?? "rtk"; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const from = ids.indexOf(String(active.id)); + const to = ids.indexOf(String(over.id)); + onChange(moveLayer(steps, from, to)); + }; + + return ( +
+
+

Pipeline

+ +
+ + +
+ {steps.map((step, index) => ( + 1} + onPatch={(patch) => onChange(updateLayer(steps, index, patch, engineIntensities))} + onRemove={() => onChange(removeLayer(steps, index))} + /> + ))} +
+
+
+
+ ); +} diff --git a/src/shared/components/compression/compressionPipelineModel.ts b/src/shared/components/compression/compressionPipelineModel.ts new file mode 100644 index 0000000000..ca2f7b468c --- /dev/null +++ b/src/shared/components/compression/compressionPipelineModel.ts @@ -0,0 +1,78 @@ +/** + * Pure model for the stacked-compression pipeline editor (T06 — gaps v3.8.42). + * + * Every operation returns a NEW `PipelineStep[]` (never mutates the input), preserves the + * engine→intensity invariant (a step's intensity is always one its engine allows), and keeps + * the pipeline non-empty — so the combos editor can never persist an invalid stacked + * pipeline that the `PUT /api/context/combos/[id]` route would reject. The React editor is a + * thin shell over these functions; the logic is tested in isolation. + */ + +export type PipelineStep = { engine: string; intensity?: string }; +export type EngineIntensities = Record; + +const FALLBACK_INTENSITIES: readonly string[] = ["standard"]; + +/** Intensities a given engine allows (falls back to `["standard"]` for unknown engines). */ +export function allowedIntensities( + engine: string, + table: EngineIntensities +): readonly string[] { + const list = table[engine]; + return list && list.length > 0 ? list : FALLBACK_INTENSITIES; +} + +/** Coerce a step's intensity to one valid for its engine (first allowed when invalid). */ +export function normalizeStep(step: PipelineStep, table: EngineIntensities): PipelineStep { + const allowed = allowedIntensities(step.engine, table); + return { + engine: step.engine, + intensity: allowed.includes(step.intensity ?? "") ? step.intensity : allowed[0], + }; +} + +/** + * Reorder: move the step at `from` to `to`. Out-of-range indices (or `from === to`) return a + * copy unchanged. The result is always a permutation of the input (same length, same members). + */ +export function moveLayer(steps: PipelineStep[], from: number, to: number): PipelineStep[] { + const next = steps.slice(); + if (from === to) return next; + if (from < 0 || from >= steps.length || to < 0 || to >= steps.length) return next; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; +} + +/** Append a new layer (normalized for its engine). */ +export function addLayer( + steps: PipelineStep[], + step: PipelineStep, + table: EngineIntensities +): PipelineStep[] { + return [...steps, normalizeStep(step, table)]; +} + +/** Remove the layer at `index`, never dropping below `minLength` (default 1). */ +export function removeLayer( + steps: PipelineStep[], + index: number, + minLength = 1 +): PipelineStep[] { + if (steps.length <= minLength) return steps.slice(); + if (index < 0 || index >= steps.length) return steps.slice(); + return steps.filter((_, i) => i !== index); +} + +/** Patch the layer at `index`, re-normalizing intensity for the (possibly new) engine. */ +export function updateLayer( + steps: PipelineStep[], + index: number, + patch: Partial, + table: EngineIntensities +): PipelineStep[] { + if (index < 0 || index >= steps.length) return steps.slice(); + return steps.map((step, i) => + i === index ? normalizeStep({ ...step, ...patch }, table) : step + ); +} diff --git a/tests/e2e/compression-studio.spec.ts b/tests/e2e/compression-studio.spec.ts new file mode 100644 index 0000000000..08c298868a --- /dev/null +++ b/tests/e2e/compression-studio.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; +import { gotoDashboardRoute } from "./helpers/dashboardAuth"; + +/** + * T03 — Compression Studio (Tela A) smoke e2e (gaps v3.8.42). + * + * The studio's reducers/renderers (compressionFlowModel, WaterfallInspector, + * CompressionCockpit, PlayView/CompareView) are unit/vitest-covered. What unit tests + * CANNOT catch is a client-side render / hydration crash on the real page — the same + * "no `useTranslations` trap" risk the compression-UI plan flagged. The existing + * combo-live-studio spec guards `/dashboard/combos/live`; this is its missing + * counterpart for the dedicated compression studio (Tela A), which that spec does not + * touch. + * + * It loads /dashboard/compression/studio and asserts the studio mounts (Play tab + its + * lane), then flips to the Compare tab and asserts the switch took effect. The + * visibility assertions ARE the hydration-trap guard: a render-time crash would mean + * none of these testids ever appear. + * + * Out of scope (kept unit-covered, to stay non-flaky): driving a live WS compression + * cascade (needs `compression.step` events an e2e cannot inject) and asserting console + * output (dev-mode on-demand compilation emits transient fast-refresh noise). + */ +test.describe("Compression Studio (Tela A)", () => { + test("loads /dashboard/compression/studio and renders the Play lane without crashing", async ({ + page, + }) => { + await gotoDashboardRoute(page, "/dashboard/compression/studio"); + + const playTab = page.locator('[data-testid="tab-play"]'); + await expect(playTab).toBeVisible({ timeout: 30_000 }); + // Play view is the default tab → its lane proves the studio body mounted. + await expect(page.locator('[data-testid="play-lane"]').first()).toBeVisible({ + timeout: 30_000, + }); + }); + + test("switches from Play to Compare", async ({ page }) => { + await gotoDashboardRoute(page, "/dashboard/compression/studio"); + + const compareTab = page.locator('[data-testid="tab-compare"]'); + await expect(compareTab).toBeVisible({ timeout: 30_000 }); + await compareTab.click(); + await expect(compareTab).toHaveAttribute("aria-pressed", "true"); + // Compare view mounted → its load control is present. + await expect(page.locator('[data-testid="compare-load"]').first()).toBeVisible({ + timeout: 30_000, + }); + }); +}); diff --git a/tests/unit/compression-pipeline-model.test.ts b/tests/unit/compression-pipeline-model.test.ts new file mode 100644 index 0000000000..4ed775f0a4 --- /dev/null +++ b/tests/unit/compression-pipeline-model.test.ts @@ -0,0 +1,111 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + allowedIntensities, + normalizeStep, + moveLayer, + addLayer, + removeLayer, + updateLayer, + type EngineIntensities, + type PipelineStep, +} from "../../src/shared/components/compression/compressionPipelineModel.ts"; + +// T06 — pure pipeline model. Reorder/add/remove/update preserve invariants. + +const TABLE: EngineIntensities = { + rtk: ["standard", "aggressive"], + caveman: ["lite", "full", "ultra"], + llmlingua: ["standard"], +}; + +const BASE: PipelineStep[] = [ + { engine: "rtk", intensity: "standard" }, + { engine: "caveman", intensity: "full" }, + { engine: "llmlingua", intensity: "standard" }, +]; + +describe("compressionPipelineModel — normalize", () => { + it("keeps a valid intensity and coerces an invalid one to the first allowed", () => { + assert.deepEqual(normalizeStep({ engine: "caveman", intensity: "ultra" }, TABLE), { + engine: "caveman", + intensity: "ultra", + }); + assert.deepEqual(normalizeStep({ engine: "caveman", intensity: "bogus" }, TABLE), { + engine: "caveman", + intensity: "lite", + }); + }); + + it("falls back to standard for an unknown engine", () => { + assert.deepEqual(allowedIntensities("nope", TABLE), ["standard"]); + assert.deepEqual(normalizeStep({ engine: "nope" }, TABLE), { + engine: "nope", + intensity: "standard", + }); + }); +}); + +describe("compressionPipelineModel — moveLayer", () => { + it("moves a step and is always a permutation (same length + members)", () => { + const moved = moveLayer(BASE, 0, 2); + assert.deepEqual( + moved.map((s) => s.engine), + ["caveman", "llmlingua", "rtk"] + ); + assert.equal(moved.length, BASE.length); + }); + + it("does not mutate the input", () => { + const copy = BASE.map((s) => ({ ...s })); + moveLayer(BASE, 0, 2); + assert.deepEqual(BASE, copy); + }); + + it("returns an unchanged copy for out-of-range or no-op moves", () => { + assert.deepEqual(moveLayer(BASE, 1, 1), BASE); + assert.deepEqual(moveLayer(BASE, -1, 2), BASE); + assert.deepEqual(moveLayer(BASE, 0, 9), BASE); + }); +}); + +describe("compressionPipelineModel — add/remove", () => { + it("addLayer appends a normalized step", () => { + const next = addLayer(BASE, { engine: "caveman", intensity: "bogus" }, TABLE); + assert.equal(next.length, 4); + assert.deepEqual(next[3], { engine: "caveman", intensity: "lite" }); + }); + + it("removeLayer drops the indexed step but never goes below minLength", () => { + const next = removeLayer(BASE, 1); + assert.deepEqual( + next.map((s) => s.engine), + ["rtk", "llmlingua"] + ); + const single: PipelineStep[] = [{ engine: "rtk", intensity: "standard" }]; + assert.deepEqual(removeLayer(single, 0), single, "must not remove the last step"); + }); + + it("removeLayer ignores out-of-range index", () => { + assert.deepEqual(removeLayer(BASE, 9), BASE); + }); +}); + +describe("compressionPipelineModel — updateLayer", () => { + it("patches a step and re-normalizes intensity for a new engine", () => { + const next = updateLayer(BASE, 0, { engine: "caveman" }, TABLE); + // rtk(standard) → caveman: 'standard' is not a caveman intensity → first allowed (lite) + assert.deepEqual(next[0], { engine: "caveman", intensity: "lite" }); + // other steps untouched + assert.deepEqual(next[1], BASE[1]); + }); + + it("keeps a still-valid intensity when only the engine changes to a compatible one", () => { + const next = updateLayer(BASE, 1, { intensity: "ultra" }, TABLE); + assert.deepEqual(next[1], { engine: "caveman", intensity: "ultra" }); + }); + + it("ignores out-of-range index", () => { + assert.deepEqual(updateLayer(BASE, 9, { engine: "rtk" }, TABLE), BASE); + }); +}); diff --git a/tests/unit/ui/compression-pipeline-editor.test.tsx b/tests/unit/ui/compression-pipeline-editor.test.tsx new file mode 100644 index 0000000000..7324100dd8 --- /dev/null +++ b/tests/unit/ui/compression-pipeline-editor.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +// +// T06 — CompressionPipelineEditor (gaps v3.8.42). The drag-reorder logic itself lives in +// the pure `compressionPipelineModel` (covered by compression-pipeline-model.test.ts); here +// we assert the controlled-component wiring: rendering one row per step and reporting +// add/remove/patch edits through `onChange`. +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +const { CompressionPipelineEditor } = await import( + "../../../src/shared/components/compression/CompressionPipelineEditor" +); + +const TABLE = { + rtk: ["standard", "aggressive"], + caveman: ["lite", "full", "ultra"], +} as const; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +function render(steps: { engine: string; intensity?: string }[], onChange: (s: unknown) => void) { + act(() => { + root.render( + } + /> + ); + }); +} + +describe("CompressionPipelineEditor (T06)", () => { + it("renders one sortable row per step", () => { + render( + [ + { engine: "rtk", intensity: "standard" }, + { engine: "caveman", intensity: "full" }, + ], + () => {} + ); + expect(container.querySelector('[data-testid="compression-pipeline-editor"]')).toBeTruthy(); + expect(container.querySelectorAll('[data-testid^="pipeline-row-"]').length).toBe(2); + // each row exposes a drag handle + expect(container.querySelectorAll('[data-testid^="pipeline-drag-"]').length).toBe(2); + }); + + it("Add step appends a normalized step via onChange", () => { + let received: { engine: string; intensity?: string }[] | null = null; + render([{ engine: "rtk", intensity: "standard" }], (s) => { + received = s as typeof received; + }); + const addBtn = container.querySelector('[data-testid="pipeline-add-step"]') as HTMLButtonElement; + act(() => addBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(received).not.toBeNull(); + expect(received!.length).toBe(2); + expect(received![1].engine).toBe("rtk"); + // appended step is normalized (valid intensity for the engine) + expect(TABLE.rtk).toContain(received![1].intensity); + }); + + it("Remove drops the row; the button is disabled when only one step remains", () => { + let received: unknown[] | null = null; + render( + [ + { engine: "rtk", intensity: "standard" }, + { engine: "caveman", intensity: "full" }, + ], + (s) => { + received = s as unknown[]; + } + ); + const removeBtn = container.querySelector( + '[data-testid="pipeline-remove-0"]' + ) as HTMLButtonElement; + expect(removeBtn.disabled).toBe(false); + act(() => removeBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(received).toEqual([{ engine: "caveman", intensity: "full" }]); + + // single-step pipeline: remove is disabled (never below minLength 1) + render([{ engine: "rtk", intensity: "standard" }], () => {}); + const onlyRemove = container.querySelector( + '[data-testid="pipeline-remove-0"]' + ) as HTMLButtonElement; + expect(onlyRemove.disabled).toBe(true); + }); + + it("changing the engine re-normalizes the intensity through onChange", () => { + let received: { engine: string; intensity?: string }[] | null = null; + render([{ engine: "rtk", intensity: "aggressive" }], (s) => { + received = s as typeof received; + }); + const engineSelect = container.querySelector( + '[data-testid="pipeline-row-0"] select[aria-label="Engine"]' + ) as HTMLSelectElement; + act(() => { + engineSelect.value = "caveman"; + engineSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(received).not.toBeNull(); + // 'aggressive' is not a caveman intensity → coerced to the first caveman intensity + expect(received![0].engine).toBe("caveman"); + expect(TABLE.caveman).toContain(received![0].intensity); + expect(received![0].intensity).toBe("lite"); + }); +});