Skip to content

feat: collapse compaction surface to a single four-state divider - #836

Merged
Astro-Han merged 21 commits into
devfrom
claude/compaction-ui
May 22, 2026
Merged

feat: collapse compaction surface to a single four-state divider#836
Astro-Han merged 21 commits into
devfrom
claude/compaction-ui

Conversation

@Astro-Han

@Astro-Han Astro-Han commented May 21, 2026

Copy link
Copy Markdown
Owner

Summary

Compaction in PawWork used to render twice — the full summary markdown as if it were an assistant reply, then the synthetic / replay user message that re-injects the original prompt. The UX exposed a purely technical process to every user, and the divider toggled to "Session compacted" the moment compaction started, before the summary had even streamed.

This PR collapses both halves so a compaction turn renders only a thin divider with four real states — pending / done / aborted / failed — plus a neutral elapsed timer while pending, matching the same TextShimmer language already used for running tool titles.

The backend half keeps the summary assistant message as the single carrier for error / finish / time.completed even when pre-summary steps fail or get aborted, so the divider's state machine never strands itself in "pending".

Why

The divider's status was reading from a piece of state that didn't exist for every code path. Pre-summary failures (agents.get / provider.getModel / select / plugin / toModelMessages / processors.create) and abort interrupts didn't create or update a summary assistant, so the UI couldn't tell pending apart from "silently failed before streaming started." And renderer-side, the summary message and the synthetic continuation were treated as ordinary assistant / user content, leaking compaction internals into every consumer of assistantMessages() (Thinking…, error card, copy target, turn-duration math, AssistantParts rendering).

Design spec, including the exact state-machine ordering and the elapsed-start switch once the summary message appears: docs/superpowers/specs/2026-05-21-compaction-ui-design.md. Visual source for the four-state divider: docs/design/preview/compaction-states-compare.html.

Related Issue

No tracking issue — change came from a direct review of the compaction surface against docs/DESIGN.md motion / iconography rules.

Human Review Status

Pending

Review Focus

  • compaction.ts — the placeholder is created up front and the rest of the flow is wrapped in Effect.catch + Effect.onInterrupt. Pre-summary failures land on the placeholder with error + finish=\"error\"; aborts land with MessageAbortedError. Verify there is no path where the placeholder is created but receives neither.
  • session-turn.tsx — raw vs visible split. The summary message must reach only compactionSummary / divider state. Every other derivation reads visibleAssistantMessages. There is a contract test guarding this, but a fresh pair of eyes on the new memos is welcome.
  • session-turn-compaction.ts — five-rule state machine ordering. Reviewer attention on rule 2/3 (abort/error precedence over time.completed) and rule 5 (elapsed switches to summary time.created once it exists).
  • User.replay schema field + SDK regeneration — straightforward additive, but worth a glance to make sure no other consumer is now sending unexpected values.

Risk Notes

  • The placeholder summary assistant now persists on aborted compactions where previously the storage stayed clean. Other systems that count or iterate "summary assistants" should expect to see aborted/failed ones too. Inside this PR, both call sites that look at summary assistants (completedCompactions filter on finish && !error, and the renderer's compactionSummary) already gate on it correctly.
  • SDK regeneration (packages/sdk/js/src/v2/gen/**) is part of this PR. Downstream consumers of the SDK now type replay?: boolean on UserMessage.
  • Cross-package change (opencode + sdk + ui) — must merge whole so the schema, type, and renderer stay in sync.

How To Verify

```text
opencode targeted (session.compaction): 44 pass, 1 file
ui targeted (session-turn-compaction helpers + contract): 29 pass, 2 files
opencode typecheck: pass
ui typecheck: pass
app typecheck: pass
git diff --check: clean
```

Pre-existing baseline failures unchanged on dev (Button single canonical size, IconButton 24×24, undefined-tokens --border-active, synthetic stop tool parts) — confirmed via git stash on this branch.

Screenshots or Recordings

compaction-divider

Checklist

  • Type label — this PR carries exactly one of bug, enhancement, task, documentation. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.
  • Routing labels — this PR carries at least one of app, ui, platform, harness, ci. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.
  • Priority label — this PR carries exactly one of P0, P1, P2, P3. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.
  • Human Review Status above is set to Pending, Approved by @<reviewer>, or Not required: <reason> (default is Pending; "not required" is restricted to bot-authored low-risk PRs).
  • I linked the related issue, or stated in Summary why there is no issue.
  • I described the review focus and any meaningful risks.
  • I replaced the example block in How To Verify with the real verification steps and the key result for each.
  • I did not introduce unrelated refactors, dependencies, generated files, or file changes beyond the stated scope.
  • (conditional) I manually checked visible UI or copy changes when needed, with screenshots or recordings. Leave unticked only if no visible UI or copy changed.
  • (conditional) I considered macOS and Windows impact for platform, packaging, updater, signing, paths, shell, or permissions changes. Leave unticked only if no platform/packaging surface was touched.
  • (conditional) I called out docs, release notes, dependencies, permissions, credentials, deletion behavior, generated content, or local file changes when relevant. Leave unticked only if none of those surfaces was touched.
  • I reviewed the final diff for unrelated changes and suspicious dependency changes.
  • I am targeting dev, and my PR title and commit messages use Conventional Commits in English.

Summary by CodeRabbit

  • New Features

    • Compaction divider is stateful with real-time elapsed display, contextual failure/abort labels, and improved visibility rules (suppresses duplicate “Thinking…”).
  • Bug Fixes

    • Divider no longer remains stuck in pending on failures or aborts.
    • Compact action disabled while a session is busy.
    • Reinjected overflow messages marked as hidden duplicates to avoid UI duplication.
  • Tests

    • Added snapshot, behavioral and cancellation tests covering divider states and visual output.
  • Documentation

    • Added English and Chinese compaction status strings.

Review Change Stack

Astro-Han added 2 commits May 21, 2026 23:01
ProcessCompaction now creates its summary assistant placeholder up front
and routes pre-summary failures (agents/provider/select/plugin/
toModelMessages/processors.create) plus abort interrupts through the
same record. Without this, the UI divider never sees an error/finish
signal for those paths and stays pending forever. Also tags re-injected
overflow user messages with replay:true so the renderer can collapse
them without inspecting parts heuristically.
The compaction turn used to render the summary markdown then a second
copy of the original user message through the synthetic continuation /
replay flow. UX-wise that re-exposes a purely technical process to every
user. This change reroutes both halves so a compaction turn now shows
only a thin divider with four real states (pending/done/aborted/failed)
plus a neutral elapsed timer in pending — matching the running-tool
TextShimmer language we already use elsewhere.

State derivation lives in a new helper file (pure functions covered by
unit tests) so the rules — abort/error precedence over time.completed,
ContextOverflowError as its own label, elapsed-start switching once the
summary assistant appears — can be regression-tested without rendering.
session-turn.tsx now splits raw vs visible assistant lists so the summary
message reaches only the divider and never leaks into "Thinking…",
error cards, copy targets, turn-duration math, or assistant rendering.
Compaction placeholder users and replay/synthetic-continue user bodies
hide their content while keeping the turn row so child assistants still
render through parentID.
@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

Failed to post review comments

📝 Walkthrough

Walkthrough

Adds backend early placeholder compaction assistant and terminalized abort/error handling, prelude/runner wiring for compaction runs, frontend divider state helpers and MessageDivider rendering (icons + elapsed), SessionTurn message filtering to isolate summaries, expanded tests, and i18n for compaction states.

Changes

Session Compaction Divider UI

Layer / File(s) Summary
Backend compaction flow & placeholder handling
packages/opencode/src/session/compaction.ts, packages/opencode/src/session/message-v2.ts
Create/update a provisional assistant placeholder early in compaction processing, resolve real agent/model inside the effect, and ensure interrupts/pre-summary failures write error, finish="error", and time.completed. Reinjected overflow user copies set replay: true.
Prompt loop prelude & onInterrupt handling
packages/opencode/src/session/prompt.ts
Add prelude.type === "compaction" to LoopInput; when provided, runner runs revert.cleanup then compaction.create before runLoop, sets rejectIfBusy for prelude calls, and onInterrupt persists a terminal synthetic compaction assistant with abort diagnostics when needed.
Runner API and run-state plumbing
packages/opencode/src/effect/runner.ts, packages/opencode/src/session/run-state.ts, packages/app/src/pages/session/use-session-commands.tsx
Runner.ensureRunning gains options?: { rejectIfBusy?: boolean } and performs busy-check atomically; session run-state forwards the options; UI session.compact command is disabled while session work is in-flight.
Summarize route: use SessionPrompt.loop prelude
packages/opencode/src/server/instance/session.ts
Summarize route invokes SessionPrompt.loop(...) with a compaction prelude (provider/model + auto) instead of calling SessionCompaction.create(...); after loop it re-reads messages and throws on non-abort compaction errors.
Compaction divider state helpers
packages/ui/src/components/session-turn-compaction.ts, packages/ui/src/components/session-turn-compaction.test.ts
Add CompactionDividerState/Label and helpers: compactionDividerState, compactionDividerLabelKey, compactionElapsedSeconds, and formatCompactionElapsed with unit tests for precedence, reason extraction, elapsed computation, clamping, and formatting.
MessageDivider component + CSS
packages/ui/src/components/message-part/parts/compaction-and-divider.tsx, packages/ui/src/components/message-part.css
MessageDivider accepts state and elapsed, binds data-state, conditionally shows aborted/failed icons and TextShimmer for pending, and optionally shows elapsed. CSS adds inline-flex label layout, icon/elapsed sizing, weaker foreground, and a [data-state="failed"] error tint.
SessionTurn integration: raw vs visible messages, timers, hiding
packages/ui/src/components/session-turn.tsx
Introduce rawAssistantMessages (keeps compaction summary) and visibleAssistantMessages (filters it out). Rewire derivations to use visible list, compute compaction divider state/label, run a 1s elapsed timer while pending (with cleanup/reset), suppress "Thinking…" while pending, and hide user body for compaction placeholders (respecting replay).
Behavioral contract tests & i18n
packages/ui/src/components/session-turn-compaction-contract.test.ts, packages/ui/src/i18n/en.ts, packages/ui/src/i18n/zh.ts
Add contract tests validating raw/visible assistant splitting, visibleAssistantMessages usage, showThinking suppression, hideUserBody detection (including replay), elapsed timer lifecycle, MessageDivider rendering contracts, and part registry. Add English and Chinese i18n entries for compaction states and failure variants.
E2E snapshot tests
packages/app/e2e/snap/compaction-divider.snap.ts
Playwright snapshot test seeds DONE/FAILED/PENDING/ABORTED sessions, waits for divider data-state transitions, injects CSS to hide notifications for clean screenshots, captures per-state images, and composes a 2-column grid artifact.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Server as SessionPrompt.loop
  participant Compaction as CompactionProcessor
  participant Placeholder as PlaceholderAssistant
  participant UI as SessionTurn

  Client->>Server: request summarize (prelude compaction)
  Server->>Compaction: create marker & provisional placeholder
  Compaction->>Placeholder: write provisional assistant (provisional model)
  alt success
    Compaction->>Placeholder: update with real model + summary
    Placeholder->>UI: visible as done
  else abort / interrupt
    Compaction->>Placeholder: set error=MessageAbortedError, finish="error", time.completed
    Placeholder->>UI: visible as aborted
  else pre-summary error
    Compaction->>Placeholder: set error, finish="error", time.completed
    Placeholder->>UI: visible as failed
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • Astro-Han/pawwork#563 — Overlaps interrupt/abort handling in SessionPrompt.loop and runner interrupt plumbing.
  • Astro-Han/pawwork#611 — Related UI changes altering assistant message sourcing/filtering that affect compaction visible vs raw logic.
  • Astro-Han/pawwork#270 — Related compaction pipeline and message schema edits (overflow/truncation and message-v2 changes).

Poem

🐰 I seeded sessions, watched dividers glow,
pending shimmered, then summaries flowed.
Errors wore red, aborts gently sighed,
timers ticked on while states complied.
A tiny rabbit cheered the UI's show.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: collapse compaction surface to a single four-state divider' clearly and concisely describes the main change: collapsing a two-part compaction UI into a single divider component with four distinct states.
Description check ✅ Passed The description comprehensively addresses all required template sections: Summary explains the UX improvement, Why covers the technical problem and design rationale, Related Issue is stated, Human Review Status is set to 'Pending', Review Focus identifies key areas, Risk Notes enumerate impacts and SDK changes, How To Verify includes actual test results, Screenshots are provided, and the Checklist is substantially completed with critical items ticked.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/compaction-ui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Astro-Han Astro-Han added enhancement New feature or request ui Design system and user interface app Application behavior and product flows labels May 21, 2026
@github-actions github-actions Bot added harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority and removed app Application behavior and product flows labels May 21, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested priority: P2 (includes non-doc, non-test paths outside the low-risk bucket).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown

Perf delta summary

Comparator: pass

Profile / Scenario interaction median interaction worst long task max tbt frame gap p95 frame gap max jank count cls status
default / homepage-cold 32 -> 24 (-8) 40 -> 24 (-16) 54 -> 57 (+3) 4 -> 7 (+3) 16.8 -> 16.8 (0) 116.6 -> 100 (-16.6) 3 -> 2 (-1) 0 -> 0 (0) pass
default / long-session-input-lag 40 -> 40 (0) 48 -> 40 (-8) 0 -> 0 (0) 0 -> 0 (0) 16.8 -> 16.8 (0) 16.8 -> 16.8 (0) 0 -> 0 (0) 0 -> 0 (0) pass
default / session-streaming-long 40 -> 40 (0) 64 -> 48 (-16) 0 -> 0 (0) 0 -> 0 (0) 33.3 -> 16.8 (-16.5) 83.2 -> 16.8 (-66.4) 1 -> 0 (-1) 0 -> 0 (0) pass
default / tool-call-expand 16 -> 16 (0) 16 -> 16 (0) 0 -> 0 (0) 0 -> 0 (0) 16.8 -> 16.7 (-0.1) 16.8 -> 16.7 (-0.1) 0 -> 0 (0) 0 -> 0 (0) pass
default / tool-default-open-heavy-bash 16 -> 16 (0) 16 -> 16 (0) 51 -> 53 (+2) 1 -> 3 (+2) 50.1 -> 50 (-0.1) 83.3 -> 83.3 (0) 4 -> 2 (-2) 0 -> 0 (0) pass
default / terminal-side-panel-open 40 -> 40 (0) 48 -> 48 (0) 0 -> 0 (0) 0 -> 0 (0) 33.3 -> 16.8 (-16.5) 33.3 -> 16.8 (-16.5) 0 -> 0 (0) 0 -> 0 (0) pass
default / session-scroll-reading 40 -> 32 (-8) 40 -> 32 (-8) 0 -> 0 (0) 0 -> 0 (0) 16.7 -> 16.8 (+0.1) 16.7 -> 16.8 (+0.1) 0 -> 0 (0) 0 -> 0 (0) pass
low-end / session-scroll-reading-long 0 -> 0 (0) 0 -> 0 (0) 62 -> 56 (-6) 31 -> 11 (-20) 33.3 -> 33.3 (0) 66.7 -> 66.7 (0) 3 -> 2 (-1) 0.011 -> 0.011 (0) pass
low-end / session-timeline-recompute 24 -> 32 (+8) 32 -> 40 (+8) 0 -> 0 (0) 0 -> 0 (0) 16.8 -> 33.4 (+16.6) 16.8 -> 33.4 (+16.6) 0 -> 0 (0) 0.214 -> 0.214 (0) pass
low-end / concurrent-shimmer-extreme 0 -> 0 (0) 0 -> 0 (0) 0 -> 0 (0) 0 -> 0 (0) 16.7 -> 16.7 (0) 16.8 -> 16.8 (0) 0 -> 0 (0) 0 -> 0 (0) pass

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the session compaction logic to provide better UI feedback and robust error handling. Key changes include the early creation of a placeholder assistant message to track compaction states (pending, aborted, failed), filtering summary messages from the main conversation view, and updating the UI to display progress indicators and elapsed time. Feedback was provided regarding the onInterrupt finalizer in the compaction logic, suggesting that errors from session.updateMessage should be explicitly propagated to ensure proper failure handling downstream.

Comment thread packages/opencode/src/session/compaction.ts
Drives done/failed/pending/aborted through real production e2e:
- done: SUMMARY_TEXT reply + summarize
- failed: HTTP 400 from LLM endpoint, non-retryable APIError path
- pending: assistant.hang() + summarize (fire-and-forget)
- aborted: session.abort after pending state captured

The pending/aborted pair shares one session so the placeholder summary
assistant transitions through both states in sequence.
@github-actions github-actions Bot added the app Application behavior and product flows label May 21, 2026
Astro-Han added 4 commits May 21, 2026 23:49
`compactionDividerLabelKey` was reading `error.message` (top-level), but
`NamedError.toObject()` returns `{ name, data: { message, ... } }` — so
the reason was always undefined and the failed divider rendered as just
"Compaction failed:" with no cause shown. Prefer `error.data.message`
and keep the top-level fallback so helper-only synthetic error shapes
still work in unit tests.

Updated the failed-label helper tests to assert against the real APIError
shape (data.message with the surrounding fields) instead of the synthetic
top-level form that hid this gap.
Pre-summary failure (outcome.ok=false) and onInterrupt (abort) branches
only wrote error + finish=error onto the summary placeholder. The UI's
`pending` memo scans `allMessages()` (which includes the summary
assistant) for assistants without `time.completed`, so an aborted /
pre-summary-failed compaction looked in-flight to other turn-level
consumers driven by that memo.

Both terminal branches now stamp `time.completed = Date.now()` alongside
the error fields. The abort-before-processor test gets a matching
assertion so the contract stays locked.
MessageOutputLengthError and other NamedError variants with empty `data`
schema produce no `data.message`, so the divider previously rendered
"Compaction failed:" / "压缩失败:" with a dangling colon. Route the
empty-reason case to a separate `failedUnknown` i18n key without the
colon template.
@Astro-Han

Copy link
Copy Markdown
Owner Author

Triaged the latest review:

P3 — empty reason produces trailing colon: fixed in 146e022.
MessageOutputLengthError is NamedError.create("MessageOutputLengthError", z.object({})), so error.data.message is always undefined and reason resolved to "", rendering Compaction failed: / 压缩失败: with a dangling colon. Added a failedUnknown i18n key (no colon) and route the empty-reason case to it. Covered by two new label tests (empty-data MessageOutputLengthError and whitespace-only reason).

P2-1 — /session/:id/summarize returns true even when compaction fails: pushing back.
git blame shows c.json(true) at packages/opencode/src/server/instance/session.ts:1036 predates this PR (it's the original PawWork summarize contract). This PR only changed the divider state machine and added time.completed stamping on terminal placeholders; the SDK return-value contract is orthogonal and out of scope. Happy to revisit in a dedicated PR that updates the OpenAPI response schema (z.boolean() → result union) and the SDK consumers, but doing it here would conflate two reversible intents.

P2-2 — legacy sessions with a compaction part but no summary assistant render as permanent pending: pushing back.
The placeholder summary assistant is created at packages/opencode/src/session/compaction.ts:380 before any failure point inside process(). Per git blame that creation has existed since e5c637ac10c (2026-04-20), which is PawWork's initial commit — there is no pre-PawWork OpenCode upgrade path that would have produced orphaned compaction parts in real user data. Adding a defensive fallback for "compaction part without summary assistant" would be guarding against a state that the current code structure cannot produce. If we ever ship an actual data-migration path that could leave orphans, that's the right time to add the fallback.

Astro-Han added 2 commits May 22, 2026 00:43
The /:sessionID/summarize route called SessionPrompt.loop and returned
c.json(true) unconditionally. Pre-summary compaction failures
(agents.get / provider.getModel / select / plugin / toModelMessages /
processors.create) land the error on the placeholder summary assistant
and return "stop" without throwing, so SDK callers would resolve `true`
for a session that visibly failed.

After the loop returns, scan back to the latest compaction-mode
assistant message. If it carries a non-aborted error, rethrow it as
UnknownError so the global ErrorMiddleware emits a structured 500. User
aborts are not failures from the route's perspective and continue to
return true. The compaction-divider snap now asserts that summarize
rejects on the failure branch.
Pre-PR compaction process() created the placeholder summary assistant
only after agents.get / provider.getModel / select / plugin /
toModelMessages / processors.create succeeded, so historical
pre-summary failures never wrote a summary assistant at all. The new
state machine returned "pending" whenever the summary was absent,
which would render an infinite shimmer on those orphan records.

Add hasLaterTurn input to compactionDividerState: when no summary
assistant exists and the session already moved on with another turn,
return "failed" (which the label key already routes to the no-colon
"failedUnknown" variant when no error data is available). The live
race window (placeholder about to land on the latest turn) still
returns "pending".

session-turn.tsx derives hasLaterTurn from messageIndex vs the full
message list and threads it through. The contract test now guards the
hasLaterTurn handoff.
@Astro-Han

Copy link
Copy Markdown
Owner Author

Reversing my earlier push-back on both P2 items. Re-reading origin/dev:packages/opencode/src/session/compaction.ts (not HEAD) showed the placeholder summary assistant was previously created after a long chain of fallible setup steps (agents.get / provider.getModel / select / plugin.trigger / toModelMessages / processors.create), so the orphan scenario the reviewer flagged is real for any pre-PR session data, and the route did silently absorb the same class of failure.

P2-1 fixed in d92465f. /:sessionID/summarize now scans back to the latest mode: "compaction" assistant after SessionPrompt.loop returns. Non-aborted error rethrows as UnknownError, which the global ErrorMiddleware turns into a structured 500. Aborts still resolve true since they're user-initiated. The compaction-divider snap now asserts summarize rejects on the failed branch (the prior try{...}catch{} was anticipating exactly this fix).

P2-2 fixed in 17c6428. Extended compactionDividerState with a hasLaterTurn input. When no summary assistant exists and the session already has a turn past this compaction, the state machine returns "failed" instead of "pending"; combined with the failedUnknown label key landed in 146e022, the divider renders a clean "Compaction failed" / "压缩失败" label without a trailing colon. The live race window (placeholder about to land on the latest turn) still returns "pending". session-turn.tsx derives hasLaterTurn from messageIndex vs the full message list and the contract test now guards the handoff.

Verification: opencode 44/44, ui session-turn-compaction + contract 34/34, all three typechecks clean. Five pre-existing baseline failures unchanged (documented in the PR body).

The previous hasLaterTurn heuristic missed the case where a legacy
orphan record was itself the last message in the session: a pre-PR
pre-summary failure that stopped on the latest turn would still render
infinite "Compacting…" shimmer because no later turn existed to flip
the gate.

Replace hasLaterTurn with isWorking, derived from the existing working
memo (isWorkInFlightStatus(status) && active()). The divider now only
returns "pending" when the session is actually doing work for this
compaction turn; otherwise an absent summary means failed/unknown.
Status and working memos are hoisted above the divider memo so the
forward reference resolves at Solid memo creation time.
@Astro-Han

Copy link
Copy Markdown
Owner Author

Fixed in 16b97fb. The previous hasLaterTurn heuristic was position-only and missed exactly the case you described: an orphan that's also the last turn would still shimmer forever because myIdx < messages.length - 1 was false.

Replaced hasLaterTurn with isWorking, threaded from the existing working memo (isWorkInFlightStatus(status) && active()). The divider now only returns "pending" when the session runtime is actually doing work for this compaction turn — busy/retry status AND this turn is the active one. Any other state with a missing summary assistant resolves to "failed" (which then routes through the failedUnknown label key from 146e022 to render without a trailing colon).

status and working memo declarations are hoisted above compactionDivider so the forward reference resolves at Solid memo-creation time (Solid runs memo bodies eagerly to register dependencies).

Verification: 34/34 compaction unit + contract tests pass (orphan-as-latest-turn case now covered), all three typechecks clean, snap re-run shows the four-state grid unchanged.

Auto-compaction calls SessionCompaction.create from inside runLoop where
status is already busy. The manual summarize route called create first,
then SessionPrompt.loop set busy. The compaction part event reached
clients before the status event, so the divider rendered the
legacy-orphan "failed" state for one frame before busy landed.

Flip status to busy in the route handler before create, matching the
auto path's event ordering.
@Astro-Han

Copy link
Copy Markdown
Owner Author

Confirmed the race. Timeline I traced:

  • Route at packages/opencode/src/server/instance/session.ts:1026-1035 calls SessionCompaction.create first (writes user message + compaction part via session.updateMessage / session.updatePart, no status touch), then SessionPrompt.loop which only sets busy at prompt.ts:1914 inside runLoop.
  • UI at session-turn.tsx:310-312: compaction() becomes truthy on the compaction part event; working() waits for session.status: busy. With backend emitting partstatus in that order, there's at least one render frame where the divider sees marker + no summary + isWorking=false → returns failed.
  • Auto-compaction has no race: both prompt.ts:1990 and 2184 call compaction.create from inside runLoop, where busy is already set.

Fixed in 4107a85025 by setting status to busy in the route handler before SessionCompaction.create. Matches the auto path's event ordering. Eight-line change, opencode typecheck clean, snap re-run passes.

The previous busy-before-create fix sat outside the Runner, so a cancel
in the pre-loop window hit SessionRunState.cancel's no-runner branch and
was silently dropped. Move marker creation into a `prelude` on
SessionPrompt.loop, executed inside ensureRunning's work fiber. The work
starts with status.set busy (still ahead of the part event, preserves
the divider flash fix) and then runs compaction.create, so any cancel
during setup hits a Running runner and Fiber.interrupt fires.

The summarize route now passes prelude instead of calling
SessionCompaction.create directly. Concurrent summarize calls also stop
double-writing markers because ensureRunning short-circuits to the
existing run.

Adds a regression test that hangs the compaction LLM call and cancels
mid-stream, asserting the marker was written and the placeholder
assistant carries MessageAbortedError.
@Astro-Han

Copy link
Copy Markdown
Owner Author

Confirmed and fixed in f942bf83e5.

The prior busy-set sat outside the Runner. Reading run-state.ts:100-108, SessionRunState.cancel short-circuits to status.set idle and returns when there's no runner — the abort intent is dropped, then SessionCompaction.create and SessionPrompt.loop continue regardless.

Restructure: marker creation now runs inside the loop's runner-protected work effect. Added an optional prelude to LoopInput. The work starts with status.set busy (still emitted before the compaction part event, so the divider flash fix is preserved) and then runs compaction.create. A cancel arriving anywhere in setup hits the Running runner and Fiber.interrupt fires; pre-loop cancel can no longer be swallowed. As a side effect, concurrent summarize calls also stop double-writing markers because ensureRunning short-circuits to the existing run.

The summarize route is now thinner — just gathers the current agent and calls SessionPrompt.loop({ sessionID, prelude }). The SessionCompaction.create import is gone from the route file.

Race test in prompt-effect.test.ts: hang the compaction LLM call, fork loop with prelude, llm.wait(1) to confirm the prelude wrote the marker and runLoop entered the LLM stream, then cancel. Asserts the loop exits successfully, the compaction part is in messages, and the placeholder summary assistant carries MessageAbortedError. 55 prompt-effect tests pass, opencode typecheck clean, snap re-run passes.

Astro-Han added 2 commits May 22, 2026 07:20
Manual compaction routed through SessionPrompt.loop({ prelude }), which
runs marker creation inside the runner's work effect. When the runner is
already Running for another prompt, ensureRunning would short-circuit to
awaitRun(existing) and the prelude effect was never executed, but the
summarize route still resolved with `true` — clients saw success for a
session that never wrote a compaction marker.

Add rejectIfBusy to Runner.ensureRunning so the prelude path can refuse
to silently no-op when the runner is already in flight: the check lives
inside the atomic SynchronizedRef.modifyEffect, eliminating any race
between status read and Idle→Running transition. SessionPrompt.loop
enables rejectIfBusy whenever input.prelude is present; busy callers
get Session.BusyError, mapped to HTTP 400 by the existing middleware.
Builtin slash commands flow through popover-controllers.ts filtering out
disabled entries entirely, and the command palette greys disabled rows.
Adding isWorkInFlightStatus(status()) to the disabled predicate stops
users from invoking compact in the brief window where the server would
now reject with Session.BusyError, keeping the in-product paths aligned
with the backend's new contract.
@Astro-Han

Copy link
Copy Markdown
Owner Author

Fixed in 73aab58 (server reject) + 0d6be60 (UI gate).

Root cause confirmed by reading Runner.ensureRunning (packages/opencode/src/effect/runner.ts:160): when a run was already in flight, the Running/ShellThenRun branch returned awaitRun(existing) without executing the new work. The prior PR moved compaction-marker creation into that work effect to fix the abort-swallow race, but as a side effect a loop({ prelude }) arriving while another run was Running silently dropped the prelude and resolved with the previous run's result — the summarize route then returned true for a session that never wrote a marker.

Considered two paths and explicitly rejected one:

  • Runner-level queue for prelude/loop — heavier abstraction change, affects normal prompt path semantics, scope far exceeds the bug.
  • Route-level assertNotBusy only — leaves SDK / future TUI callers exposed, and has TOCTOU between status read and the runner's atomic transition.

Took the third option: Runner.ensureRunning now accepts rejectIfBusy, evaluated inside the SynchronizedRef.modifyEffect so the busy check and Idle→Running transition are atomic. SessionPrompt.loop enables it whenever input.prelude is set; bypass paths (CLI / scripts hitting /summarize directly) get Session.BusyError → 400 instead of the prior silent success.

UI side: session.compact command now also disables when isWorkInFlightStatus(status()). Builtin slash entries flow through popover-controllers.ts filter((opt) => !opt.disabled ...), so the slash popover hides the entry entirely while busy and the command palette greys it. Bypass routes still get the honest 400.

Queueing compact through the followup machinery (so a busy click parks the action and auto-fires on idle) is a UX nice-to-have rather than a fix for this finding — tracking as a separate follow-up so this PR stays scoped to the silent-success root cause.

Test added: "loop rejects compaction prelude when a run is already in flight" — pins a normal prompt in Running via llm.hang, asserts the prelude loop call fails with Session.BusyError, and verifies no new compaction marker was written.

Verification: bun --cwd packages/opencode run typecheck clean; bun test test/session/prompt-effect.test.ts 56/56 pass; bun --cwd packages/app run typecheck clean; bun run snap compaction-divider 1 passed.

…eholder

Between `SessionCompaction.create` writing the user message + compaction
part and `processCompaction` writing its placeholder summary assistant,
a cancel left an orphan: marker present, no assistant, status idle. The
divider state machine then read this as `failed` (no summary + not
working) instead of `aborted`.

Extend `SessionPrompt.loop.onInterrupt`'s fallback branch: when
`currentTurnTarget` returns a user message whose parts include a
compaction part, write a terminal summary assistant carrier with
`MessageAbortedError`, `finish="error"`, `time.completed`, and the same
`diagnostics.abort` shape the existing assistant-branch records. The
condition is gated on the marker (not `input.prelude`) so auto-overflow
compaction calls inside `runLoop` get the same treatment.

Test covers the race window by polling for the compaction part and
cancelling immediately; asserts the terminal carrier exists with the
expected error/finish/parentID regardless of which path resolved the
abort (the new fallback or `processCompaction`'s own onInterrupt
finalizer if it had already registered).
@Astro-Han

Copy link
Copy Markdown
Owner Author

Picked up the P2 about cancel-during-compaction-prelude landing as failed instead of aborted. Verified the race window first, then applied the surgical onInterrupt fallback the reviewer's primary suggestion describes.

Verification (verbatim from the code):

  • SessionCompaction.create (packages/opencode/src/session/compaction.ts L670-693) writes the user message + compaction part, no summary assistant.
  • The placeholder summary assistant is written by processCompaction at compaction.ts:408; its Effect.onInterrupt finalizer is only installed after that write (compaction.ts:519-533).
  • SessionPrompt.loop.onInterrupt (prompt.ts:2206-2246) used to short-circuit to a return assistant when currentTurnTarget returned a user message — no terminal carrier, no error written.
  • Frontend compactionDividerState (packages/ui/src/components/session-turn-compaction.ts:28-35): no summary + not working → "failed". So a cancel landing in that window surfaced as failed.

Chosen approach: Reviewer's primary option (surgical fallback in onInterrupt). Considered the alternative (atomic prelude that creates the placeholder up front), but it has a residual sub-window between the early placeholder write and processCompaction's onInterrupt registration — it would just shift the failure mode from failed to pending forever, not close the race. Pulled an external second opinion from Codex (xhigh) which confirmed this analysis and surfaced one refinement: gate the fallback on "latest user has a compaction part" rather than input.prelude?.type === "compaction", so the same race coming from runLoop-internal auto-overflow compaction calls (prompt.ts:1990, prompt.ts:2184) gets the same treatment. Applied that refinement.

What landed (commit c2120f7):

  • prompt.ts:2247- — in onInterrupt's fallback branch, when currentTurnTarget returns a user message whose parts include a compaction part, write a terminal summary assistant carrier mirroring the shape from processCompaction (mode=compaction, agent=compaction, summary=true, provisional model from the marker, path from session executionContext) with MessageAbortedError, finish="error", time.completed=recordedAt, and the same diagnostics.abort envelope the existing assistant-branch records (propagation_point distinguished as session.prompt.loop.onInterrupt.compaction_prelude).
  • New test cancel after compaction marker but before placeholder yields aborted carrier: polls until the compaction part is observable, cancels immediately, asserts terminal summary exists with error.name === "MessageAbortedError", finish === "error", time.completed is a number, parentID points at the marker.
  • 57/57 prompt-effect.test.ts pass. opencode typecheck clean.

Considered out of scope: the smaller theoretical window inside compaction.create between the user-message write and the compaction-part write. Hitting that window leaves a partial user with no parts — distinct failure mode, distinct fix, not what this finding describes.

A busy /summarize used to mutate session.revert before SessionPrompt.loop
got a chance to reject the call with BusyError, so a rejected compact was
not a pure no-op. revert.cleanup now runs inside the work effect, after
the Runner has won the Idle slot, alongside status.set and the marker
write — the whole prelude is one atomic unit and rejected requests touch
nothing.

Also tighten the post-marker-pre-placeholder race test: poll for the
exact window (marker present, placeholder absent) and assert the new
onInterrupt fallback was the path that handled the cancel via
propagation_point, so a future regression that drops the fallback would
fail the test instead of silently relying on the old finalizer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/app/e2e/snap/compaction-divider.snap.ts`:
- Around line 96-109: The test currently treats any exception from
projectSdk.session.summarize(...) as proof the compaction failure was surfaced;
change it to capture the thrown error (e.g., const err = caughtError) and assert
that the error carries the injected compaction-failure signal from the test
fixture (use projectSdk.session.summarize, failedSessionID and the
placeholder/error marker your setup injects), for example by checking
err.message or err.code includes the expected "compaction" / failure marker so
the test fails only when the specific compaction failure is observed.

In `@packages/opencode/src/server/instance/session.ts`:
- Around line 1057-1060: The current rethrow uses the provider message verbatim
which can be empty/whitespace; normalize by extracting (info.error.data as
{message?: string} | undefined)?.message, trim it, and if the trimmed string is
non-empty use it as the reason, otherwise fall back to the default `Compaction
failed (${info.error.name})` before throwing `new NamedError.Unknown({ message:
reason })`, updating the logic around `info.error`/`NamedError.Unknown`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f29d11de-1a3d-4b20-a599-48b5c5fd9f90

📥 Commits

Reviewing files that changed from the base of the PR and between 146e022 and 41406e9.

📒 Files selected for processing (11)
  • packages/app/e2e/snap/compaction-divider.snap.ts
  • packages/app/src/pages/session/use-session-commands.tsx
  • packages/opencode/src/effect/runner.ts
  • packages/opencode/src/server/instance/session.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/session/run-state.ts
  • packages/opencode/test/session/prompt-effect.test.ts
  • packages/ui/src/components/session-turn-compaction-contract.test.ts
  • packages/ui/src/components/session-turn-compaction.test.ts
  • packages/ui/src/components/session-turn-compaction.ts
  • packages/ui/src/components/session-turn.tsx

Comment thread packages/app/e2e/snap/compaction-divider.snap.ts
Comment thread packages/opencode/src/server/instance/session.ts
Comment thread packages/opencode/src/session/prompt.ts Outdated
Astro-Han added 5 commits May 22, 2026 15:30
The previous commit moved revert.cleanup into the prelude work effect
but left agent derivation in the route handler, where it read the
pre-cleanup message list. For a reverted session this picked the agent
off a discarded user message instead of the revert point's last active
agent.

LoopInput.prelude.agent is now optional; when omitted the work effect
derives it from sessions.messages after revert.cleanup, restoring the
original cleanup-then-pick-agent ordering inside the atomic
transaction. The route handler drops both the messages lookup and the
agent loop.

Also strengthen the race test setup: observedRaceWindow flag fails
explicitly when polling never caught the marker-without-placeholder
window, instead of producing a confusing propagation_point mismatch
downstream.
The previous onInterrupt fallback only fired when currentTurnTarget
returned a user message bearing the compaction part. SessionPrompt
.prompt persists its user message before ensureRunning awaitRuns the
in-flight compaction, so a normal prompt landing while compaction is
running displaces the latest-user pointer to the new prompt's user.
The marker stays orphaned, summary assistant never gets created, and
the divider renders `failed` instead of `aborted`.

Replace the latest-user gate with a sweep: find any user message with
a compaction part that has no summary assistant child, and write the
aborted carrier against that marker. Covers both the simple race
window (marker just written, placeholder pending) and the queued-
prompt window where the marker is no longer the latest user. Added a
queued-prompt test that injects a fresh user message before cancel
and asserts the carrier still lands on the marker.

Also normalize empty/whitespace provider error messages when
rethrowing from the summarize route — `??` previously surfaced an
empty string verbatim, leaving SDK callers with a blank reason.
External review (Codex xhigh + Claude/Codex crosscheck) flagged the
sweep's "find newest marker, only act if orphaned" approach as
potentially missing older orphans. Recording why iterating to the
first orphan would be wrong: it would retroactively rewrite a
historical crash-orphan as `aborted` and stamp the current cancel's
propagation_point on it. The Runner is per-session serial, so the
newest marker is the only one this cancel can be honestly attributed
to.
Adds a focused regression test for the prelude path's optional
LoopInput.prelude.agent: after revert.cleanup drops messages newer
than the revert point, the loop derives the marker's agent from the
latest *remaining* user. The test seeds a build-agent userOne, writes
a ninja-agent userTwo, points revert back at userOne, then calls
loop without prelude.agent and asserts the marker records build.

A pre-cleanup derivation regression would pick ninja off userTwo and
fail loudly.

Also extends the sweep's semantic-boundary comment to spell out the
two upstream invariants the newest-marker-only attribution relies on
(per-session serial Runner + rejectIfBusy on the prelude path), so a
future change that weakens either knows what would also have to move.
# Conflicts:
#	packages/opencode/src/session/compaction.ts
@Astro-Han
Astro-Han merged commit e7e3a23 into dev May 22, 2026
28 checks passed
@Astro-Han
Astro-Han deleted the claude/compaction-ui branch May 22, 2026 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app Application behavior and product flows enhancement New feature or request harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority ui Design system and user interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant