Skip to content

fix(web): budget subagent messages separately from top-level history - #1257

Open
hqhq1025 wants to merge 2 commits into
tiann:mainfrom
hqhq1025:fix/history-window-subagent-budget
Open

hqhq1025 wants to merge 2 commits into
tiann:mainfrom
hqhq1025:fix/history-window-subagent-budget

Conversation

@hqhq1025

Copy link
Copy Markdown
Collaborator

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 (sqlite3 on the hub's hapi.db):

messages share
sidechain (subagent) 2655 96%
top-level 90 4%
total 2745

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:

round scrollHeight request
1 2956 → 4209 beforeSeq=2508&limit=200
2 → 5272 beforeSeq=2308
3 5272 (unchanged) beforeSeq=2108, then nothing
4 5272 (stalled) —
5 → 2365 —
6 2365 —

Round 5 is the second half of the bug: loaded history is discarded, text length dropping 7356 → 3466. The scroll container itself is fine — scrollTop holds where you put it, and scrolling down to the bottom works.

Approach

Give sidechain messages their own budget, exactly the way AGENT_RUN_WINDOW_SIZE already fences off Codex agent-run messages. isSidechainMessage reads the same stored field normalizeAgent reads — 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:139 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 — inverting the bug rather than fixing it. trimSidechain drops 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:

round scrollHeight request
1 14412 → 15466 beforeSeq=2637
2 → 16719 beforeSeq=2437
3 → 17782 beforeSeq=2237
4 → 18887 beforeSeq=2037
5 — beforeSeq=1837

Five 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 removed
  • drops subagent messages that fall behind the oldest surviving top-level row — fails when the orphan boundary is removed
  • does not force a latest reset when only subagent messages overflow — drives the real syncTailMessages path; fails when the reset check is removed

31 passed in this file (28 before). bun run typecheck clean.

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.

@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.

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

Comment thread web/src/lib/message-window-store.ts Outdated
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[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.

Comment thread web/src/lib/message-window-store.ts Outdated
const inSpan = boundary === null
? sidechain
: sidechain.filter((message) => {
if (typeof message.seq !== 'number' || message.seq >= boundary) return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[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.
@hqhq1025

Copy link
Copy Markdown
Collaborator Author

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. sidechainOverflowed records that the window is short, and enterTailMode redeems it into requiresLatestReset — that being exactly the moment the reader chose to come back to the tail, so the refetch costs them nothing. activateMessageWindow's early-return had to learn about the flag too, or re-opening the session would skip the redemption.

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 tracer.ts:139 renders those at the top level.

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:

  • refetches the latest page when returning to the tail after a subagent overflow — history → overflow → tail, asserts the epoch is cleared
  • drops subagent messages left past the newest surviving top-level row — prepend overflow with the parent Task trimmed
  • handles a window that holds nothing but subagent messages — sidechain-only overflow

34 passed in this file. Full suite shows no change against baseline (same 128 environment-related failures before and after, +3 passing = the new tests). bun run typecheck clean.

@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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[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)) }

@heavygee heavygee added area:web Web PWA / React client bug Something isn't working community-pr PR from non-collaborator contributor labels Sep 4, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:web Web PWA / React client bug Something isn't working community-pr PR from non-collaborator contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants