Skip to content

tui: new background steps refill transcripts of already-evicted session families #56737

Description

@521ox

Title: tui: new background steps refill transcripts of already-evicted session families

Summary

After the real recent-three retention owner evicts a session family's heavy transcript cache, later background session.step.started and text events recreate and retain full assistant messages for that excluded family.

In a deterministic reproduction, an already-evicted running session accumulates 0 → 1 → 2 complete assistant messages without being revisited or explicitly resynchronized. A later metadata update recomputes retention, but the messages remain and its eviction count stays 1 → 1.

The concern is the mismatch between the existing family-retention policy and subsequent heavy-data admission. This is not an OOM, heap-size, throughput, or latency claim.

Environment

  • Windows; Bun 1.4.2.
  • Dynamically reproduced against a custom checkout at 72d0dce8ea437ea061e0680110fa19a81aa118fd.
  • Compared official references: 63c1a27af5a391d9caa55b82694f496911e1859b and latest-checked 60a5a6ca7ce176b04cd2321ffd1819b1ea8e6006.
  • The retention owner/caller, message mutation helpers, and step-start handler are identical across those revisions. The official revisions were source-compared, not dynamically executed.
  • The fixture uses real production createData and createSessionRetention, injected in-memory HTTP responses, Solid signals for navigation, and synthetic events. No TUI renderer, real service, database, model, or user session is accessed.

Reproduction

Use the fixture approach from packages/tui/test/context/session-retention.test.ts, mounting the production retention owner over production client data inside a Solid root.

  1. Configure current as a writable Solid signal, keep: () => [], and limit: 3. This corresponds to the existing tabs-disabled/no-kept-tabs path; do not change the policy to a smaller artificial budget.
  2. Wrap session.evict only to count calls, forwarding every call to the real data.session.evict implementation.
  3. For four fixture root sessions A, B, C, and D, sequentially:
    • Register valid metadata through data.session.remember.
    • Set current to that session.
    • Await data.session.message.sync, with the injected fetch returning one synthetic history message and an empty cursor.
  4. After initially viewing A, deliver a valid synthetic session.execution.started event for A. Keep it running while navigating to B, C, and D.
  5. After viewing D, verify that A's transcript is empty and B/C/D each retain their one history row. A remains a known running session, and A's eviction count is one.
  6. Send a session.text.delta for an assistant message missing from A's evicted transcript. Confirm that it does not restore a message.
  7. Send a subsequent complete step for background A through the normal event listener:
    • session.step.started with a new assistant-message ID, fixture agent/model, and start time;
    • session.text.started, session.text.delta, and session.text.ended for ordinal zero;
    • session.step.ended with ordinary finish/cost/token data.
  8. Observe one full assistant message, including its final text, in A's transcript. Repeat the sequence with a second assistant-message ID and observe two messages.
  9. Update A's metadata using data.session.remember with a newer time.updated, leaving its root, navigation state, and keep list unchanged. Both messages remain; A's eviction count remains one.

All HTTP responses are fixture-only. The only transcript requests are the four initial reads for A/B/C/D; no explicit read or navigation for A occurs after eviction. Events for A are delivered in order.

Expected Behavior

The existing retention owner should have a coherent contract with subsequent heavy-data admission: a family excluded from the retained set should not silently rebuild and retain a full transcript solely because background steps continue.

This expectation is grounded in the current implementation and tests, rather than an assumption that all inactive activity must stop:

  • packages/tui/src/context/session-tabs.tsx:139–145 explicitly retains open-tab families and three other recently viewed families.
  • packages/tui/src/context/session-retention.ts:26–39 computes retained/excluded families and calls the heavy-cache eviction operation for excluded members.
  • Existing retention tests cover eviction of unviewed families, newly discovered descendants, and hydration when an evicted session is revisited.
  • The message mutation code explicitly describes a missing streaming target as unloaded or evicted and drops edits to that target.

If retaining new full transcripts for every excluded running family is intentional, that exception needs an explicit policy and bounds; the observed behavior currently arises from step-start bypassing the one-time eviction decision.

Actual Behavior

Observation Result
A after recent-three eviction 0 transcript messages
A after a delta targeting a missing assistant 0 transcript messages
A after first later complete step 1 complete assistant message with text
A after second later complete step 2 complete assistant messages with text
A after metadata-triggered retention recomputation Both messages still retained
A eviction count before/after those events 1 / 1

The assertion requiring the excluded transcript to remain empty fails. This proves retention bypass and incremental message/text accumulation, not a measured memory-exhaustion threshold.

Additional Context

Source locations are repository-relative:

  • packages/tui/src/context/session-retention.ts:30–39: eviction is deduplicated using the previously excluded member/root map. An unchanged excluded member is not evicted again.
  • packages/client/src/solid/data.ts:453–469 in the tested checkout, corresponding to :449–465 in both official references: message updates lazily recreate a missing per-session message array and index.
  • packages/client/src/solid/data.ts:886–920 in the tested checkout, corresponding to :881–915 in both official references: a new step appends an assistant row; following text events populate it normally.

Controls in the same fixture:

  • Initial recent-three eviction works: A is empty while B/C/D retain their history.
  • An equivalent complete streamed step for current D retains the correct assistant text. Dropping all streaming events would therefore fail the control.
  • A delta for an absent assistant does not resurrect that assistant before a new step starts.
  • The final companion run recorded 3 passing controls and 3 failing product assertions, with this retention invariant accounting for one failure. Exit status was 1, not a passing product gate. All fixtures disposed with zero pending responses/listeners and zero captured request errors; no fallback fetch occurred.

Related context: #36185 concerns active Location scoping. This finding is about the interaction between session-family transcript retention and later live events; that related issue does not establish that this event-publication path is covered or fixed.

Suggested solution — not implemented

Maintainer decision requested: coordinate heavy-data admission with the existing retention owner so the selected policy remains true after eviction, not just at the moment of cleanup. This should be a focused change at the existing ownership boundaries, not a new generic GC framework.

Do not indiscriminately drop session metadata, activity/attention state, permission/form requests, or notification inputs: those have different consumers and lifetimes. Preserve current/active-view and open-tab protections, and explicitly decide how excluded but still-running families should be handled rather than silently changing that policy. Preserve optimistic submissions and the ability to rehydrate a transcript when a session is revisited.

Keep the current-session streaming control and the excluded-family background-step sequence as distinguishing acceptance cases. No implementation or production behavior change is included; the appropriate policy and mechanism remain maintainer decisions.

Activity

  1. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on the latest dev (60a5a6ca7) with a small test that uses the real createData and createSessionRetention, fake HTTP responses and made-up events.

    Steps:

    • View root sessions A, B, C and D in turn (no kept tabs, limit 3). A's transcript is evicted (0 messages, 1 eviction) and B/C/D keep their history.
    • Send two complete background steps for A (session.step.started, text started/delta/ended, session.step.ended).
    • Update A's metadata with a newer time.updated.

    Result:

    • After each step, A's transcript grows from 0 to 1 to 2 full assistant messages, text included.
    • The metadata update doesn't evict them again: A's eviction count stays at 1.

    The script below reproduces it. Run it from packages/tui with bun test. Its last assertion, that A's transcript stays empty, fails with 2 messages.

    Reproduction script (repro-56737.test.ts)
    // Reproduction for anomalyco/opencode#56737: run from packages/tui with `bun test test/context/repro-56737.test.ts`
    import { expect, test } from "bun:test"
    import type { OpenCodeEvent } from "@opencode/client"
    import { createData } from "@opencode/client/solid"
    import { createGlobalEmitter } from "@solid-primitives/event-bus"
    import { createRoot, createSignal } from "solid-js"
    import { createSessionRetention } from "../../src/context/session-retention"
    import { createApi, createFetch, directory, json } from "../fixture/tui-client"
    
    test("#56737 background steps refill an evicted family's transcript", async () => {
      await createRoot(async (dispose) => {
        const events = createGlobalEmitter<{ [T in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: T }> }>()
        const api = createApi(
          createFetch((url) => {
            if (url.pathname.endsWith("/message"))
              return json({ data: [{ id: "msg_hist", type: "user", text: "History", time: { created: 1 } }], cursor: {} })
            if (url.pathname.endsWith("/inbox")) return json({ data: [] })
            return undefined
          }).fetch,
        )
        const data = createData({ api: () => api, event: events, directory })
        const [current, setCurrent] = createSignal<string>()
        const evictions: string[] = []
        createSessionRetention({
          session: { ...data.session, evict: (id) => (evictions.push(id), data.session.evict(id)) },
          current,
          keep: () => [],
          limit: 3,
        })
        const remember = (id: string, updated = 0) =>
          data.session.remember({
            id,
            projectID: "project",
            location: { directory },
            cost: 0,
            tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
            time: { created: 0, updated },
          })
        let seq = 0
        const emit = (event: any) =>
          (events.emit as any)(event.type, { ...event, id: `evt_${++seq}`, created: seq, location: { directory } })
        const durable = () => ({ aggregateID: "A", seq, version: 1 })
        const step = (msg: string) => {
          emit({ type: "session.step.started", durable: durable(), data: { sessionID: "A", assistantMessageID: msg, started: seq, agent: "build", model: { providerID: "p", id: "m" } } })
          emit({ type: "session.text.started", durable: durable(), data: { sessionID: "A", assistantMessageID: msg, ordinal: 0 } })
          emit({ type: "session.text.delta", data: { sessionID: "A", assistantMessageID: msg, ordinal: 0, delta: `text of ${msg}` } })
          emit({ type: "session.text.ended", durable: durable(), data: { sessionID: "A", assistantMessageID: msg, ordinal: 0, text: `text of ${msg}` } })
          emit({ type: "session.step.ended", durable: durable(), data: { sessionID: "A", assistantMessageID: msg, finish: "stop", cost: 0, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } } })
        }
        try {
          for (const id of ["A", "B", "C", "D"]) {
            remember(id)
            setCurrent(id)
            await data.session.message.sync(id)
          }
          const count = () => data.session.message.list("A").length
          const log = (label: string) => console.log(label.padEnd(36), "A messages:", count(), "A evictions:", evictions.filter((x) => x === "A").length)
          log("after viewing A,B,C,D (A evicted)")
          expect(count()).toBe(0)
          expect(["B", "C", "D"].every((id) => data.session.message.list(id).length === 1)).toBe(true)
          step("msg_bg_1")
          log("after background step 1")
          step("msg_bg_2")
          log("after background step 2")
          remember("A", 100)
          log("after metadata update (recompute)")
          console.log("A transcript:", JSON.stringify(data.session.message.list("A").map((m: any) => ({ id: m.id, type: m.type, content: m.content }))))
          expect(count()).toBe(0) // fails: evicted family keeps accumulating messages
        } finally {
          dispose()
        }
      })
    })

    Run it: bun repro-56737.test.ts

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions