compaction: the surface — settings row, two ruled dividers, summary dialog, send gate (round 14 slice 3)
- settings: compaction.thresholdTokens row on the Agent tab (owner pick)
in a Context group — CommitInput mirroring the graph budget row, hint
resolved through the SAME core resolver the trigger reads; PUT rejects
below the 32k floor with the anti-churn rationale; own section
(compaction) in SETTINGS_SECTIONS + the settings-signal union
- view fold: one marker → two dividers — the minimal MOMENT divider at
the marker (retrying-message register, no rule lines) and the longer
CUT divider emitted immediately before the first kept message
(phase-divider geometry, chip carries '· Nk tok'); both carry the full
summary payload; full history stays visible to the user
- components/compaction-divider.tsx: the dividers + summary dialog
(pure exported body for SSR pins, the delete-dialog pattern; Esc +
Close dismiss, backdrop does not)
- chat.tsx: compaction-changed SSE flips the running state — composer
canSend, onSend/doSend/doSteerOrSend, and both docks' submit + hint
('compacting session…') gate on it (the server's registry mutex is the
structural gate; this is the affordance); dialog mounts beside the
abort dialog
- pins: settings-body (4), view fold (2), divider SSR (4)
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
* discipline, same as the per-chat and defaults routes); sections are
|
* discipline, same as the per-chat and defaults routes); sections are
|
||||||
* independent, so partial writes can never clobber each other.
|
* independent, so partial writes can never clobber each other.
|
||||||
*
|
*
|
||||||
* section ∈ general | chat | agent | appearance | graph (else 404).
|
* section ∈ general | chat | agent | appearance | graph | compaction
|
||||||
* Body: the complete desired section object. Parse + validate strictly
|
* Body: the complete desired section object. Parse + validate strictly
|
||||||
* (server/settings-body.ts): a bad shape/type is a 400 carrying the
|
* (server/settings-body.ts): a bad shape/type is a 400 carrying the
|
||||||
* supported list where one exists. The chat and models sections
|
* supported list where one exists. The chat and models sections
|
||||||
|
|||||||
+50
-4
@@ -43,6 +43,12 @@ import { TopBar } from "@/components/top-bar";
|
|||||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
|
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
|
||||||
import type { LhViewMessage } from "@/src/core";
|
import type { LhViewMessage } from "@/src/core";
|
||||||
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
||||||
|
import {
|
||||||
|
CompactionCutDivider,
|
||||||
|
CompactionMomentDivider,
|
||||||
|
CompactionSummaryDialog,
|
||||||
|
type CompactionDividerData,
|
||||||
|
} from "@/components/compaction-divider";
|
||||||
import { CoverageRing } from "@/components/extraction-coverage";
|
import { CoverageRing } from "@/components/extraction-coverage";
|
||||||
import {
|
import {
|
||||||
activeProblemOf,
|
activeProblemOf,
|
||||||
@@ -613,7 +619,14 @@ export function Chat({
|
|||||||
// a pending question still owns the interaction (the dock replaces the
|
// a pending question still owns the interaction (the dock replaces the
|
||||||
// composer, and there is no live turn to steer anyway — questions end
|
// composer, and there is no live turn to steer anyway — questions end
|
||||||
// turns).
|
// turns).
|
||||||
const canSend = (text.trim().length > 0 || dictation.active) && !pending && !activeProblem;
|
// Compaction (owner round 14): the RUNNING state — compaction-changed
|
||||||
|
// flips it; the send paths, canSend, and the docks gate on it (the
|
||||||
|
// server's registry mutex is the structural gate; this is the
|
||||||
|
// affordance). The summary dialog state rides with it.
|
||||||
|
const [compacting, setCompacting] = useState(false);
|
||||||
|
const [compactionSummary, setCompactionSummary] = useState<CompactionDividerData | null>(null);
|
||||||
|
const canSend =
|
||||||
|
(text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting;
|
||||||
|
|
||||||
const syncFromServer = useCallback(async () => {
|
const syncFromServer = useCallback(async () => {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
@@ -722,6 +735,7 @@ export function Chat({
|
|||||||
// popup) mount INSIDE the session-area div — both are session-scoped, so
|
// popup) mount INSIDE the session-area div — both are session-scoped, so
|
||||||
// the drag bar stays sharp and draggable while a focus owns the area.
|
// the drag bar stays sharp and draggable while a focus owns the area.
|
||||||
const [mermaidFocus, setMermaidFocus] = useState<string | null>(null);
|
const [mermaidFocus, setMermaidFocus] = useState<string | null>(null);
|
||||||
|
|
||||||
// Overlay mirrors for the layered-Esc gate (owner 2026-10-04): the
|
// Overlay mirrors for the layered-Esc gate (owner 2026-10-04): the
|
||||||
// lh:kbd-abort-turn handler reads these at EVENT time — declared here
|
// lh:kbd-abort-turn handler reads these at EVENT time — declared here
|
||||||
// beside their states, consumed below wherever the states live.
|
// beside their states, consumed below wherever the states live.
|
||||||
@@ -995,6 +1009,14 @@ export function Chat({
|
|||||||
// Fully idle — a turn we never saw (every window missed): adopt now.
|
// Fully idle — a turn we never saw (every window missed): adopt now.
|
||||||
void syncFromServer();
|
void syncFromServer();
|
||||||
});
|
});
|
||||||
|
useServerEvent("compaction-changed", (p) => {
|
||||||
|
// The settle-kick's background summary: while it runs, sends gate
|
||||||
|
// (the next turn's server-side ensureCompacted awaits it anyway —
|
||||||
|
// this only keeps the affordance honest). On finish, record-changed
|
||||||
|
// (emitted by the compaction write itself) carries the marker in.
|
||||||
|
if (p.chatId !== chatId) return;
|
||||||
|
setCompacting(p.running);
|
||||||
|
});
|
||||||
useServerEvent("record-changed", (p) => {
|
useServerEvent("record-changed", (p) => {
|
||||||
// The silent dismissal write (A2, 2026-10-03): no turn ever ran, so
|
// The silent dismissal write (A2, 2026-10-03): no turn ever ran, so
|
||||||
// only the record moved. Other tabs and post-error windows adopt;
|
// only the record moved. Other tabs and post-error windows adopt;
|
||||||
@@ -1008,7 +1030,7 @@ export function Chat({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSend = () => {
|
const onSend = () => {
|
||||||
if (pending) return;
|
if (pending || compacting) return;
|
||||||
/* FINISH DICTATION FIRST (owner, 2026-10-01): a learner who keeps
|
/* FINISH DICTATION FIRST (owner, 2026-10-01): a learner who keeps
|
||||||
* talking right through Enter must not lose the tail — stop the run,
|
* talking right through Enter must not lose the tail — stop the run,
|
||||||
* let the last finals land in the composer, then send the REF value
|
* let the last finals land in the composer, then send the REF value
|
||||||
@@ -1020,7 +1042,7 @@ export function Chat({
|
|||||||
void dictation.finish().then(doSteerOrSend);
|
void dictation.finish().then(doSteerOrSend);
|
||||||
};
|
};
|
||||||
const doSend = () => {
|
const doSend = () => {
|
||||||
if (busy || pending) return;
|
if (busy || pending || compacting) return;
|
||||||
const t = textRef.current.trim();
|
const t = textRef.current.trim();
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
draftRef.current = t; // the refusal flow's preserve-and-replay seam
|
draftRef.current = t; // the refusal flow's preserve-and-replay seam
|
||||||
@@ -1038,7 +1060,7 @@ export function Chat({
|
|||||||
* time the send's POST resolved, so the steer lands. Idle takes the
|
* time the send's POST resolved, so the steer lands. Idle takes the
|
||||||
* plain send; a raced settle 409s and falls back to exactly that. */
|
* plain send; a raced settle 409s and falls back to exactly that. */
|
||||||
const doSteerOrSend = () => {
|
const doSteerOrSend = () => {
|
||||||
if (pending) return;
|
if (pending || compacting) return;
|
||||||
const t = textRef.current.trim();
|
const t = textRef.current.trim();
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
if (busy) {
|
if (busy) {
|
||||||
@@ -1227,6 +1249,22 @@ export function Chat({
|
|||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case "data-graph-context":
|
case "data-graph-context":
|
||||||
return <GraphContextCard key={pi} data={part.data as GraphContextData} />;
|
return <GraphContextCard key={pi} data={part.data as GraphContextData} />;
|
||||||
|
case "data-compaction":
|
||||||
|
return (
|
||||||
|
<CompactionMomentDivider
|
||||||
|
key={pi}
|
||||||
|
data={part.data as CompactionDividerData}
|
||||||
|
onOpen={() => setCompactionSummary(part.data as CompactionDividerData)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "data-compaction-cut":
|
||||||
|
return (
|
||||||
|
<CompactionCutDivider
|
||||||
|
key={pi}
|
||||||
|
data={part.data as CompactionDividerData}
|
||||||
|
onOpen={() => setCompactionSummary(part.data as CompactionDividerData)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "data-practice-thinking":
|
case "data-practice-thinking":
|
||||||
return <ThinkingCard key={pi} data={part.data as PracticeThinkingData} />;
|
return <ThinkingCard key={pi} data={part.data as PracticeThinkingData} />;
|
||||||
default:
|
default:
|
||||||
@@ -1418,6 +1456,7 @@ export function Chat({
|
|||||||
<DockOverlay
|
<DockOverlay
|
||||||
pending={pending}
|
pending={pending}
|
||||||
practice={activeProblem}
|
practice={activeProblem}
|
||||||
|
compactionRunning={compacting}
|
||||||
onSubmitThinking={submitThinking}
|
onSubmitThinking={submitThinking}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
|
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
|
||||||
@@ -1491,6 +1530,7 @@ export function Chat({
|
|||||||
)}
|
)}
|
||||||
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
|
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
|
||||||
<AbortTurnDialog mode={abortAsk} onClose={() => setAbortAsk(null)} onConfirm={confirmAbort} busy={busy} />
|
<AbortTurnDialog mode={abortAsk} onClose={() => setAbortAsk(null)} onConfirm={confirmAbort} busy={busy} />
|
||||||
|
<CompactionSummaryDialog summary={compactionSummary} onClose={() => setCompactionSummary(null)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1632,6 +1672,7 @@ export function shouldStampGeometryMute(nextH: number, prevH: number, pending: b
|
|||||||
function DockOverlay({
|
function DockOverlay({
|
||||||
pending,
|
pending,
|
||||||
practice,
|
practice,
|
||||||
|
compactionRunning,
|
||||||
onSubmitThinking,
|
onSubmitThinking,
|
||||||
requiresReveal,
|
requiresReveal,
|
||||||
readingThinking,
|
readingThinking,
|
||||||
@@ -1650,6 +1691,9 @@ function DockOverlay({
|
|||||||
queuedSteers,
|
queuedSteers,
|
||||||
}: {
|
}: {
|
||||||
pending: ReturnType<typeof findPendingToolCall>;
|
pending: ReturnType<typeof findPendingToolCall>;
|
||||||
|
/** Compaction running for this chat (round 14) — the docks' submit
|
||||||
|
* affordance gate (the server's registry is the real one). */
|
||||||
|
compactionRunning: boolean;
|
||||||
/** The ACTIVE practice problem (practice sessions, 2026-10-04):
|
/** The ACTIVE practice problem (practice sessions, 2026-10-04):
|
||||||
* {toolCallId, input} — dock ownership exactly like a pending
|
* {toolCallId, input} — dock ownership exactly like a pending
|
||||||
* question (the composer is replaced); null in teaching sessions. */
|
* question (the composer is replaced); null in teaching sessions. */
|
||||||
@@ -2067,6 +2111,7 @@ function DockOverlay({
|
|||||||
onSubmit={onSubmitAnswer}
|
onSubmit={onSubmitAnswer}
|
||||||
onEscape={dictationCancel}
|
onEscape={dictationCancel}
|
||||||
dictationClaim={!revealPending}
|
dictationClaim={!revealPending}
|
||||||
|
compactionRunning={compactionRunning}
|
||||||
/>
|
/>
|
||||||
) : practice ? (
|
) : practice ? (
|
||||||
<PracticeDock
|
<PracticeDock
|
||||||
@@ -2079,6 +2124,7 @@ function DockOverlay({
|
|||||||
}}
|
}}
|
||||||
onSubmit={onSubmitThinking}
|
onSubmit={onSubmitThinking}
|
||||||
onEscape={dictationCancel}
|
onEscape={dictationCancel}
|
||||||
|
compactionRunning={compactionRunning}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
composer
|
composer
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import type { CompactionDetails } from "@/src/core";
|
||||||
|
|
||||||
|
/** What one divider carries: the marker's full model-facing content (the
|
||||||
|
* [SESSION HISTORY COMPACTED] header + the summary) and its details —
|
||||||
|
* both dividers open the same dialog from the same payload. */
|
||||||
|
export interface CompactionDividerData {
|
||||||
|
content: string;
|
||||||
|
details: CompactionDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The moment divider (owner round 14): minimal centered small text where
|
||||||
|
* the user currently is — the retrying-message register (SystemLine's
|
||||||
|
* exact classes), NO horizontal rule. The transcript's quiet note that
|
||||||
|
* history was just summarized; the cut divider below carries the seam. */
|
||||||
|
export function CompactionMomentDivider({ data, onOpen }: { data: CompactionDividerData; onOpen: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center justify-center py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compaction-divider"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="rounded px-1.5 text-xs text-muted-foreground/70 transition-colors hover:text-muted-foreground"
|
||||||
|
>
|
||||||
|
Context compacted
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cut divider (owner round 14): the LONGER divider at the actual
|
||||||
|
* compaction cut — the seam where verbatim history ends and the kept
|
||||||
|
* recent tail begins. Phase-divider's geometry (dotted rules + centered
|
||||||
|
* chip) so it reads as a transcript boundary, not a message. */
|
||||||
|
export function CompactionCutDivider({ data, onOpen }: { data: CompactionDividerData; onOpen: () => void }) {
|
||||||
|
const kTok = data.details.tokensBefore > 0 ? ` · ${Math.round(data.details.tokensBefore / 1000)}k tok` : "";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compaction-cut-divider"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="my-4 flex w-full items-center gap-3 text-left"
|
||||||
|
>
|
||||||
|
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
|
||||||
|
<span className="rounded-full border border-border/80 px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
History compacted{kTok}
|
||||||
|
</span>
|
||||||
|
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dialog's pure body — exported for SSR pins (the delete-dialog /
|
||||||
|
* node-dialog body pattern; the wrapper owns the portal + Esc effect). */
|
||||||
|
export function CompactionSummaryBody({ summary, onClose }: { summary: CompactionDividerData; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="compaction-summary-dialog"
|
||||||
|
className="flex max-h-[75vh] w-full max-w-2xl flex-col rounded-2xl border border-border bg-card shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3">
|
||||||
|
<span className="font-title text-xl tracking-tight">Compacted context</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compaction-summary-close"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-foreground/90">
|
||||||
|
{summary.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full-summary view both dividers open (owner round 14): the
|
||||||
|
* marker's content verbatim — the compaction header + the summary the
|
||||||
|
* model itself reads — in a scrollable document block. Esc and the
|
||||||
|
* Close row both dismiss; the backdrop click does not (the summary is
|
||||||
|
* reference material, not a decision — an accidental dismissal costs a
|
||||||
|
* re-click, and a stray click-through mid-read costs more). */
|
||||||
|
export function CompactionSummaryDialog({
|
||||||
|
summary,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
summary: CompactionDividerData | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!summary) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [summary, onClose]);
|
||||||
|
|
||||||
|
if (!summary) return null;
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-6">
|
||||||
|
<CompactionSummaryBody summary={summary} onClose={onClose} />
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,9 +41,14 @@ export interface PracticeDockProps {
|
|||||||
onSubmit: (text: string) => void;
|
onSubmit: (text: string) => void;
|
||||||
/** Escape in the thinking field stops dictation (threaded from the page). */
|
/** Escape in the thinking field stops dictation (threaded from the page). */
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
|
/** Compaction is running for this chat (owner round 14): the thinking
|
||||||
|
* POST would block on the server's compaction gate — disable submit
|
||||||
|
* and say so in the hint row. The server registry is the real gate;
|
||||||
|
* this is the affordance. */
|
||||||
|
compactionRunning?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmit, onEscape }: PracticeDockProps) {
|
export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmit, onEscape, compactionRunning = false }: PracticeDockProps) {
|
||||||
const [thinking, setThinking] = useState("");
|
const [thinking, setThinking] = useState("");
|
||||||
// Ref MIRRORS the field ("nothing gets lost", the question-dock
|
// Ref MIRRORS the field ("nothing gets lost", the question-dock
|
||||||
// discipline): a submit resuming after `await finishDictation()` reads
|
// discipline): a submit resuming after `await finishDictation()` reads
|
||||||
@@ -63,6 +68,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
|
if (compactionRunning) return;
|
||||||
const text = thinkingRef.current.trim();
|
const text = thinkingRef.current.trim();
|
||||||
if (text.length === 0) return;
|
if (text.length === 0) return;
|
||||||
thinkingRef.current = "";
|
thinkingRef.current = "";
|
||||||
@@ -149,12 +155,12 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
field 10 − p-1 (4) = button 6. */}
|
field 10 − p-1 (4) = button 6. */}
|
||||||
<div className="flex items-center justify-between gap-2 p-1">
|
<div className="flex items-center justify-between gap-2 p-1">
|
||||||
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
|
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
|
||||||
↵ submits · ⇧↵ newline
|
{compactionRunning ? "compacting session…" : "↵ submits · ⇧↵ newline"}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="practice-send"
|
data-testid="practice-send"
|
||||||
disabled={thinking.trim().length === 0}
|
disabled={thinking.trim().length === 0 || compactionRunning}
|
||||||
onClick={() => submit()}
|
onClick={() => submit()}
|
||||||
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
|
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ export interface QuestionDockProps {
|
|||||||
* during the reading; the dock reclaims on reveal. Default true (probe
|
* during the reading; the dock reclaims on reveal. Default true (probe
|
||||||
* questions, predictions, old callers). */
|
* questions, predictions, old callers). */
|
||||||
dictationClaim?: boolean;
|
dictationClaim?: boolean;
|
||||||
|
/** Compaction is running for this chat (owner round 14): the answer
|
||||||
|
* POST would block on the server's compaction gate — disable submit
|
||||||
|
* and say so in the hint row. The server registry is the real gate;
|
||||||
|
* this is the affordance. */
|
||||||
|
compactionRunning?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionDock({
|
export function QuestionDock({
|
||||||
@@ -99,6 +104,7 @@ export function QuestionDock({
|
|||||||
onEscape,
|
onEscape,
|
||||||
phase = "probe",
|
phase = "probe",
|
||||||
dictationClaim = true,
|
dictationClaim = true,
|
||||||
|
compactionRunning = false,
|
||||||
}: QuestionDockProps) {
|
}: QuestionDockProps) {
|
||||||
// Parse, don't cast (src/core/tool-parts.ts): the model authored this
|
// Parse, don't cast (src/core/tool-parts.ts): the model authored this
|
||||||
// input, and only the schema the tool declared vouches for its shape.
|
// input, and only the schema the tool declared vouches for its shape.
|
||||||
@@ -140,6 +146,7 @@ export function QuestionDock({
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onEscape={onEscape}
|
onEscape={onEscape}
|
||||||
dictationClaim={dictationClaim}
|
dictationClaim={dictationClaim}
|
||||||
|
compactionRunning={compactionRunning}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -228,6 +235,7 @@ function QuestionDockForm({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onEscape,
|
onEscape,
|
||||||
dictationClaim = true,
|
dictationClaim = true,
|
||||||
|
compactionRunning = false,
|
||||||
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
|
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
|
||||||
const kind = toolQuestionKind(toolName);
|
const kind = toolQuestionKind(toolName);
|
||||||
|
|
||||||
@@ -340,7 +348,7 @@ function QuestionDockForm({
|
|||||||
: sel.dontKnow || (sel.otherSelected && other.trim().length > 0) || sel.selected.size > 0;
|
: sel.dontKnow || (sel.otherSelected && other.trim().length > 0) || sel.selected.size > 0;
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit || compactionRunning) return;
|
||||||
/* FINISH DICTATION FIRST (owner, 2026-10-01): Enter may arrive while
|
/* FINISH DICTATION FIRST (owner, 2026-10-01): Enter may arrive while
|
||||||
* a run is live with reasoning still in Deepgram's hands. Stop the
|
* a run is live with reasoning still in Deepgram's hands. Stop the
|
||||||
* run, let the last finals land in the target field, and only then
|
* run, let the last finals land in the target field, and only then
|
||||||
@@ -846,11 +854,11 @@ function QuestionDockForm({
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between gap-2 p-1">
|
<div className="flex items-center justify-between gap-2 p-1">
|
||||||
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
|
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
|
||||||
↵ submits · ⇧↵ newline
|
{compactionRunning ? "compacting session…" : "↵ submits · ⇧↵ newline"}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit || compactionRunning}
|
||||||
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
|
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Submit answer
|
Submit answer
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved";
|
|||||||
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
|
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
|
||||||
// dispatches it after save AND delete — deletions used to be invisible to
|
// dispatches it after save AND delete — deletions used to be invisible to
|
||||||
// the zero-keys pill and the setup dialog's auto-advance.
|
// the zero-keys pill and the setup dialog's auto-advance.
|
||||||
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph";
|
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction";
|
||||||
|
|
||||||
/** Writers call this after the PUT resolves ok — never before (a failed
|
/** Writers call this after the PUT resolves ok — never before (a failed
|
||||||
* write notifies nothing; the record is unchanged). */
|
* write notifies nothing; the record is unchanged). */
|
||||||
|
|||||||
@@ -57,8 +57,11 @@ import { useSettingsSaved } from "@/components/settings-signal";
|
|||||||
import { defaultOrderLevel, populateLevel } from "@/components/thinking-default";
|
import { defaultOrderLevel, populateLevel } from "@/components/thinking-default";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
|
COMPACTION_THRESHOLD_FLOOR,
|
||||||
MODEL_SLOTS,
|
MODEL_SLOTS,
|
||||||
|
resolveSettings,
|
||||||
type AgentSettings,
|
type AgentSettings,
|
||||||
|
type CompactionSettings,
|
||||||
type GraphSettings,
|
type GraphSettings,
|
||||||
type ModelCatalogEntry,
|
type ModelCatalogEntry,
|
||||||
type ModelSlotName,
|
type ModelSlotName,
|
||||||
@@ -67,7 +70,7 @@ import {
|
|||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import { prettyModel } from "@/components/usage-format";
|
import { prettyModel } from "@/components/usage-format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SettingsGroup, SettingsRow, Toggle } from "./controls.tsx";
|
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
|
||||||
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
||||||
|
|
||||||
/** One slot's display identity — the tier's roster as the row hint rides
|
/** One slot's display identity — the tier's roster as the row hint rides
|
||||||
@@ -289,8 +292,10 @@ export function AgentSection({
|
|||||||
agent,
|
agent,
|
||||||
models,
|
models,
|
||||||
graph,
|
graph,
|
||||||
|
compaction,
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
onModelsChange,
|
onModelsChange,
|
||||||
|
onCompactionChange,
|
||||||
}: {
|
}: {
|
||||||
agent: AgentSettings | undefined;
|
agent: AgentSettings | undefined;
|
||||||
/** The `models` section (slot pins — engine-level, PUT /api/settings/models). */
|
/** The `models` section (slot pins — engine-level, PUT /api/settings/models). */
|
||||||
@@ -299,10 +304,15 @@ export function AgentSection({
|
|||||||
* master switch (graph.enabled off dims + disables the row; the
|
* master switch (graph.enabled off dims + disables the row; the
|
||||||
* effective gate is server-side, per-turn fresh). */
|
* effective gate is server-side, per-turn fresh). */
|
||||||
graph: GraphSettings | undefined;
|
graph: GraphSettings | undefined;
|
||||||
|
/** The `compaction` section — its own PUT (the threshold is engine
|
||||||
|
* behavior, not an agent-section key; owner round 14, Agent tab). */
|
||||||
|
compaction: CompactionSettings | undefined;
|
||||||
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
|
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
|
||||||
onSectionChange: (next: AgentSettings) => void;
|
onSectionChange: (next: AgentSettings) => void;
|
||||||
/** Writes the composed models section (slots — replace-PUT discipline). */
|
/** Writes the composed models section (slots — replace-PUT discipline). */
|
||||||
onModelsChange: (next: ModelsSettings) => void;
|
onModelsChange: (next: ModelsSettings) => void;
|
||||||
|
/** Writes the whole compaction section (replace-PUT discipline). */
|
||||||
|
onCompactionChange: (next: CompactionSettings) => void;
|
||||||
}) {
|
}) {
|
||||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
||||||
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
|
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
|
||||||
@@ -599,6 +609,13 @@ export function AgentSection({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Resolved through the SAME core resolver the server's effective
|
||||||
|
// settings use (the GraphSection precedent): the hint and placeholder
|
||||||
|
// report exactly the number the trigger waits for.
|
||||||
|
const resolvedCompaction = resolveSettings(
|
||||||
|
compaction === undefined ? null : { version: 1, compaction },
|
||||||
|
).compaction;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsGroup label="Models" testid="group-models">
|
<SettingsGroup label="Models" testid="group-models">
|
||||||
@@ -844,6 +861,39 @@ export function AgentSection({
|
|||||||
})()}
|
})()}
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup label="Context" testid="group-context">
|
||||||
|
<SettingsRow
|
||||||
|
label="Compaction threshold"
|
||||||
|
hint={`${resolvedCompaction.thresholdTokens} tok`}
|
||||||
|
testid="compaction-row-threshold"
|
||||||
|
>
|
||||||
|
<CommitInput
|
||||||
|
type="number"
|
||||||
|
value={String(compaction?.thresholdTokens ?? "")}
|
||||||
|
placeholder={String(resolvedCompaction.thresholdTokens)}
|
||||||
|
ariaLabel="compaction threshold"
|
||||||
|
testid="input-compaction-threshold"
|
||||||
|
onCommit={(v) => {
|
||||||
|
// Empty = factory default (the budget row's discipline):
|
||||||
|
// absence is the unset for numbers.
|
||||||
|
if (v === "") {
|
||||||
|
onCompactionChange({ ...compaction, thresholdTokens: undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return; // bad input: no write
|
||||||
|
// Mirror the server floor (anti-churn: each compaction
|
||||||
|
// keeps a ~20k-token tail); no ceiling — the effective
|
||||||
|
// trigger is min(threshold, contextWindow − 16k) anyway.
|
||||||
|
onCompactionChange({
|
||||||
|
...compaction,
|
||||||
|
thresholdTokens: Math.max(COMPACTION_THRESHOLD_FLOOR, Math.round(n)),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup label="System prompts" testid="group-prompts">
|
<SettingsGroup label="System prompts" testid="group-prompts">
|
||||||
{prompts === null ? (
|
{prompts === null ? (
|
||||||
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
|
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
CURRENT_SETTINGS_VERSION,
|
CURRENT_SETTINGS_VERSION,
|
||||||
type AgentSettings,
|
type AgentSettings,
|
||||||
type AppearanceSettings,
|
type AppearanceSettings,
|
||||||
|
type CompactionSettings,
|
||||||
type GraphSettings,
|
type GraphSettings,
|
||||||
type UserSettings,
|
type UserSettings,
|
||||||
} from "@/src/core";
|
} from "@/src/core";
|
||||||
@@ -216,7 +217,7 @@ export function SettingsDialogBody({
|
|||||||
* schema mistake caught by the validation surface). */
|
* schema mistake caught by the validation surface). */
|
||||||
const write = useCallback(
|
const write = useCallback(
|
||||||
async (
|
async (
|
||||||
section: "general" | "agent" | "models" | "appearance" | "graph",
|
section: "general" | "agent" | "models" | "appearance" | "graph" | "compaction",
|
||||||
body: object,
|
body: object,
|
||||||
) => {
|
) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -379,12 +380,16 @@ export function SettingsDialogBody({
|
|||||||
agent={record.agent}
|
agent={record.agent}
|
||||||
models={record.models}
|
models={record.models}
|
||||||
graph={record.graph}
|
graph={record.graph}
|
||||||
|
compaction={record.compaction}
|
||||||
onModelsChange={(next: ModelsSettings) =>
|
onModelsChange={(next: ModelsSettings) =>
|
||||||
void write("models", next)
|
void write("models", next)
|
||||||
}
|
}
|
||||||
onSectionChange={(next: AgentSettings) =>
|
onSectionChange={(next: AgentSettings) =>
|
||||||
void write("agent", next)
|
void write("agent", next)
|
||||||
}
|
}
|
||||||
|
onCompactionChange={(next: CompactionSettings) =>
|
||||||
|
void write("compaction", next)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -575,8 +575,9 @@ function sanitizeGraph(raw: Record<string, unknown>): GraphSettings {
|
|||||||
|
|
||||||
/** clamp-never-reject (the sanitizer's discipline): a finite positive
|
/** clamp-never-reject (the sanitizer's discipline): a finite positive
|
||||||
* number floors to COMPACTION_THRESHOLD_FLOOR, anything else drops (the
|
* number floors to COMPACTION_THRESHOLD_FLOOR, anything else drops (the
|
||||||
* factory default resolves at read time). */
|
* factory default resolves at read time). Exported for the PUT parser's
|
||||||
const COMPACTION_THRESHOLD_FLOOR = 32_000;
|
* reject-with-rationale (the graph chunk-budget precedent). */
|
||||||
|
export const COMPACTION_THRESHOLD_FLOOR = 32_000;
|
||||||
|
|
||||||
function sanitizeCompaction(raw: Record<string, unknown>): CompactionSettings {
|
function sanitizeCompaction(raw: Record<string, unknown>): CompactionSettings {
|
||||||
const out: CompactionSettings = {};
|
const out: CompactionSettings = {};
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
||||||
import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL } from "./native-messages.ts";
|
import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL } from "./native-messages.ts";
|
||||||
|
import { activeCompactionOf, type CompactionDetails } from "./compaction.ts";
|
||||||
import { QUESTION_TOOLS } from "./pending.ts";
|
import { QUESTION_TOOLS } from "./pending.ts";
|
||||||
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
|
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
|
||||||
import type { PlanBlockData } from "./contracts/chat.ts";
|
import type { PlanBlockData } from "./contracts/chat.ts";
|
||||||
@@ -145,9 +146,30 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The active compaction (owner round 14): the fold emits the CUT
|
||||||
|
// DIVIDER — the longer, at-the-seam marker — immediately BEFORE the
|
||||||
|
// first kept message, so the transcript shows exactly where history
|
||||||
|
// was summarized away. Everything before it stays visible to the
|
||||||
|
// user (full history is never hidden); only the MODEL's context jumps.
|
||||||
|
const compaction = activeCompactionOf(messages);
|
||||||
|
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
const role = (m as { role?: string }).role;
|
const role = (m as { role?: string }).role;
|
||||||
|
|
||||||
|
if (compaction && m.id === compaction.details.firstKeptMessageId) {
|
||||||
|
out.push({
|
||||||
|
id: `cut_${compaction.id}`,
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "data-compaction-cut",
|
||||||
|
id: `cc_${compaction.id}`,
|
||||||
|
data: { content: compaction.content, details: compaction.details },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (role === "user") {
|
if (role === "user") {
|
||||||
const content = m.content as string | Array<{ type: string; text?: string }>;
|
const content = m.content as string | Array<{ type: string; text?: string }>;
|
||||||
const texts =
|
const texts =
|
||||||
@@ -383,6 +405,25 @@ function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void {
|
|||||||
// UI-only (the ask-style dismissal): the synthesized conclude
|
// UI-only (the ask-style dismissal): the synthesized conclude
|
||||||
// pair — which rides the NEXT input — carries the record's face.
|
// pair — which rides the NEXT input — carries the record's face.
|
||||||
return;
|
return;
|
||||||
|
case "compaction": {
|
||||||
|
// THE MOMENT DIVIDER (owner round 14): minimal centered small
|
||||||
|
// text at the compaction moment — where the user currently is
|
||||||
|
// (the marker appends at the record's tail). Clicking opens the
|
||||||
|
// full summary. The cut divider is emitted by the fold loop at
|
||||||
|
// the kept boundary (see projectView).
|
||||||
|
out.push({
|
||||||
|
id: marker.id,
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "data-compaction",
|
||||||
|
id: `cm_${marker.id}`,
|
||||||
|
data: { content: marker.content, details: marker.details as CompactionDetails },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "plan-block": {
|
case "plan-block": {
|
||||||
// No message of its own: splice into the preceding assistant
|
// No message of its own: splice into the preceding assistant
|
||||||
// group, IN FLOW after the last successful plan_init call.
|
// group, IN FLOW after the last successful plan_init call.
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ import type {
|
|||||||
AgentSettings,
|
AgentSettings,
|
||||||
AppearanceSettings,
|
AppearanceSettings,
|
||||||
BackgroundModelRole,
|
BackgroundModelRole,
|
||||||
|
CompactionSettings,
|
||||||
GeneralSettings,
|
GeneralSettings,
|
||||||
GraphSettings,
|
GraphSettings,
|
||||||
ModelsSettings,
|
ModelsSettings,
|
||||||
RoleModelOverride,
|
RoleModelOverride,
|
||||||
} from "../core/settings.ts";
|
} from "../core/settings.ts";
|
||||||
import { BACKGROUND_MODEL_ROLES, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
|
import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
|
||||||
import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
|
import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
|
||||||
|
|
||||||
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
|
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
|
||||||
@@ -342,6 +343,34 @@ export function parseModelsSettingsBody(body: unknown): SettingsBodyResult<Model
|
|||||||
return { ok: true, settings };
|
return { ok: true, settings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** compaction (owner round 14): the context-compaction threshold — the
|
||||||
|
* projected-token budget over which a session summarizes its own history
|
||||||
|
* into the compaction marker. Strict write side: a present
|
||||||
|
* thresholdTokens must be a number at or above the floor (the floor is
|
||||||
|
* anti-churn: each compaction keeps a ~20k-token recent tail, so a
|
||||||
|
* smaller threshold would re-summarize the tail it just wrote). Absent =
|
||||||
|
* factory default 150k at resolution. Replace-whole, like every
|
||||||
|
* section. */
|
||||||
|
export function parseCompactionSettingsBody(body: unknown): SettingsBodyResult<CompactionSettings> {
|
||||||
|
const raw = asRecord(body);
|
||||||
|
if (!raw) return { ok: false, error: "Invalid JSON body" };
|
||||||
|
const settings: CompactionSettings = {};
|
||||||
|
if (raw.thresholdTokens !== undefined) {
|
||||||
|
if (typeof raw.thresholdTokens !== "number" || !Number.isFinite(raw.thresholdTokens)) {
|
||||||
|
return { ok: false, error: "thresholdTokens must be a number" };
|
||||||
|
}
|
||||||
|
const floored = Math.floor(raw.thresholdTokens);
|
||||||
|
if (floored < COMPACTION_THRESHOLD_FLOOR) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `thresholdTokens must be at least ${COMPACTION_THRESHOLD_FLOOR} (compaction keeps a ~20k-token recent tail — smaller budgets would churn it)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
settings.thresholdTokens = floored;
|
||||||
|
}
|
||||||
|
return { ok: true, settings };
|
||||||
|
}
|
||||||
|
|
||||||
/** The section→parser map for PUT /api/settings/[section] (the route also
|
/** The section→parser map for PUT /api/settings/[section] (the route also
|
||||||
* carries the special prompts sub-resource). */
|
* carries the special prompts sub-resource). */
|
||||||
export const SETTINGS_SECTIONS = {
|
export const SETTINGS_SECTIONS = {
|
||||||
@@ -350,6 +379,7 @@ export const SETTINGS_SECTIONS = {
|
|||||||
agent: parseAgentSettingsBody,
|
agent: parseAgentSettingsBody,
|
||||||
appearance: parseAppearanceSettingsBody,
|
appearance: parseAppearanceSettingsBody,
|
||||||
graph: parseGraphSettingsBody,
|
graph: parseGraphSettingsBody,
|
||||||
|
compaction: parseCompactionSettingsBody,
|
||||||
models: parseModelsSettingsBody,
|
models: parseModelsSettingsBody,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type LhMarkerMessage,
|
type LhMarkerMessage,
|
||||||
} from "../src/core/index.ts";
|
} from "../src/core/index.ts";
|
||||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
|
import { projectView } from "../src/core/view.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* COMPACTION (owner round 14, 2026-10-05) — the pure core: the cut walk,
|
* COMPACTION (owner round 14, 2026-10-05) — the pure core: the cut walk,
|
||||||
@@ -242,3 +243,43 @@ describe("serializeForSummary + buildCompactionPrompt", () => {
|
|||||||
assert.ok(text.includes("Alpha → Beta"));
|
assert.ok(text.includes("Alpha → Beta"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// THE VIEW FOLD (owner round 14): two dividers from one marker —
|
||||||
|
// the minimal moment divider where the user is, the longer cut divider
|
||||||
|
// at the kept boundary. Both carry the full summary payload.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("projectView compaction dividers", () => {
|
||||||
|
const record = (): LhMessage[] => [
|
||||||
|
{ id: "old1", role: "user", content: "earliest", timestamp: 1 },
|
||||||
|
{ id: "kept", role: "user", content: "recent", timestamp: 2 },
|
||||||
|
newCompactionMarker("SUMMARY TEXT", {
|
||||||
|
firstKeptMessageId: "kept",
|
||||||
|
tokensBefore: 12345,
|
||||||
|
at: new Date(0).toISOString(),
|
||||||
|
}) as unknown as LhMessage,
|
||||||
|
];
|
||||||
|
|
||||||
|
it("emits the cut divider BEFORE the first kept message", () => {
|
||||||
|
const view = projectView(record());
|
||||||
|
const idx = view.findIndex((m) => m.parts.some((p) => p.type === "data-compaction-cut"));
|
||||||
|
const keptIdx = view.findIndex((m) => m.parts.some((p) => p.type === "text" && p.text === "recent"));
|
||||||
|
assert.ok(idx !== -1, "the cut divider exists");
|
||||||
|
assert.ok(keptIdx !== -1);
|
||||||
|
assert.equal(idx, keptIdx - 1, "immediately before the kept message");
|
||||||
|
const part = view[idx]!.parts.find((p) => p.type === "data-compaction-cut")!;
|
||||||
|
assert.equal((part.data as { content: string }).content.includes("SUMMARY TEXT"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the moment divider at the marker (the transcript tail)", () => {
|
||||||
|
const view = projectView(record());
|
||||||
|
const moment = view.find((m) => m.parts.some((p) => p.type === "data-compaction"));
|
||||||
|
assert.ok(moment, "the moment divider exists");
|
||||||
|
const part = moment!.parts.find((p) => p.type === "data-compaction")!;
|
||||||
|
assert.equal((part.data as { details: { tokensBefore: number } }).details.tokensBefore, 12345);
|
||||||
|
// and it renders AFTER the kept message (the compaction moment is
|
||||||
|
// the tail — where the user is when it happens)
|
||||||
|
const keptIdx = view.findIndex((m) => m.parts.some((p) => p.type === "text" && p.text === "recent"));
|
||||||
|
assert.ok(view.indexOf(moment!) > keptIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* THE COMPACTION DIVIDERS — SSR render pins (owner round 14). Two ruled
|
||||||
|
* surfaces from one marker: the MOMENT divider (minimal centered small
|
||||||
|
* text, the retrying-message register — no rule lines) where the user
|
||||||
|
* is when compaction lands, and the CUT divider (the longer one —
|
||||||
|
* phase-divider geometry, dotted rules + a chip carrying the summarized
|
||||||
|
* token count) at the kept boundary. Both open the same summary dialog
|
||||||
|
* (the marker's full model-facing content, verbatim, scrollable).
|
||||||
|
*
|
||||||
|
* The dialog portals to document.body at z-50; SSR renders null when
|
||||||
|
* closed — the pins hold the open state through a jsdom-free server
|
||||||
|
* render (createPortal is a no-op passthrough on the server).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
CompactionCutDivider,
|
||||||
|
CompactionMomentDivider,
|
||||||
|
CompactionSummaryBody,
|
||||||
|
CompactionSummaryDialog,
|
||||||
|
type CompactionDividerData,
|
||||||
|
} from "../components/compaction-divider.tsx";
|
||||||
|
|
||||||
|
const data: CompactionDividerData = {
|
||||||
|
content: "[SESSION HISTORY COMPACTED]\n\nTHE LEARNER'S UNDERSTANDING: …",
|
||||||
|
details: {
|
||||||
|
firstKeptMessageId: "kept_1",
|
||||||
|
tokensBefore: 152000,
|
||||||
|
at: "2026-10-05T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CompactionMomentDivider", () => {
|
||||||
|
it("renders the minimal centered note — no rule lines, retrying-message register", () => {
|
||||||
|
const html = renderToStaticMarkup(<CompactionMomentDivider data={data} onOpen={() => {}} />);
|
||||||
|
assert.ok(html.includes('data-testid="compaction-divider"'));
|
||||||
|
assert.ok(html.includes("Context compacted"));
|
||||||
|
// NO horizontal rule (the owner ruling: minimal, unlike the cut)
|
||||||
|
assert.ok(!html.includes("border-t-2"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CompactionCutDivider", () => {
|
||||||
|
it("renders the longer seam — dotted rules + chip with the token count", () => {
|
||||||
|
const html = renderToStaticMarkup(<CompactionCutDivider data={data} onOpen={() => {}} />);
|
||||||
|
assert.ok(html.includes('data-testid="compaction-cut-divider"'));
|
||||||
|
assert.ok(html.includes("border-t-2 border-dotted"));
|
||||||
|
assert.ok(html.includes("History compacted"));
|
||||||
|
// 152000 → 152k tok
|
||||||
|
assert.ok(html.includes("152k tok"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CompactionSummaryDialog", () => {
|
||||||
|
it("renders the marker content verbatim, scrollable (the pure body — portals need document)", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CompactionSummaryBody summary={data} onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
assert.ok(html.includes('data-testid="compaction-summary-dialog"'));
|
||||||
|
assert.ok(html.includes("[SESSION HISTORY COMPACTED]"));
|
||||||
|
assert.ok(html.includes("overflow-y-auto"));
|
||||||
|
assert.ok(html.includes('data-testid="compaction-summary-close"'));
|
||||||
|
});
|
||||||
|
it("renders nothing when closed", () => {
|
||||||
|
assert.equal(renderToStaticMarkup(<CompactionSummaryDialog summary={null} onClose={() => {}} />), "");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
parseModelsSettingsBody,
|
parseModelsSettingsBody,
|
||||||
parseAppearanceSettingsBody,
|
parseAppearanceSettingsBody,
|
||||||
parseGeneralSettingsBody,
|
parseGeneralSettingsBody,
|
||||||
|
parseCompactionSettingsBody,
|
||||||
parseGraphSettingsBody,
|
parseGraphSettingsBody,
|
||||||
SETTINGS_SECTIONS,
|
SETTINGS_SECTIONS,
|
||||||
} from "../src/server/settings-body.ts";
|
} from "../src/server/settings-body.ts";
|
||||||
@@ -194,9 +195,41 @@ describe("parseGraphSettingsBody", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseCompactionSettingsBody (owner round 14)", () => {
|
||||||
|
it("parses a threshold; absent = the empty section (factory default at resolution)", () => {
|
||||||
|
const r = parseCompactionSettingsBody({ thresholdTokens: 120000 });
|
||||||
|
assert.ok(r.ok);
|
||||||
|
assert.deepEqual(r.settings, { thresholdTokens: 120000 });
|
||||||
|
const none = parseCompactionSettingsBody({});
|
||||||
|
assert.ok(none.ok);
|
||||||
|
assert.deepEqual(none.settings, {});
|
||||||
|
});
|
||||||
|
it("floors to integers", () => {
|
||||||
|
const r = parseCompactionSettingsBody({ thresholdTokens: 120000.9 });
|
||||||
|
assert.ok(r.ok);
|
||||||
|
assert.equal(r.settings.thresholdTokens, 120000);
|
||||||
|
});
|
||||||
|
it("rejects below the 32k floor with the anti-churn rationale", () => {
|
||||||
|
const r = parseCompactionSettingsBody({ thresholdTokens: 20_000 });
|
||||||
|
assert.ok(!r.ok);
|
||||||
|
assert.match(r.error ?? "", /at least 32000/);
|
||||||
|
assert.match(r.error ?? "", /churn/);
|
||||||
|
});
|
||||||
|
it("rejects non-numbers", () => {
|
||||||
|
const r = parseCompactionSettingsBody({ thresholdTokens: "big" });
|
||||||
|
assert.ok(!r.ok);
|
||||||
|
assert.match(r.error ?? "", /must be a number/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("SETTINGS_SECTIONS (the route's section map)", () => {
|
describe("SETTINGS_SECTIONS (the route's section map)", () => {
|
||||||
it("maps exactly the six record sections", () => {
|
it("maps exactly the seven record sections", () => {
|
||||||
assert.deepEqual(Object.keys(SETTINGS_SECTIONS), ["general", "chat", "agent", "appearance", "graph", "models"]);
|
// compaction joined (owner round 14): its own section PUT — the
|
||||||
|
// threshold is engine behavior, not an agent-section key.
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(SETTINGS_SECTIONS),
|
||||||
|
["general", "chat", "agent", "appearance", "graph", "compaction", "models"],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user