From 4fa8acffed9eebb20ada8583f112118a29d0c828 Mon Sep 17 00:00:00 2001 From: Devika Verma Date: Wed, 29 Jul 2026 16:20:24 +0530 Subject: [PATCH] compaction: Settings overrides + GUI divider (OPE-27 3/4) Settings -> Models grows a Context compaction card next to Token savings: the trigger % of the context window (10-95), the absolute token cap (clamped 10k-2M), and the summarizer-model pin (default: the session's own model). POST /v1/settings/compaction persists them; engines read the knobs live per check, so changes apply to running sessions immediately. The "context compacted" divider rides the existing notice machinery: the persisted `compacted` notice replays on reload (itemsFromMessages) and the live COMPACTED event appends the same info notice mid-turn. The transcript itself stays intact - outbound-only by construction. Covered by vitest (marker replay), a settings-card e2e (defaults + clamped POSTs + model pin), and a mid-session divider e2e driven by the fixtures' scripted `compacted` event. --- coworker/server/app.py | 11 ++ coworker/server/manager.py | 43 +++++++ surfaces/gui/e2e/compaction.spec.ts | 75 +++++++++++ surfaces/gui/e2e/fixtures.ts | 8 ++ surfaces/gui/src/App.tsx | 5 + surfaces/gui/src/api.ts | 24 ++++ surfaces/gui/src/components/SettingsView.tsx | 124 ++++++++++++++++++- surfaces/gui/src/itemsFromMessages.test.ts | 14 +++ surfaces/gui/src/itemsFromMessages.ts | 5 +- surfaces/gui/src/types.ts | 1 + tests/test_compaction_engine.py | 26 ++++ 11 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 surfaces/gui/e2e/compaction.spec.ts diff --git a/coworker/server/app.py b/coworker/server/app.py index b27f6c7d..55cd155f 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1378,6 +1378,17 @@ def create_app(manager: SessionManager) -> FastAPI: max_mb=b.get("pdf_max_mb"), ) + @app.post("/v1/settings/compaction") + def settings_set_compaction(body: dict) -> dict[str, Any]: + # Auto-compaction overrides (OPE-27): threshold % of the context window, the + # absolute token cap, and the summarizer-model pin ("" → session's own model). + b = body or {} + return manager.set_compaction_settings( + threshold_pct=b.get("compaction_threshold_pct"), + cap_tokens=b.get("compaction_cap_tokens"), + model=b.get("compaction_model"), + ) + @app.post("/v1/attachments/inspect-pdf") def attachments_inspect_pdf(body: dict) -> dict[str, Any]: # Attach-time page/size probe for the composer's threshold check. Local only. diff --git a/coworker/server/manager.py b/coworker/server/manager.py index d43a76c3..9b79f345 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -1791,6 +1791,7 @@ class SessionManager: # hardcoded POSIX one (Windows -> %APPDATA%\coworker, macOS/Linux -> ~/.config). "secrets_path": str(self.secrets.path), **self.pdf_settings(), + **self.compaction_settings_payload(), } def _surfaces(self) -> dict[str, bool]: @@ -1884,6 +1885,48 @@ class SessionManager: "model": str(self._prefs.get("compaction_model") or ""), } + def compaction_settings_payload(self) -> dict[str, Any]: + """The same knobs under REST-facing names (prefixed to keep /v1/settings flat).""" + settings = self.compaction_settings() + return { + "compaction_threshold_pct": settings["threshold_pct"], + "compaction_cap_tokens": settings["cap_tokens"], + "compaction_model": settings["model"], + } + + def set_compaction_settings( + self, + threshold_pct: Any = None, + cap_tokens: Any = None, + model: Any = None, + ) -> dict[str, Any]: + """Persist the auto-compaction overrides (OPE-27). Threshold is a percentage of + the model's context window (10–95); the cap is an absolute token ceiling; model + pins the summarizer ('' → the session's own model). Engines read these live via + `compaction_settings()`, so changes apply to running sessions immediately.""" + if threshold_pct is not None: + try: + pct = float(threshold_pct) + except (TypeError, ValueError): + return {"ok": False, "error": "compaction_threshold_pct must be a number"} + if not 0.10 <= pct <= 0.95: + return { + "ok": False, + "error": "compaction_threshold_pct must be between 0.10 and 0.95", + } + self._prefs["compaction_threshold_pct"] = pct + if cap_tokens is not None: + try: + self._prefs["compaction_cap_tokens"] = max( + 10_000, min(int(cap_tokens), 2_000_000) + ) + except (TypeError, ValueError): + return {"ok": False, "error": "compaction_cap_tokens must be a number"} + if model is not None: + self._prefs["compaction_model"] = str(model) + self._save_prefs() + return {"ok": True, **self.compaction_settings()} + def set_pdf_settings( self, fallback: Any = None, diff --git a/surfaces/gui/e2e/compaction.spec.ts b/surfaces/gui/e2e/compaction.spec.ts new file mode 100644 index 00000000..8a3bd0e6 --- /dev/null +++ b/surfaces/gui/e2e/compaction.spec.ts @@ -0,0 +1,75 @@ +// OPE-27 — auto-compaction GUI: the Settings card's two overrides + summarizer-model +// pin POST through, and the "context compacted" divider renders inline mid-session +// (driven by the fixtures' scripted `compacted` event) without touching the transcript. +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; + +test("Settings: Context compaction card edits threshold, cap, and summarizer model", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Models", exact: true }).click(); + + const card = page.getByTestId("compaction-card"); + await expect(card).toBeVisible(); + await expect(card.getByText("Context compaction")).toBeVisible(); + + // Defaults render when the backend doesn't send the fields (older-backend robustness). + await expect(card.getByTestId("compaction-threshold")).toHaveValue("80"); + await expect(card.getByTestId("compaction-cap")).toHaveValue("250000"); + await expect(card.getByTestId("compaction-model")).toHaveValue(""); + + // Threshold edits POST as a fraction, clamped to 10–95%. + const [req] = await Promise.all([ + page.waitForRequest( + (r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST", + ), + card.getByTestId("compaction-threshold").fill("70"), + ]); + expect(req.postDataJSON()).toEqual({ compaction_threshold_pct: 0.7 }); + + const [req2] = await Promise.all([ + page.waitForRequest( + (r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST", + ), + card.getByTestId("compaction-cap").fill("100000"), + ]); + expect(req2.postDataJSON()).toEqual({ compaction_cap_tokens: 100000 }); + + // Summarizer pin: the picker offers the session-default plus the configured models. + const [req3] = await Promise.all([ + page.waitForRequest( + (r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST", + ), + card.getByTestId("compaction-model").selectOption("gpt-4o-mini"), + ]); + expect(req3.postDataJSON()).toEqual({ compaction_model: "gpt-4o-mini" }); +}); + +test("the compacted divider renders mid-session and the transcript stays intact", async ({ + page, +}) => { + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + const box = page.getByPlaceholder(/Ask the coworker/); + + // An earlier exchange that must survive the compaction marker (transcript intact). + await box.fill("remember the launch date"); + await box.press("Enter"); + await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible({ + timeout: 10_000, + }); + + await box.fill("compact the context"); + await box.press("Enter"); + await expect( + page.getByText("Context compacted — earlier turns were summarized").first(), + ).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByText("Still on it — continuing where I left off.").first(), + ).toBeVisible(); + // Outbound-only: everything before the divider is still on screen. + await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible(); +}); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 4224bff7..037fc617 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -681,6 +681,14 @@ export async function mockApi(page: import("@playwright/test").Page) { }, 120); return; } + // Auto-compaction (OPE-27): the server compacts mid-run and emits the marker, + // then the turn continues normally — the divider must render inline. + if (/compact the context/i.test(msg.text)) { + send("compacted", { text: "Context compacted — earlier turns were summarized" }); + send("assistant_message", { text: "Still on it — continuing where I left off." }); + send("turn_done"); + return; + } // A turn that dies on a provider error; the follow-up {type:"retry"} recovers. if (/fail the turn/i.test(msg.text)) { send("error", { error: "model unreachable" }); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 6c261944..f814cdb8 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -709,6 +709,11 @@ export function App() { if (d.model) setModel(d.model); setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Model switched" }]); break; + case "compacted": + // Auto-compaction marker (OPE-27): outbound-only — the transcript stays intact, + // this divider just shows where the model's memory was summarized. + setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Context compacted" }]); + break; case "interrupted": flushPartialStream(); setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Interrupted." }]); diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 80a5dcef..55d97f5e 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -702,6 +702,12 @@ export interface ModelSettings { pdf_fallback?: "text" | "images"; pdf_max_pages?: number; // default 20, 1–100 pdf_max_mb?: number; // default 10, 1–10 + // Auto-compaction of long histories (OPE-27): trigger = min(threshold% × context + // window, cap tokens); model pins the summarizer ("" → the session's own model). + // Optional so the GUI is robust to an older backend. + compaction_threshold_pct?: number; // default 0.8, 0.10–0.95 + compaction_cap_tokens?: number; // default 250000 + compaction_model?: string; } export interface PdfSettings { @@ -722,6 +728,24 @@ export async function setPdfSettings( return res.json(); } +export interface CompactionSettings { + compaction_threshold_pct: number; + compaction_cap_tokens: number; + compaction_model: string; +} + +/** Persist the auto-compaction overrides (threshold %, token cap, summarizer model). */ +export async function setCompactionSettings( + patch: Partial, +): Promise<{ ok: boolean; error?: string }> { + const res = await fetch(`${httpBase()}/v1/settings/compaction`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + return res.json(); +} + /** Local page/size probe for a PDF data URL — the composer's attach-time threshold check. */ export async function inspectPdf( dataUrl: string, diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx index 5b88cc84..fcc15505 100644 --- a/surfaces/gui/src/components/SettingsView.tsx +++ b/surfaces/gui/src/components/SettingsView.tsx @@ -2,11 +2,13 @@ import { useEffect, useState } from "react"; import { getSettings, getTrustedWorkspaces, + setCompactionSettings, setOnboarded, setPdfSettings, setScratchBase, setSessionsPeek, setWorkspaceTrusted, + type CompactionSettings, type ModelSettings, type PdfSettings, type WorkspaceCommandTrust, @@ -118,6 +120,7 @@ export function SettingsView({ not under General. */}
+
) : tab === "voice" ? ( @@ -584,9 +587,9 @@ function UpdateInline() { // -- Sidebar density ------------------------------------------------------------- // -- Token savings (PDF attachments; owner ask, 2026-07-17) --------------------- // Attachments replay with EVERY turn, so a big PDF quietly multiplies token spend. -// Auto-compaction of long histories is a planned follow-up (punchlist §7) — until -// then this card is the user's dial: attach thresholds + the fallback for models -// without native PDF support. +// This card is the attachment dial: attach thresholds + the fallback for models +// without native PDF support. (Long-history spend is handled by auto-compaction — +// the CompactionCard below, OPE-27.) function TokenSavingsCard() { const [pdf, setPdf] = useState(null); @@ -672,6 +675,121 @@ function TokenSavingsCard() { ); } +// -- Context compaction (OPE-27) ------------------------------------------------ +// Long sessions are summarized automatically when they approach the model's context +// limit, so work continues instead of hitting a raw provider error. Two spec'd +// overrides (trigger % + token cap) and the summarizer-model pin — nothing more. +function CompactionCard() { + const [cfg, setCfg] = useState(null); + const [models, setModels] = useState([]); + const [labels, setLabels] = useState>({}); + + useEffect(() => { + getSettings() + .then((s) => { + setCfg({ + compaction_threshold_pct: s.compaction_threshold_pct ?? 0.8, + compaction_cap_tokens: s.compaction_cap_tokens ?? 250_000, + compaction_model: s.compaction_model ?? "", + }); + setModels(s.models || []); + setLabels(s.model_labels || {}); + }) + .catch(() => + setCfg({ + compaction_threshold_pct: 0.8, + compaction_cap_tokens: 250_000, + compaction_model: "", + }), + ); + }, []); + + const save = async (patch: Partial) => { + setCfg((p) => (p ? { ...p, ...patch } : p)); + await setCompactionSettings(patch); + }; + + if (!cfg) return null; + const modelLabel = (id: string) => labels[id]?.split(" · ")[0] || id; + return ( +
+
Context compaction
+
+ Long sessions are compacted automatically: older turns are summarized so the + coworker keeps working instead of running out of context. Your visible transcript + is never changed — a small marker shows where compaction happened. +
+ +
+ + +
+
+ The cap makes very-large-context models compact early — quality and speed degrade + well before their nominal limit. +
+ +
+ Summarizer model + +
+
+ The summary is written by this model. The default follows whatever model the + session is using. +
+
+ ); +} + function SidebarCard() { const [peek, setPeek] = useState(null); diff --git a/surfaces/gui/src/itemsFromMessages.test.ts b/surfaces/gui/src/itemsFromMessages.test.ts index ad025475..b87b8717 100644 --- a/surfaces/gui/src/itemsFromMessages.test.ts +++ b/surfaces/gui/src/itemsFromMessages.test.ts @@ -83,6 +83,20 @@ describe("itemsFromMessages model switch", () => { }); }); +describe("itemsFromMessages compaction", () => { + it("replays the persisted compacted marker as an info notice (the divider)", () => { + const items = itemsFromMessages([ + { role: "user", content: "hi" }, + { role: "notice", kind: "compacted", text: "Context compacted — earlier turns were summarized" }, + ] as any); + expect(items[1]).toEqual({ + kind: "notice", + tone: "info", + text: "Context compacted — earlier turns were summarized", + }); + }); +}); + describe("itemsFromMessages reasoning", () => { it("attaches the reasoning sidecar to assistant items; thinking-only messages still render", () => { const items = itemsFromMessages([ diff --git a/surfaces/gui/src/itemsFromMessages.ts b/surfaces/gui/src/itemsFromMessages.ts index b13b81e2..62e9a931 100644 --- a/surfaces/gui/src/itemsFromMessages.ts +++ b/surfaces/gui/src/itemsFromMessages.ts @@ -72,7 +72,10 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] { ? { kind: "notice", tone: "warn", text: "Interrupted." } : m.kind === "model_switch" ? { kind: "notice", tone: "info", text: m.text || "Model switched" } - : { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true }, + : m.kind === "compacted" + ? // The subtle "compacted here" divider (OPE-27) — the transcript itself is intact. + { kind: "notice", tone: "info", text: m.text || "Context compacted" } + : { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true }, ); } // system messages are omitted; tool-result messages are folded into the tool row above diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index f86b3ce8..a05a37ab 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -18,6 +18,7 @@ export type EventType = | "input_rejected" | "interrupted" | "model_changed" + | "compacted" | "turn_done"; export interface WsEvent { diff --git a/tests/test_compaction_engine.py b/tests/test_compaction_engine.py index ff54748f..99e1270c 100644 --- a/tests/test_compaction_engine.py +++ b/tests/test_compaction_engine.py @@ -204,6 +204,32 @@ def test_non_overflow_provider_errors_still_surface(tmp_path): assert not any(e.type == EventType.COMPACTED for e in events) +def test_set_compaction_settings_validates_and_round_trips(tmp_path): + from coworker.server.manager import SessionManager + + class Provider(ProviderClient): + def complete(self, *, model, messages, tools=None, **settings): + return AssistantTurn(text="hi") + + def capabilities(self, model): + return ModelCapabilities() + + mgr = SessionManager(workspace=tmp_path, provider=Provider()) + out = mgr.set_compaction_settings( + threshold_pct=0.5, cap_tokens=100_000, model="gpt-4o-mini" + ) + assert out["ok"] and out["threshold_pct"] == 0.5 and out["cap_tokens"] == 100_000 + assert mgr.compaction_settings()["model"] == "gpt-4o-mini" + # validation: out-of-range % and non-numeric cap are rejected, tiny caps clamp up + assert mgr.set_compaction_settings(threshold_pct=0.05)["ok"] is False + assert mgr.set_compaction_settings(cap_tokens="lots")["ok"] is False + assert mgr.set_compaction_settings(cap_tokens=1)["cap_tokens"] == 10_000 + # the flat /v1/settings names + payload = mgr.compaction_settings_payload() + assert payload["compaction_threshold_pct"] == 0.5 + assert payload["compaction_model"] == "gpt-4o-mini" + + def test_compaction_state_survives_save_and_rebuild(tmp_path): from coworker.compaction import CompactionState from coworker.server.manager import SessionManager