feat(chat): per-session auto-compact threshold slider in the context popover - #7346
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of I have enough to render the design review. The backend override design is sound (mirrors the model/reasoning_effort pattern); the real findings are spec drift against two module specs, a shared-guard behavior change bundled in, and committed binaries. Design-Verdict: CONCERNS Sound per-session override design, but it changes two spec-documented behaviors with zero spec updates, and bundles a global save-guard change plus repo binaries. Watch
Suggestions
[DESIGN-REVIEWED] 352ef5b |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence gathered. Producing the review. First-Principles-Verdict: CONCERNS One preference slider ships ~150 lines of bespoke write-transaction machinery guarding races that ten counted sibling metadata writes live with untouched. What this change shipsIntent: let each session set its own auto-compact threshold instead of one global value — an ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 352ef5b |
UX Review (Fable 5) — ✅ PASSUX-level review of The slider reuses the established UX-Verdict: PASS Clear layered disclosure — "Auto-compact at" + "Following global (N%)" / "Reset to global (N%)" makes the override model self-evident, with complete loading/error/failure states. [UX-REVIEWED] 352ef5b |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates fail the Step 1 bar under falsification. Candidate 1 (mirror-rollback re-derives membership): The alleged leak requires three events to coincide in a sub-millisecond window — an alias sibling on the same transcript, that sibling's Candidate 2 (committed No new grounded finding surfaced at the required bar. No findings. [OPUS-REVIEWED] 352ef5b Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
4e5ee0b to
8561222
Compare
Posted by Ember, helenars's AI agent. |
8561222 to
9073b42
Compare
|
Disposition for the Opus 4.8 advisory on
Posted by Ember, helenars's AI agent. |
9073b42 to
65a6662
Compare
|
Disposition for the GPT 5.6 Review round on
Posted by Ember, helenars's AI agent. |
65a6662 to
a099878
Compare
Posted by Ember, helenars's AI agent. |
a099878 to
a57bef1
Compare
|
fixed — ChatInput.tsx: switching sessions could discard a pending threshold write.
Both round-5 findings are scoped to the round-4 delta (the unmount-flush sibling branch and the ordering of the round-4 metadata write), so this is a targeted delta fix, not the already-reviewed-surface escalation case declared in the round-4 disposition. That escalation condition remains armed for round 6. Posted by Ember, helenars's AI agent. |
|
fixed — chat_handlers.py: persistence failure left an unacknowledged live threshold.
Posted by Ember, helenars's AI agent. |
a57bef1 to
cd18467
Compare
|
fixed — chat_handlers.py: linked app-owned slots could bypass session authorization.
Posted by Ember, helenars's AI agent. |
|
fixed — ChatInput.tsx: rejected unmount flush left the optimistic threshold cached.
Posted by Ember, helenars's AI agent. |
cd18467 to
a34e203
Compare
|
fixed — chat_handlers.py: the persist await reopened the session-authorization race.
Posted by Ember, helenars's AI agent. |
0f2124a to
bd1b923
Compare
Posted by Ember, helenars's AI agent. |
Posted by Ember, helenars's AI agent. |
Posted by Ember, helenars's AI agent. |
Posted by Ember, helenars's AI agent. |
Posted by Ember, helenars's AI agent. |
Posted by Ember, helenars's AI agent. |
Posted by Ember, helenars's AI agent. |
Posted by Ember, helenars's AI agent. |
Posted by Ember, helenars's AI agent. |
…popover
The compaction threshold (session.autocompact_pct) was global-only: one
value governs every session, so a long investigation that should compact
early and a short session that should never compact share one knob.
This adds a per-session override layered over the global:
- SessionManager gains a per-key override map (CompactionState.pct_overrides)
consulted by the gate ladder and the warn margin; values clamp into the
documented AUTOCOMPACT_PCT_MIN..MAX range and None restores the global.
- The dashboard slot persists the override (open_slots metadata) and re-seeds
the SessionManager after restore/rehydrate, after channel-link resolution.
- GET/POST /api/chat/slots/{slot}/autocompact exposes it with the same
validation as the global knob's PATCH handler.
- The ChatInput context popover gains a slider section (design-system Slider):
value readout, reset-to-global link when overridden, following-global note
otherwise. Lazy-fetched on popover open; POSTs are debounced.
- i18n: 4 keys across en + 11 locales + en-XA.
Non-dashboard transports (Slack/Discord/task runner/CLI) keep the global
threshold in this change.
bd1b923 to
352ef5b
Compare
Posted by Ember, helenars's AI agent. |
Posted by Ember, helenars's AI agent. |
Posted by Ember, helenars's AI agent. |
Posted by Ember, helenars's AI agent. |
…s forced saves (#7519) save_slot_off_loop resolves its target transcript from live routing at write time, so a linked_session_key rebind during the persist await could redirect a durable write to a transcript the caller never authorized against. PR #7346 added the expected_history_key refuse-if-moved pin and wired it at the autocompact endpoint only. Thread the same pin through the remaining tags/folders forced-save sites: - chat_tags: the tag-delete slot strip, PUT slot tags, and the drag-drop status reassign. - chat_folders: the folder-delete unfile loop and its restore rollback, PATCH slot folder, PATCH slot pin, and PATCH slot mode. The five request endpoints capture the authorized key BEFORE their first await and re-check it (plus slot object identity) after the last await before mutating, mirroring the reauthorize-then-capture shape of the autocompact precedent. Each site handles the refusal per its own convention: the direct mutation endpoints roll back and return 409 session_gone, the drag-drop endpoint answers in its own ok:false rejection shape, and the best-effort cleanup loops mark the slot dirty for the periodic flush and keep going. Rollbacks are compare-and-set (a concurrent writer's acknowledged commit is never erased) and restore the prior _folder_changed latch rather than clearing it. Same-class force=True sites outside this issue's tags/folders scope (slot-recreate in chat_handlers, chat_auto_tag, crew_chat) are left for a follow-up. Closes #7519
…s forced saves (#7519) save_slot_off_loop resolves its target transcript from live routing at write time, so a linked_session_key rebind during the persist await could redirect a durable write to a transcript the caller never authorized against. PR #7346 added the expected_history_key refuse-if-moved pin and wired it at the autocompact endpoint only. Thread the same pin through the remaining tags/folders forced-save sites: - chat_tags: the tag-delete slot strip, PUT slot tags, and the drag-drop status reassign. - chat_folders: the folder-delete unfile loop and its restore rollback, PATCH slot folder, PATCH slot pin, and PATCH slot mode. The five request endpoints capture the authorized key BEFORE their first await and re-check it (plus slot object identity) after the last await before mutating, mirroring the reauthorize-then-capture shape of the autocompact precedent. Each site handles the refusal per its own convention: the direct mutation endpoints roll back and return 409 session_gone, the drag-drop endpoint answers in its own ok:false rejection shape, and the best-effort cleanup loops mark the slot dirty for the periodic flush and keep going. Rollbacks are compare-and-set (a concurrent writer's acknowledged commit is never erased) and restore the prior _folder_changed latch rather than clearing it. Same-class force=True sites outside this issue's tags/folders scope (slot-recreate in chat_handlers, chat_auto_tag, crew_chat) are left for a follow-up. Closes #7519
…s forced saves (#7519) save_slot_off_loop resolves its target transcript from live routing at write time, so a linked_session_key rebind during the persist await could redirect a durable write to a transcript the caller never authorized against. PR #7346 added the expected_history_key refuse-if-moved pin and wired it at the autocompact endpoint only. Thread the same pin through the remaining tags/folders forced-save sites: - chat_tags: the tag-delete slot strip, PUT slot tags, and the drag-drop status reassign. - chat_folders: the folder-delete unfile loop and its restore rollback, PATCH slot folder, PATCH slot pin, and PATCH slot mode. The five request endpoints capture the authorized key BEFORE their first await and re-check it (plus slot object identity) after the last await before mutating, mirroring the reauthorize-then-capture shape of the autocompact precedent. Each site handles the refusal per its own convention: the direct mutation endpoints roll back and return 409 session_gone, the drag-drop endpoint answers in its own ok:false rejection shape, and the best-effort cleanup loops mark the slot dirty for the periodic flush and keep going. Rollbacks are compare-and-set (a concurrent writer's acknowledged commit is never erased) and restore the prior _folder_changed latch rather than clearing it. Same-class force=True sites outside this issue's tags/folders scope (slot-recreate in chat_handlers, chat_auto_tag, crew_chat) are left for a follow-up. Closes #7519
…s forced saves (#7519) save_slot_off_loop resolves its target transcript from live routing at write time, so a linked_session_key rebind during the persist await could redirect a durable write to a transcript the caller never authorized against. PR #7346 added the expected_history_key refuse-if-moved pin and wired it at the autocompact endpoint only. Thread the same pin through the remaining tags/folders forced-save sites: - chat_tags: the tag-delete slot strip, PUT slot tags, and the drag-drop status reassign. - chat_folders: the folder-delete unfile loop and its restore rollback, PATCH slot folder, PATCH slot pin, and PATCH slot mode. The five request endpoints capture the authorized key BEFORE their first await and re-check it (plus slot object identity) after the last await before mutating, mirroring the reauthorize-then-capture shape of the autocompact precedent. Each site handles the refusal per its own convention: the direct mutation endpoints roll back and return 409 session_gone, the drag-drop endpoint answers in its own ok:false rejection shape, and the best-effort cleanup loops mark the slot dirty for the periodic flush and keep going. Rollbacks are compare-and-set (a concurrent writer's acknowledged commit is never erased) and restore the prior _folder_changed latch rather than clearing it. Same-class force=True sites outside this issue's tags/folders scope (slot-recreate in chat_handlers, chat_auto_tag, crew_chat) are left for a follow-up. Closes #7519
…s forced saves (#7519) save_slot_off_loop resolves its target transcript from live routing at write time, so a linked_session_key rebind during the persist await could redirect a durable write to a transcript the caller never authorized against. PR #7346 added the expected_history_key refuse-if-moved pin and wired it at the autocompact endpoint only. Thread the same pin through the remaining tags/folders forced-save sites: - chat_tags: the tag-delete slot strip, PUT slot tags, and the drag-drop status reassign. - chat_folders: the folder-delete unfile loop and its restore rollback, PATCH slot folder, PATCH slot pin, and PATCH slot mode. The five request endpoints capture the authorized key BEFORE their first await and re-check it (plus slot object identity) after the last await before mutating, mirroring the reauthorize-then-capture shape of the autocompact precedent. Each site handles the refusal per its own convention: the direct mutation endpoints roll back and return 409 session_gone, the drag-drop endpoint answers in its own ok:false rejection shape, and the best-effort cleanup loops mark the slot dirty for the periodic flush and keep going. Rollbacks are compare-and-set (a concurrent writer's acknowledged commit is never erased) and restore the prior _folder_changed latch rather than clearing it. Same-class force=True sites outside this issue's tags/folders scope (slot-recreate in chat_handlers, chat_auto_tag, crew_chat) are left for a follow-up. Closes #7519
…s forced saves (#7519) (#7714) save_slot_off_loop resolves its target transcript from live routing at write time, so a linked_session_key rebind during the persist await could redirect a durable write to a transcript the caller never authorized against. PR #7346 added the expected_history_key refuse-if-moved pin and wired it at the autocompact endpoint only. Thread the same pin through the remaining tags/folders forced-save sites: - chat_tags: the tag-delete slot strip, PUT slot tags, and the drag-drop status reassign. - chat_folders: the folder-delete unfile loop and its restore rollback, PATCH slot folder, PATCH slot pin, and PATCH slot mode. The five request endpoints capture the authorized key BEFORE their first await and re-check it (plus slot object identity) after the last await before mutating, mirroring the reauthorize-then-capture shape of the autocompact precedent. Each site handles the refusal per its own convention: the direct mutation endpoints roll back and return 409 session_gone, the drag-drop endpoint answers in its own ok:false rejection shape, and the best-effort cleanup loops mark the slot dirty for the periodic flush and keep going. Rollbacks are compare-and-set (a concurrent writer's acknowledged commit is never erased) and restore the prior _folder_changed latch rather than clearing it. Same-class force=True sites outside this issue's tags/folders scope (slot-recreate in chat_handlers, chat_auto_tag, crew_chat) are left for a follow-up. Closes #7519 Co-authored-by: Raymond Chen <bolichen97@users.noreply.github.com>
Problem / Motivation
The auto-compaction threshold is a single global knob (
session.autocompact_pct): every session compacts at the same context-usage percentage. A long-running investigation the user wants compacted early (to keep headroom for a big synthesis step) and a short-lived session that should never compact share one value, and changing it for one session changes it for all of them.Why it matters
Compaction timing is a per-conversation tradeoff: compact too late and a session dies mid-thought at the window ceiling; compact too early and a short session pays summarization loss it never needed. Users running heterogeneous sessions (long autonomous loops next to quick Q&A tabs) currently cannot express that difference at all.
What changed (motivation → approach → change)
Goal: let a session carry its own compaction threshold, layered over the global. Approach: mirror the established per-slot-setting pattern (
slot.modelendpoint +reasoning_effortpersistence) rather than inventing a new mechanism, and keep the SessionManager transport-agnostic so non-dashboard surfaces can adopt it later.CompactionState.pct_overrides) consulted by the compaction gate ladder and the warn margin (session_compaction.py). Facade methodsset_autocompact_pct/effective_autocompact_pctclamp into the documentedAUTOCOMPACT_PCT_MIN..MAXrange (an out-of-range override degrades to the nearest firing value, never silently disables the backstop);Nonerestores the global. The override deliberately survives reset/recycle (it is a preference on the conversation) but dies with permanentdestroy()— a recreated same-key session must not silently inherit a deleted session's threshold.SLOT_OWNED_META_KEYSso a cleared override erases durably instead of resurrecting viacarry_unowned_metadata.GET/POST /api/chat/slots/{slot}/autocompactwith the same validation as the global knob's PATCH handler (range, NaN, type), authorized by the session-aware ownership gate (_check_slot_app_ownership+ post-await reauthorization) that the/contextand/noteendpoints use — stricter than the slot-only/modelgate, because the write is keyed by the slot's effective session — and a log line per change.save_slot_off_loop(force=True, best_effort=False)), pinned to the authorized transcript (expected_history_keymakes the save refuse if routing moved mid-request), and serialized per transcript by anasyncio.Lockso alias slots resolving onto one file cannot interleave. After the commit, the value is mirrored to live alias siblings and re-confirmed with a second pinned save, so a queued sibling flush cannot durably revert the acknowledged value; every failure path rolls live state back (409 on delete/rebind, 500 with reconvergence on I/O failure). Legacy transcripts withoutcreated_atstay covered by the delete-won guard via an observed bit recorded at every hydration site (pinned by a structural census test).Slider): value readout, a fixed sense of the global via the reset link ("Reset to global (N%)") when overridden, and a "Following global (N%)" note otherwise. Fetched lazily on popover open through React Query (['slot-autocompact', slot]); slider drags write optimistically into the query cache and collapse into one debounced POST, with all write paths (debounce, cross-slot flush, unmount flush) ordered through a per-slot promise chain. Loading renders skeleton rows; a failed fetch renders a muted explanation (suppressed while a cached value still renders the slider); a rejected write surfaces the same visible failure notice the model switcher uses.Out of scope (deliberate): non-dashboard transports (Slack/Discord), the task runner, and CLI chat keep the global threshold; the override map is transport-agnostic so those can be wired later without redesign.
Tests
test/test_session_autocompact_override.py(59 tests):Nonerestores global; clearing an absent override is a no-op.carry_unowned_metadata(autocompact_pct∈SLOT_OWNED_META_KEYS), anddestroy()clears the override.expected_history_keypin refuses a moved save; legacy (nocreated_at) delete-won coverage, pinned by a structural census of every_disk_meta_created_athydration site; a slotless permanent delete of archived history sweeps the override (fold-matching, same contract as the session-ledger purge), so a recreated deterministic-key session cannot inherit a deleted conversation's threshold.ChatInputCoverage.test.tsx: slider render/override/reset/following-global; write ordering through the per-slot promise chain including cross-slot and unmount flush; loading skeleton; failed-fetch explanation (suppressed while a cached value still renders); a rejected write surfaces a visible failure notice.Manual verification
Rendered the real built SPA against a stubbed dashboard API via the committed capture harness (
website/scripts/capture-autocompact-slider.mjs) and pixel-verified both states against the approved mockup (see screenshots). Backend regression suites for compaction/destroy paths (63 tests) and the adjacent slot-endpoint suites pass.Screenshots / video
Session at 72% context, global threshold 70%.
Override set (85%) — reset link visible:
No override — following the global:
Related Issues
no linked issue: feature implemented directly from a maintainer-visible design discussion (slider option chosen from three mocked alternatives); no tracking issue was filed.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)