mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 07:20:10 +00:00
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:
@@ -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();
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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." }]);
|
||||
|
||||
@@ -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<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,
|
||||
|
||||
@@ -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’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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ export type EventType =
|
||||
| "input_rejected"
|
||||
| "interrupted"
|
||||
| "model_changed"
|
||||
| "compacted"
|
||||
| "turn_done";
|
||||
|
||||
export interface WsEvent {
|
||||
|
||||
Reference in New Issue
Block a user