feat(agent-chat): expose earlier prompt index for thread rail

TE
6d ago
+1440-39

HOLD: base red web / react-apps-check hides webview test signal on feat-cmux-next (owner nx-ci); this PR touches webviews/ and lands once that check is green again.

Summary

PR A for the split rebuild of #25160. This lands the prompt-level history index and paging contract only; it has no rail consumer yet, so it changes nothing visible until the stacked rail PR lands.

"if you haven't loaded in chats ... the session rails ... should be able to still, like, gradually load that stuff in"

This reads the selected chat's _acpmux/events records that can start prompts (user_message, with turn_started, queue and turn records used to match the transcript's turn-start semantics: a turn the agent carries on opens with turn_started alone, and a prompt sent now into it goes on with it), paging backward 400 records at a time and pacing each page from idle work. The index has its own cursor and store. Index-only pages never advance the loaded transcript's firstSeq/historyFrom, fold transcript rows, or remount them.

The public contract is:

client.earlier: EarlierPromptsStore = {
  subscribe(listener: () => void): () => void,
  get(): readonly AcpmuxRow[],
}
client.loadThrough(seq: number): Promise<void>

Reading starts only once something subscribes to client.earlier (the rail, in #25160). Attach records where the index would start (below the attach page's oldest record); a pane with no rail listening pays no paging and holds no index (the last unsubscribe stops reading and drops it), so landing this PR alone changes neither the loaded transcript's behavior nor its cost. Test: a pane with no rail listening reads no earlier prompts.

loadThrough delegates to the existing loadOlder path, retaining its in-flight, selection-generation, history-epoch, and exhaustion guards. It does not write scrollTop; preserveScrollAnchorTop remains the only scroll correction. Stale index responses become inert after chat selection changes or disposal.

Coordination

nx-chat acknowledged ACK-nx-history-contract-1027: this PR owns the prompt index, while nx-chat owns logical/physical history coordinates and the existing row-id prepend anchor. Their historyCoordinates work consumes event sequence and page geometry separately.

Tests

  • thread-rail-earlier-prompts.test.ts: 4,000-prompt paging, stable rows, prompt extraction, failed-read stop, stale in-flight disposal/chat switch, index-only cursor/row identity invariants, loadThrough materialization/no-scroll invariant, continuation across maxBytes page boundaries, and final pending-batch publication after a failed read.
  • agent-pane-history-paging.test.ts existing browser-lane history paging coverage is retained.
  • bun test test/thread-rail-earlier-prompts.test.ts test/agent-pane-history-paging.test.ts — 19 pass, 1 browser-lane skip outside CI/Freestyle.
  • Targeted vp fmt and git diff --check pass.

Failed-result delta

A failed turn_result now clears continued, including UNKNOWN boundary state. The next prompt is therefore a definite new turn even if older paging fails or reaches the 200-page cap. Both stop-path regressions fail on 1c1fd5d4 and pass after the fix; each asserts the prompt is visible and published exactly once. All 19 index tests, including the 235 generated splits and cap-cost tests, pass.

Boundary state, publication, and cost

The retained window starts with unknown open, interrupting, and continued fields until the server reports exhaustion (or seq 1 is reached). Reaching the 200-page cap, a no-progress page, or a failed read does not invent a closed boundary. A prompt whose classification still depends on older records stays out of the visible index until resolved.

"This prompt or nothing" ({ maybe }) only lives while the boundary is unknown: when an older page resolves it (say, to idle), the newer page is evaluated again from the resolved input, and the maybe is replaced by what that input says. a prompt sent now while idle stays on the rail once older pages say no turn ran checks this at every page split around queue_order / user_message (acpmux promotes a queued prompt with no turn running, queue_edit.rs).

Each record caches its incoming state. Older context can only refine one of the three fields from unknown to known; replay starts at the changed boundary and stops on matching input/output state. Once outgoing state is fixed, transition records behind it are dropped. Unresolved records retain only seq/kind/direction and the four transition fields; queue payloads and unrelated metadata are discarded. Prompt records still needed for display or late-sender refresh are retained separately.

Publication tracks the last published version, independently of the row count. Equal-count dirtiness flushes at the end of a page; ordinary growth remains batched. Failure and other terminal stops flush any unpublished version, while chat switch/disposal invalidates responses and clears state without publishing stale rows. There is at most one publication per page, and terminal flushes do not duplicate an already published version.

Both new regressions fail on 8885fc97: the cap no longer shows an unresolved continuation, and the equal-count replacement becomes observable before the next page/read failure. The original boundary regressions and all 235 generated page-size/byte-budget comparisons still pass.

Cost at the 200 × 400-record cap

The worst-case transition bound is 6N = 480,000, down from about 8 million: one initial evaluation and at most five incoming-field refinements per record (open once; each prompt field from unknown to "this prompt or nothing" to known). A prompt sent now at the unread boundary is that prompt or nothing (no turn running interrupts nothing), so its later removal or refusal, or a prompt with another id, settles the next prompt without older history. No transition work runs on streamed tokens.

cap cost stays linear and releases payloads after state becomes known measures 80,000 rowless records carrying 19,350,000 serialized input bytes:

cap fixturetransitionsretained transition payload bytesretained transition records
Older history still unknown at cap80,0005,749,79980,000
Exhaustion resolves the boundary on page 200159,60000

5,749,799 bytes is the worst measured retained transition payload in this harness, a 70.3% reduction from its raw input. It is serialized payload, not a heap measurement: JS objects/state snapshots and displayed/refreshable prompt content are additional. There is no universal 5.75 MB total-memory guarantee: arbitrary prompt text and prompt-ID lengths remain payload dependent. The prior ~19 MB raw paging payload is no longer retained wholesale merely to replay transitions.

Timers and stop conditions

  • Nothing is read until earlier.subscribe has a listener.
  • One idle callback schedules at most one 400-record page.
  • A chat switch or dispose cancels the idle callback and invalidates the response generation.
  • A failed read flushes the final pending batch before stopping; empty page, repeated cursor, exhausted page, or the 200-page safety bound also stops and flushes.
  • A chat switch or dispose clears the index without publishing stale rows.
  • loadThrough stops when the requested row is materialized, history is exhausted, the cursor cannot advance, or the session becomes stale.

Screenshots

None apply: this PR draws nothing, and there is no after image to view. No component reads client.earlier or calls loadThrough, and the index reads nothing until one subscribes, so the pane renders byte-for-byte as on base. The rail's before/after belongs to #25160, its consumer.

Changelog

No visible change in this PR; the consumer is in stacked PR #25160.

Agent session: agent session