Repository navigation
Conversation
The message window counts raw DecryptedMessages, but the timeline renders folded blocks. A subagent run emits one message per step and renders as a single Task card, so under one shared budget a single run evicts the top-level history the reader is trying to scroll back through. Measured on a real session: 2745 messages, 2655 of them sidechain (96%), 90 top-level. The 800-message budget held ~43 top-level rows, so scrolling up stalled after three pages and the loaded history was then discarded. Give sidechain messages their own budget, the same way Codex agent-run messages already have one. Two consequences to keep straight: - They must not outlive the Task card that owns them. tracer.ts renders a parentless sidechain message at the top level, so a transcript whose tool_use was trimmed away would spill into the timeline as loose rows. Anything older than the oldest surviving top-level row is dropped with it. - Trimming them must not arm requiresLatestReset. That flag means "the window no longer holds the live tail", and trimming a subagent against its own budget always drops the newest of *them* — arming it there refetches the latest page and yanks the reader back to the tail they deliberately scrolled away from.
There was a problem hiding this comment.
Findings
- [Major] Sidechain-only tail truncation has no recoverable state — the new exemption either suppresses the reset permanently or, with no regular rows, still triggers it immediately. Evidence:
web/src/lib/message-window-store.ts:594. - [Major] Prepend trimming can retain sidechain rows after their owning Task row is dropped — the orphan boundary only handles the old edge of the window. Evidence:
web/src/lib/message-window-store.ts:512.
Summary
- Review mode: initial
- Two correctness regressions found in the new sidechain budgeting path. Both affect long histories: one can leave the live Task card permanently incomplete or discard history; the other can retain/render sidechain content without its parent Task.
Testing
- Not run (automation); PR-provided code was not executed.
- Missing coverage: sidechain-only overflow, history-to-tail recovery after sidechain overflow, and prepend overflow where the parent Task is trimmed.
HAPI Bot
| const newest = derivePosition(kept, 'newest') | ||
| const keptTailNewest = derivePosition(kept.filter((message) => !isSidechainMessage(message)), 'newest') | ||
| const mergedTailNewest = derivePosition(merged.filter((message) => !isSidechainMessage(message)), 'newest') | ||
| if (keptTailNewest && mergedTailNewest && comparePosition(keptTailNewest, mergedTailNewest) >= 0) { |
There was a problem hiding this comment.
[MAJOR] Preserve a recoverable tail after sidechain-only truncation
In prepend mode, sliceForTrim removes the newest sidechain rows. This branch returns next while leaving newestPosition* at the snapshot head, even though those rows are no longer in messages. When the user returns to tail mode, enterTailMode only forces a latest fetch for requiresLatestReset (web/src/lib/message-window-store.ts:824), so the subsequent incremental request starts after the unchanged cursor and can never restore the dropped Task-card rows. The opposite edge also fails: if the window contains only sidechain rows, both derived positions are null and this condition falls through to the immediate reset this change is trying to avoid.
Track sidechain-tail truncation separately, and decide the immediate reset from the actual dropped rows rather than nullable positions.
Suggested fix:
const droppedRegularTail = dropped.some((message) => !isSidechainMessage(message))
const droppedSidechainTail = dropped.some(isSidechainMessage)
if (!droppedRegularTail) {
return buildState(next, {
requiresSidechainLatestReset:
previous.requiresSidechainLatestReset || droppedSidechainTail
})
}Then include requiresSidechainLatestReset in forceLatest inside enterTailMode, and clear it after applyLatestResponse. Add tests for a sidechain-only window and for history → tail after overflowing SIDECHAIN_WINDOW_SIZE.
| const inSpan = boundary === null | ||
| ? sidechain | ||
| : sidechain.filter((message) => { | ||
| if (typeof message.seq !== 'number' || message.seq >= boundary) return true |
There was a problem hiding this comment.
[MAJOR] Drop sidechains whose Task parent was trimmed from the newest edge
This boundary only removes sidechain rows older than the oldest kept regular row. During prepend trimming, however, regularTrim drops the newest regular rows. A Task tool_use can therefore be dropped while its later sidechain rows all satisfy message.seq >= boundary and remain under the separate 4000-row cap. tracer.ts:69-99 can only resolve parentToolUseId against Task calls still present in the window; unresolved parentless rows then fall through to top-level rendering at tracer.ts:139 (or remain orphaned when they have a missing parentUuid).
Filter direct-parent sidechains against the Task tool-use IDs represented by keptRegular; keep the sequence fallback only for legacy rows without parentToolUseId.
Suggested fix:
const keptTaskToolUseIds = collectSubagentToolUseIds(keptRegular)
const owned = inSpan.filter((message) => {
const parentId = sidechainParentToolUseId(message)
return parentId === null || keptTaskToolUseIds.has(parentId)
})
const trimmed = sliceForTrim(owned, SIDECHAIN_WINDOW_SIZE, mode)Add a prepend test where enough older regular rows evict the Task parent and assert that its sidechain rows are evicted too.
Addresses both findings from the review of the sidechain budget. Suppressing requiresLatestReset kept the reader in place but left no way back: the window really is short of the transcript afterwards, and nothing ever refetched it, so a Task card could stay permanently incomplete. The suppression is now a deferral — `sidechainOverflowed` records the debt and `enterTailMode` redeems it, which is exactly the moment the reader has chosen to come back to the tail. The reset condition itself is now read off the dropped set (did any top-level row leave?) rather than compared positions, so a window holding only sidechain rows behaves consistently instead of resetting on every overflow. The orphan boundary only guarded the old edge of the window. Prepend drops the *newest* top-level rows, so a subagent that started after the last survivor also loses its parent, and tracer.ts renders parentless sidechain messages at the top level. Both edges are now enforced, with the new edge applied only under prepend — under append a subagent running right now legitimately sits past every top-level row, and cutting there would empty the live Task card.
|
Both findings were real. Fixed in 6957978. Sidechain-only tail truncation has no recoverable state — correct, and the second half of that is the more serious one: suppressing the reset kept the reader in place but left nothing to ever refetch the missing transcript, so a Task card could stay permanently incomplete. The suppression is now a deferral. The reset condition itself was also the wrong shape. Comparing newest positions meant "no top-level rows at all" fell through to a reset on every overflow. It now reads the dropped set directly — did any top-level row leave? — which answers the question the flag actually asks and behaves consistently for a sidechain-only window. Prepend trimming can retain sidechain rows after their owning Task row is dropped — also correct, I only guarded the old edge. Prepend drops the newest top-level rows, so a subagent that started after the last survivor has lost its parent too, and Both edges are now enforced, with the new edge applied only under prepend. Under append it would be actively wrong: a subagent running right now legitimately sits past every top-level row, and cutting there would empty the live Task card. Coverage for the three gaps called out, each verified to fail when its own logic is disabled:
|
There was a problem hiding this comment.
Findings
- [Major] Deferred sidechain recovery is lost across sessionStorage hydration; see inline comment.
- [Major] The prepend high-sequence cutoff drops sidechain children whose Task parent is still retained; see inline comment.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Two correctness issues remain in the revised overflow handling. Both can leave Task-card history incomplete; one survives page reload with no recovery path.
Testing
- Not run (automation); PR-provided code was not executed.
- Missing coverage: persistence/reload after sidechain overflow, and a prepend overflow where the newest retained regular row is the owning Task.
HAPI Bot
| requiresLatestReset: boolean | ||
| // A subagent transcript overflowed its budget while the reader was in | ||
| // history mode. Deferred rather than acted on: see mergeIntoWindow. | ||
| sidechainOverflowed: boolean |
There was a problem hiding this comment.
[MAJOR] Persist the deferred reset across hydration
sidechainOverflowed is now the only signal that makes enterTailMode invalidate the cached tail cursor after a sidechain-only prepend trim. However, PersistedMessageWindowState/persistState do not store this flag, and hydrateState starts it as false while accepting the persisted epoch and newestPosition* as valid. If the page reloads after the overflow but before returning to the tail, the app hydrates the incomplete transcript and resumes incrementally after the old snapshot head, so the dropped sidechain rows can never be fetched.
Suggested fix:
type PersistedMessageWindowState = {
// existing fields...
sidechainOverflowed: boolean
}
// persistState
sidechainOverflowed: state.sidechainOverflowed,
// hydrateState/buildState
sidechainOverflowed: parsed.sidechainOverflowed === true,Add a hydration test that persists an overflowed history window, clears the in-memory state, reactivates it, and verifies the next sync requests the latest page rather than using the persisted after cursor.
| orphaned.push(message) | ||
| return false | ||
| } | ||
| if (high !== null && message.seq > high) { |
There was a problem hiding this comment.
[MAJOR] Keep children of a retained Task parent
During prepend, high is the sequence of the newest retained regular row. A Task/Agent tool_use can be that row, while all of its sidechain messages naturally have later sequence numbers; this condition drops those children even though their owning Task is still present. Those children are user-visible in the Task summary and trace (web/src/components/ToolCard/ToolCard.tsx:216, web/src/components/ToolCard/trace.tsx:54), so the retained card becomes incomplete while reading history. The new test only places sidechains after unrelated regular rows and does not cover a retained owning Task.
Use parentToolUseId ownership for current messages; keep sequence bounds only as a fallback for legacy rows without that field. Nested Task calls require pruning until no child references a removed parent:
const candidates = sidechain.filter((message) => {
if (sidechainParentToolUseId(message) !== null) return true
if (typeof message.seq !== 'number') return true
return (low === null || message.seq >= low)
&& (high === null || message.seq <= high)
})
let kept = sliceForTrim(candidates, SIDECHAIN_WINDOW_SIZE, mode).kept
while (true) {
const parentIds = collectSubagentToolUseIds([...regularTrim.kept, ...kept])
const next = kept.filter((message) => {
const parentId = sidechainParentToolUseId(message)
return parentId === null || parentIds.has(parentId)
})
if (next.length === kept.length) break
kept = next
}
return { kept, dropped: sidechain.filter((message) => !kept.includes(message)) }
Problem
Scrolling up through a long session stalls after a few pages, and the history that did load is then thrown away.
The message window budgets raw
DecryptedMessages, but the timeline renders folded blocks. A subagent run emits one message per step and renders as a single Task card, so the two units disagree by orders of magnitude — and the budget is spent in the units the reader never sees.Measured against a real session (
sqlite3on the hub'shapi.db):The 800-message budget bought roughly 43 top-level rows. The other ~1900 messages were unreachable — not because the session was too long, but because one subagent run can spend the entire budget on rows that collapse into a single card.
The distribution across sessions is wide enough that no single total works: 0%, 11%, 26%, 50%, 69%, 96% sidechain in the six largest sessions on this hub.
Reproduction
Driving the real UI with Playwright, scrolling to the top and waiting 3.5s per round:
scrollHeightbeforeSeq=2508&limit=200beforeSeq=2308beforeSeq=2108, then nothingRound 5 is the second half of the bug: loaded history is discarded, text length dropping 7356 → 3466. The scroll container itself is fine —
scrollTopholds where you put it, and scrolling down to the bottom works.Approach
Give sidechain messages their own budget, exactly the way
AGENT_RUN_WINDOW_SIZEalready fences off Codex agent-run messages.isSidechainMessagereads the same stored fieldnormalizeAgentreads — deliberately a per-message property, not the reducer's grouping, which is stateful and belongs in the reducer.Two consequences that don't fall out for free:
Sidechain messages must not outlive their Task card.
tracer.ts:139renders a parentless sidechain message at the top level, so a transcript whosetool_usewas trimmed away would spill into the timeline as loose rows — inverting the bug rather than fixing it.trimSidechaindrops anything older than the oldest surviving top-level row, keeping every retained sidechain message inside the span its parent can still occupy.Trimming them must not arm
requiresLatestReset. That flag means "the window no longer holds the live tail", and the next tail sync acts on it by refetching the latest page and replacing the window — which is precisely the discard observed in round 5 above. Trimming a subagent against its own budget always drops the newest of them, so the reset check now compares the newest non-sidechain message before and after the trim.Result
Same probe, after:
scrollHeightbeforeSeq=2637beforeSeq=2437beforeSeq=2237beforeSeq=2037beforeSeq=1837Five pages instead of three, monotonically growing, no discard.
Testing
Three tests in
message-window-store.test.ts, each verified to fail when its own half of the change is disabled:protects top-level rows from a subagent flood— fails when the sidechain bucket is removeddrops subagent messages that fall behind the oldest surviving top-level row— fails when the orphan boundary is removeddoes not force a latest reset when only subagent messages overflow— drives the realsyncTailMessagespath; fails when the reset check is removed31 passedin this file (28 before).bun run typecheckclean.