cmux-next agent chat: a chat opened in a new tab is instant too, from a tail the app host keeps in memory

Merged
TE
15d ago
+1172-53

A chat is instant when it is cached — in a new tab too, not only in the tab that read it.

Leo, 2026-10-20: a chat "should be instant if cached". #22946 made that true inside a pane: the page keeps the tails of the chats it read (keptTails), and a switch back draws the chat on the frame it lands on while the attach refreshes it behind that (cached 69-111 ms against 258-289 ms cold). It left one gap, noted on that PR: each agent pane is its own page, so a chat opened in a new tab lands on a page whose map is empty and opens cold.

This closes it. Every page now hands its tails to the app host and asks the host for one before it attaches, so the tails outlive the page that read them.

In memory only

The host's store is AgentPaneTailCache: nothing is written to disk, so this adds no place a transcript persists, no setting to turn off, and no privacy or size policy to decide. Quitting the app forgets every tail; signing out clears them with the rest of the session's state. What it holds is bounded on both sides:

boundpagehost
chats8 most recently read (KEPT_TAILS)8 (AgentPaneTailCache.chats)
records per chat400 (KEPT_EVENTS)400 (AgentPaneTailCache.events)
bytes per chat256 KiB of the whole tail, newest records first (HOST_TAIL_BYTES)1 MiB of its compact UTF-8 (AgentPaneTailCache.bytes)

The page's bound is the number that describes real use, because the page has the strings; the host's is a backstop against a page that bounds wrongly or not at all. It is deliberately four times the page's rather than equal, because the two sides count differently — the page measures JSON.stringify().length in UTF-16 units and the host measures UTF-8 bytes, and one CJK character is 1 unit there and 3 bytes here. An equal number would silently refuse a correct page's tail for any chat not in English, and that chat would quietly open cold in the next tab.

A chat whose newest record alone is over the page's bound hands over nothing rather than make the bound one paste wide; it opens cold in the next tab, as it did before. A tail that arrives over either bound is refused whole, never cut: a cut tail that still called its history exhausted would hide the chat's older turns, which is exactly the bug test/reopened-chat-history.test.ts guards (#23064). A refusal also drops whatever was kept for that chat, because the page has read on since.

Both hosts serve it

New agent tabs are on the pane's own host unless the agent.pageHost Debug tunable is on, which it is not by default, so the two ops are served on both paths from the one store: AgentPaneBridge for the pane's own host and AgentPageProvider+Tail for the shared page host. A chat's tail is the chat's, not the pane's, so AgentPaneModel never sees it on either path — the same shape chat.scrollAnchor uses.

The page asks through the seam the pane's other host calls go through (window.cmuxAcpmuxActions, else the real host), and treats any refusal as "no tail kept": a host without these ops, an older app, the gallery, a page with no host at all. A cache never fails an open.

The read is started before the socket connects and awaited at the draw, so it overlaps the connect and the chat list and costs the open no round trip of its own.

The proof: red on the mechanism, green with it

webviews/test/agent-pane-new-tab-cached-chat.test.ts — nine tests at the client's own boundary, with a stand-in acpmux and a stand-in host that holds tails the way the Swift one does.

Two are the A/B: the same chat, the same daemon, one new tab with the host's tail and one without.

new tabdrawn with the attach still in flight
the host kept this chat's tailthe chat ("Retry added.")
the host kept nothingnothing, until acpmux answers

With readHostTail stubbed out to answer nothing — the mechanism removed, everything else the same — the tests that describe the cached path fail and the two that describe the cold path still pass.

The rest hold what could go wrong quietly: the tail is bounded in bytes before it crosses and a tail the bound cut still offers the turns it cut (canLoadOlder), a chat whose attach fails is dropped here and in the host so no later tab draws it, a host answering anything but a page tail leaves the chat to open cold, and the chat on screen is handed over when a tab opens it elsewhere or closes on it.

What the reviews changed

The first review found one red of this change's own and three soundness gaps, all fixed in 8cdac6e2eebe1: the "no page" reject message carries its l10n-allow reason; readHostTail races HOST_TAIL_READ_MS (250 ms), so a cache is never why an open waits; the connect path re-checks the selection after its await, as select does; and two the review called free — a record dropped by the read-side filter clears historyExhausted, and a tail whose newest record alone passes the byte bound forgets the host's older tail. It also found the store had no Swift test, so ChatTailCacheTests now covers the hand-off between two pages, the LRU and its recency, clear() for sign-out, both hosts over the one store, a request with no chat, and the record cap refused whole.

The second review was BLOCKING, and it was right on all three. Each fix is held by a test proven red without it:

  1. The feature did not work for the flow it is named for. rememberCurrentTail had exactly one caller: the top of select(), on the chat being left. Opening the chat on screen in a new tab keeps it selected, so nothing handed it over and the new tab opened cold — the one flow this cache exists for. My own suite missed it because its fixture has to select(b) for a tail to exist at all. The chat on screen is now handed over by both chat.openTab actions, by close() and on pagehide.
  2. The byte bound was not the bound two doc comments promised. writeHostTail bounded tail.events and then spread ...tail, so queue crossed uncounted — and a queued prompt carries its whole text. The Swift side checked only the record count. A reader who queued a pasted log handed over megabytes that neither side counted. The page now counts the rest of the tail before bounding records, and the host enforces its own byte bound.
  3. The bridge call site was uncovered — the same defect as #23232 in a second place. WKScriptMessage has no public initializer, so no test can drive userContentController: deleting those five lines left every suite green while every new tab went cold, on the host agent.pageHost: false makes production use. My own fix for (1) had the same hole one layer up, since a test calling handOverCurrentTail() proves the client and not that the product asks. Both call sites are now read out of the source, and each guard names what to rewrite it to if the symbol is renamed.

Then CI caught a regression my fix for (1) introduced, and it is fixed here. The hand-over on close wrote this page's own tail map as well as the host's. The page is the one going away, so that entry serves nobody — but a later client in the same page reads it and draws a chat it never attached to. agent-pane-turn-tokens showed 225,000 tokens for a chat whose family reports none, and failedPromptRetry drew two bubbles for one refused prompt. Both suites were green on the merge base and red at d0f99de4f33f; they pass at this head. rememberTail now takes forThisPage, false from close and pagehide, so the host's copy — the one a later page reads — is what survives. Those two suites catch this by accident, so a test holds it on purpose: "a closed client leaves its tail with the host alone, not in its own page" takes the host away and holds the chat to opening cold. It fails without the fix.

The third review passed and found three things in the delta; two are fixed here and one is disclosed. Both hosts now branch on AgentPaneTailCache.handles(method:), which names the two ops rather than matching the chat.tail prefix: reply(to:params:) takes anything that is not .get as a write, so a later chat.tail.forget caught by a prefix would have erased that chat's tail instead of being refused, and on the pane's own host a prefix would swallow it before AgentPaneRequest could see it. The source guard reads that call and not the type's name, because a prefix match mentions the type too — it passed on a mutation that reverted the branch. A late host read can no longer put back a tail forgetTail dropped, and its cleanup clears only its own entry. rememberTail's note no longer promises the next client in the page "the same tail": it is at most that, because the byte bound may have cut it and the host's chat cap is shared by every page where the page's own is not.

Disclosed, not fixed: CloudService.swift grows by two lines — the sign-out clear and the import it needs — on a file already over the god-file ceiling (433 to 435 lines, limit 400). check-no-godfiles.sh reports it as a warning, not a red; splitting a file this PR touches for one statement is not this PR's business.

Changelog

Changed: a chat you have opened before draws at once in a new tab, not only in the tab that read it.

Observed / Not run

Observed, on big-red at this head: test/agent-pane-new-tab-cached-chat.test.ts 9 pass / 0 fail; the two suites the regression broke green again (agent-pane-turn-tokens 13, failedPromptRetry 4), after being red at d0f99de4f33f in CI and green on the merge base 5f84bd023b11 when I ran them there. The new page-map test fails with the fix reverted, with the message it exists for. webviews bun run check: the whole chain passes, including check:browser-lanes — the base red that forced a skip reason on the previous push is fixed on base by #23325, so this push needed none. vp check: 2155 files formatted, no type or lint errors in 2087. prepush-gate.py: no new violations. ChatTailCacheTests + ChatScrollRestoreDefaultPathTests on the fleet at this head (b954f8389350d, step tailcache-green-8): both suites passed. They passed the same way at the pre-merge head 200de5c49ea01 (step tailcache-green-7); this head is that one with the base merged in, and the merge touched Swift (the AgentPageOps.methods op list), so it got its own compile rather than inheriting that one. The lane before it did not compile, on a base defect this PR does not touch — CmuxNextActionsTests/PaletteMenuVocabularyTests.swift:74 wrote #expect over a throwing key path, which fails the whole package's test build — already fixed on base and merged in here.

Base findings on this head, not this PR's: cmux-next checks is red only on its Companion workflows step, red the same way on base pushes; agent-pane-scrollbar-full-height.test.ts:375 (Expected <= 1, received 20) is red on #23335, #23336, #23339 and #23343 at the same line — four unrelated PRs.

Not run by me: the Swift compile and the macOS suites beyond the fleet lane above — no Mac build runs on this host. The browser perf sampler (chat-open-perf.test.ts) measures the cached open inside one page and is not extended here; a new tab's timing would need a second page sharing a host stand-in, which I did not build. The claim this PR makes about timing is the frame-level one the A/B above shows: the chat is drawn before the attach answers.

No capture: nothing here draws differently. The native files are an in-memory tail store, its two host ops and a sign-out clear, and none of them paints. The one visible difference is in the first frames of a chat opened in a new tab, where the loading state gives way to the chat before the attach answers — a scene still shows that only if it lands on exactly that frame, so what stands behind the claim is the frame-level test above: the page draws the kept tail with the attach still pending, and fails with the fix reverted.

🤖 Generated with Claude Code

Agent session: big-red


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

A chat you've opened before now draws at once in a new tab, not only in the tab that read it: every agent page hands the tails of the chats it read to the app host and asks for one before attaching, so a new tab draws the chat while its own attach still fetches beneath it.

Tails live in memory only — no disk writes, no new setting — and quitting or signing out forgets them. Both hosts serve the two named ops (chat.tail, chat.tail.get) from one store, and a chat's tail never reaches the pane model. The store is bounded per chat (8 chats, 400 records, 1 MiB host-side / 256 KiB page-side, counted in UTF-8), and a tail over a bound is refused whole so a cut transcript never hides older turns. The host read races a 250 ms bound so a stuck or absent host never holds an open, and the selection is re-checked once it answers. A missing or unwilling host leaves the chat to open cold, as before.

Review fixes, each held by a test that fails without it:

  • Only a switched-off chat reached the host before; the chat on screen is now handed over before chat.openTab, on close, and on pagehide.
  • The byte bound covers the whole tail on both sides, including queued prompts, and both hosts branch on the named ops rather than the chat.tail prefix.
  • A closing page's tail goes to the host alone rather than into its own page map — a stale map entry made the next client in that page draw rows it never attached to, a regression CI caught in agent-pane-turn-tokens and failedPromptRetry. A late host read cannot restore a tail forgetTail dropped.

Tests

  • ChatTailCacheTests covers the store itself: the page-to-page hand-off, the LRU and its recency, sign-out clearing, both hosts over the one store, the 400-record cap refused whole, and the named ops.
  • Five client boundary tests cover the new-tab draw vs the cold path, the byte bound, attach failures dropping a tail here and in the host, and defensive host reads.