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.
This commit is contained in:
Devika Verma
2026-07-29 16:20:24 +05:30
parent f08a3c425b
commit 4fa8acffed
11 changed files with 332 additions and 4 deletions
+11
View File
@@ -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.
+43
View File
@@ -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 (1095); 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,
+75
View File
@@ -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 1095%.
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();
});
+8
View File
@@ -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" });
+5
View File
@@ -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." }]);
+24
View File
@@ -702,6 +702,12 @@ export interface ModelSettings {
pdf_fallback?: "text" | "images";
pdf_max_pages?: number; // default 20, 1100
pdf_max_mb?: number; // default 10, 110
// 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.100.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<CompactionSettings>,
): 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,
+121 -3
View File
@@ -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. */}
<div className="mt-6">
<TokenSavingsCard />
<CompactionCard />
</div>
</section>
) : 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<PdfSettings | null>(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<CompactionSettings | null>(null);
const [models, setModels] = useState<string[]>([]);
const [labels, setLabels] = useState<Record<string, string>>({});
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<CompactionSettings>) => {
setCfg((p) => (p ? { ...p, ...patch } : p));
await setCompactionSettings(patch);
};
if (!cfg) return null;
const modelLabel = (id: string) => labels[id]?.split(" · ")[0] || id;
return (
<div className={CARD + " p-4 mb-4"} data-testid="compaction-card">
<div className={FIELD_LABEL}>Context compaction</div>
<div className={FIELD_HELP}>
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.
</div>
<div className="mt-3 flex items-center gap-5 flex-wrap">
<label className="flex items-center gap-2.5">
<span className="text-[13px] text-ink">Compact at</span>
<input
type="number"
min={10}
max={95}
value={Math.round(cfg.compaction_threshold_pct * 100)}
data-testid="compaction-threshold"
className="w-16 px-2 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"
onChange={(e) =>
save({
compaction_threshold_pct:
Math.max(10, Math.min(Number(e.target.value) || 80, 95)) / 100,
})
}
/>
<span className="text-[12.5px] text-muted">% of the context window</span>
</label>
<label className="flex items-center gap-2.5">
<span className="text-[13px] text-ink">or at</span>
<input
type="number"
min={10_000}
max={2_000_000}
step={10_000}
value={cfg.compaction_cap_tokens}
data-testid="compaction-cap"
className="w-28 px-2 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"
onChange={(e) =>
save({
compaction_cap_tokens: Math.max(
10_000,
Math.min(Number(e.target.value) || 250_000, 2_000_000),
),
})
}
/>
<span className="text-[12.5px] text-muted">tokens, whichever is smaller</span>
</label>
</div>
<div className={FIELD_HELP}>
The cap makes very-large-context models compact early quality and speed degrade
well before their nominal limit.
</div>
<div className="mt-3 flex items-center gap-2.5">
<span className="text-[13px] text-ink">Summarizer model</span>
<select
value={cfg.compaction_model}
data-testid="compaction-model"
className="px-2 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"
onChange={(e) => save({ compaction_model: e.target.value })}
>
<option value="">Session&rsquo;s own model (default)</option>
{models.map((m) => (
<option key={m} value={m}>
{modelLabel(m)}
</option>
))}
</select>
</div>
<div className={FIELD_HELP}>
The summary is written by this model. The default follows whatever model the
session is using.
</div>
</div>
);
}
function SidebarCard() {
const [peek, setPeek] = useState<number | null>(null);
@@ -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([
+4 -1
View File
@@ -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
+1
View File
@@ -18,6 +18,7 @@ export type EventType =
| "input_rejected"
| "interrupted"
| "model_changed"
| "compacted"
| "turn_done";
export interface WsEvent {
+26
View File
@@ -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