cmux NEXT: cmux://agent links carry a message, and a stray parameter never costs the session

Merged
TE
15d ago
+1255-41

Summary

Leo opened https://cmux.com/deeplink/agent?harness=claude&session=e8f63fd9-…&host=cosmic-gray&message=5cf944c9-… and the page threw the whole link away over &message=. #22940 (into main) fixes the page; this is the app side of the same link.

  • ?message=<harness message id> is a parameter of an agent link. The chat tab the link opens scrolls that message's row into view and marks it, the way a #turn-<id> link does and through the same reveal channel (message:<id>, the page's MESSAGE_REVEAL). The transcript replays when the tab opens, so the page waits for the row and then gives up quietly: a message this machine's transcript does not have, or a terminal tab, leaves the view exactly where it is. The id is the one acpmux reports on a message's chunks (agent_message_chunk.messageId), which is what the page already keys its rows by.
  • A parameter cmux cannot use never costs the link its target, and is no longer silent. DeepLink.read returns the names it ignored — a name no link of that kind reads, a repeated one (no single value, so neither side guesses), or a hint outside its charset — and link.open shows them in a toast. A malformed machine= on an agent link is one of those hints now instead of a refusal. The other kinds parse exactly as before, including machine= still refusing a session link.
  • One schema for the emitter and the parser. schemas/deeplinks/agent-link.json states the parameters, their charsets, what happens to an unknown one, and the vectors. AgentLinkSchemaTests runs them here; web/tests/deeplink-agent.test.ts runs the same file in #22940, which adds it byte-identically, so the base merge takes it cleanly. The schema's harness charset is this app's (1–64 lowercase), and #22940 widens the page to match.

An independent review of 107839f9 found two real defects and four smaller ones; 7960c943 fixes them:

  • It did not compile. The reveal called AgentTabs.messageReveal, but the type in AgentTabs.swift is AgentTabStore.
  • The reveal only worked near the tail. The transcript virtualizes its rows, so a message more than a viewport or two above the view had no element to scroll to, and the reveal gave up exactly as if the chat lacked the message — the opposite of why a message link is sent. It now asks the transcript for the row by its place in the chat, the way a running line's count already does (showMessage → revealRow), and marks the row once it is drawn.
  • Folding older history kept the pre-fold messageRows, so a link to a message from an older page found nothing; the fold's entries are kept now, under the live turn's.
  • ?machine with no value refused every other kind of link, which it never did before; and two machine= on an agent link are dropped by name like any other repeated parameter.
  • The ignored-parameter toast is for agent links only, and only after the link opened: nightly's stable_*_id pane links and a shared ?utm_source= were reporting parameters their kinds have always ignored, and a refusal replaced the notice a frame later.
  • The schema now says where a message id comes from and what a link naming anything else does (an assistant message's ACP agent_message_chunk.messageId; a prompt, a tool call, a Cloud row id or an id this transcript never saw opens the chat and leaves the view alone), carries machine, and states the order both sides write a query in and name ignored parameters in.

A second independent review, of 95d5c861, found one more real defect and two smaller ones; 484f7854, a6a868f8 and 6a89a84c answer them:

  • A message that worked was unreachable. A tool call ends the reply it interrupts, so one harness message is several rows and only a turn's last row stays outside its "Worked for" fold. The reveal asked for the message's first row, the fold hid it, the transcript drew no such row, and the link did nothing - the view stayed at the bottom, exactly as if this session had never seen the message. That is the ordinary shape of a reply that worked ("Let me check the tests.", Bash, "All green."). The row choice moved out of App.tsx, where nothing could test it, into messageRevealTarget: it takes a row the transcript already draws, and when the fold hides every one of them it names that fold so the pane opens it first - the four lines a running line's count already has for a shell's row. scrollToRow marks the copy an open fold draws of the row (<id>:fold), which is the same row to a reader.
  • The toast echoed the link's own text. The ignored names came straight from the URL, capped only at 200 characters in all, so cmux://agent/claude/<a real session>?<190 characters of sentence text>=1 opened the chat and then rendered attacker-chosen prose - newlines and writing-direction marks included - inside cmux's own toast chrome. It now shows them the way the page does, from the same schema: each name inside the charset a parameter name may have, 32 characters each, eight at most, and ? for a name with nothing printable left.
  • Known, not fixed here, and in Observed / Not run below: a message above the loaded history window still opens the chat and leaves the view alone, and a ?message= that resolves to nothing stays silent - which is what the schema says a link naming an id this transcript never saw does.

A third independent review, of 50c29b47, found two defects worth fixing and three smaller ones; 584d39ca answers them:

  • The toast could deny a parameter the link had used. The app took the page's display transform for an ignored name (cut to the charset a name may have) but not the page's collision guard, so cmux://agent/claude/<session>?host%20=x&host=big-red opened the chat host=big-red named and then said cmux "ignored parameters it doesn't use: host" — contradicting the link it had just followed. DeepLink.usedParameters names what the link carried, and the notice drops a name shown as one of those, which is what ignoredOrder says to do; with nothing left to name there is no toast at all.
  • The notice ignored the rule every refusal follows. It was shown unconditionally, so a script, an agent or cmux link open popped a HUD in whatever window the reader was in, where a refusal from the same run shows nothing (AppActionContext.showsNotice). It is now gated on the run being this reader's and on the caller not collecting what the run said.
  • A pending message reveal belongs to the view, not only to the chat: a reader who moves into a subagent within the five-second wait drops the row rather than scrolling to whatever carries its id there (a subagent's rows carry ids of the same shape).
  • AgentLinkSchemaTests gains two cases that pin machine ahead of the hints (?cwd=x&host=a&host=b&machine=nope → machine, host, cwd). Every Swift case before this had one ignored name, so the order the schema makes normative — and the web pins — could have moved on this side alone. They are Swift cases, not new vectors in the schema, so web/tests/deeplink-agent.test.ts does not run them; the shared vectors are unchanged.
  • parameters(of:) is read once per link instead of once per query item.

A fourth independent review of 584d39ca passed it, with four low findings; a236dda0 fixes the three that are defects:

  • The collision guard was half the page's. It covered the query parameters but not the path: cmux://agent/claude/s1?session=x (likewise ?session%20=x, ?harness=y) opened the chat s1 and then said cmux ignored session — the same contradiction the previous commit removed for host. The page never says it, because harness and session are parameters it carries values for. usedParameters now covers an agent link's pathParams.
  • A repeated stray name was said twice. The dedupe ran on the names the link wrote, where the page dedupes what it shows, so ?cwd%20=x&cwd%09=y printed "cwd, cwd" and two unprintable names printed "?, ?". It dedupes after the display transform now, and the eight-name cap still runs last.
  • The palette would have shown nothing. refusalHasCaller also covers a reporting run, and a reporting caller can only display a reason refuse() reported — this notice is not one. So gating on it left Open Link… from the palette silent about a stray parameter. It gates on isCapturingRefusal, the socket and CLI runs that collect what a run said; !background already covers scripts and agents.

Known and not fixed, both deliberate:

  • A pending reveal is dropped when the reader moves out of a subagent too, not only into one: the recorded view key is the subagent's. Keeping the row would mean scrolling to whatever carries its id after the view changed, and a subagent's rows carry ids of the chat's own shape, so the drop is the safe side of a five-second race. The reveal is a scroll, not a navigation; losing it costs the reader nothing they can't redo by clicking the link again.
  • DeepLinkNavigator.openAgent has no app-level test, so the reveal channel (AgentTabStore.messageReveal → links.ts MESSAGE_REVEAL) is pinned on the webviews side only. The pre-existing subagent: channel has the same shape, and a string-pin test would be the kind of unit test cmux-next asks lanes not to add; the honest fix is a boundary test of a real link in a built app, which needs a Mac.

Everything #22096 decided stays: a link opens only a tab this app already shows the session in, never a new tab, a resume or a connection, and host/name remain display hints. plans/cmux-next/links.json gains the message rows when the PR autofix regenerates it on a Mac (its inputs changed; no one hand-edits it).

Not in scope: cmux://agent links written inside Markdown or a chat reply still drop their query string (webviews/src/pages/markdown/links.ts, replyHref.ts allow no query), so a pasted message link opens the chat but not the message. Worth a follow-up.

Testing

On big-red (Linux): no Swift build here, so the Swift compile and tests are CI's.

  • Red, then green (the fold defect): 484f78542cfb8 commits the three new cases with the row choice extracted but still choosing as before - bun test test/agent-pane-reveal-message.test.ts in webviews/ is 9 pass, 3 fail (the fold is not named where it must open, and the folded row is taken over the drawn one). a6a868f8d68a3 is 12 pass, 0 fail, 36 expects; the cases shape their rows with the real turnView, so the fold is the one the transcript actually draws.

  • Red, then green: 0f60c962 commits the schema and both test files with no implementation — bun test test/agent-pane-reveal-message.test.ts in webviews/ fails with SyntaxError: Export named 'MESSAGE_REVEAL' not found, and AgentLinkSchemaTests does not compile (message, DeepLink.read do not exist). 107839f9 passes: 6 tests, 17 expects.

  • webviews/: bun run check on the head - clean, exit 0 ("All 2082 files are correctly formatted", "no warnings, lint errors, or type errors in 2015 files", layers, type scale, dialog keys, acpmux protocol); the three base warnings it used to carry are fixed on base. bun test test/agent-pane-reveal-message.test.ts test/transcript-long-chat-updates.test.tsx - 15 pass, 0 fail, 68 expects.

  • Two more base merges, 93fb7c08 and 558c5e95: feat-cmux-next reworked the chat's counted ref (it gained the Inspector, then was reformatted) right where the ?message= reveal reads it, so both resolutions keep base's ref and this branch's reveal beside it. bun run check is clean on each. Nothing else conflicted; handlers.refusal.linkParametersIgnored is still in all 21 locales after both.

  • cmux-ci compile-check --app --ref a236dda086caf0709f320368212e5da7c0afb2a4: passed (28 s) on the fleet, with the three review fixes in.

  • The base merge 50c29b47 is clean (no conflicts). It also cleared a base red my earlier head ran into: CmuxNextAppTests did not compile on base until 0f6199ce (TerminalFirstNewTabTests.swift:49 passed a String where #expect takes a Comment?), which is what failed the fleet Swift run of c3189dfb.

  • cmux-ci compile-check --app --ref 9f4b0bb0d7bb36c547b96e7eadfb4ddcf6d125f2: passed (28 s) on the fleet (the head after it, 6a89a84c, changes only #expect comment arguments in ToastCopyTests.swift). The earlier 7960c943 check: passed (45 s) on the fleet — the compile error the review found is gone. It compiles no tests, so the new test file's compile is CI's cmux-next swift test.

  • python3 scripts/cmux-next/prepush-gate.py: no new violations. ./scripts/lint-ios-package-conventions.sh: no unjustified violations.

  • A new test, theParsersCharsetsAreTheSchemas, runs 25 probes (65-character harness, 201-character message, empty, .., machine_ with uppercase hex, …) through both the schema's own expression and this app's validator and requires the same answer, so a bound cannot move on one side alone.

  • python3 scripts/localization_catalog.py check: the new handlers.refusal.linkParametersIgnored is in all 21 locales of Refusals.xcstrings and is not among the reported rows (the 41k parity errors are the base's needs_review debt, untouched here).

  • cmux-ci compile-check --app --ref 558c5e95fb280ffc5bf9bef7391d63172984560d: passed (33 s) on the fleet (job bd9625ed6659567c8dc8aa5b), on the head with both base merges in.

  • Review of the delta since the passing review (the three fixes plus both merge resolutions): PASS, safe to merge, no blockers. It hand-traced DeepLink.read for all three new assertions, decoded all 21 localizations of handlers.refusal.linkParametersIgnored to show neither negative assertion in twoStrayNamesShownTheSameAreSaidOnce can be produced in any locale, enumerated every setter of isCapturingRefusal and isReportingRefusal to confirm the HUD gate adds no HUD to a non-interactive path, checked the dedupe-then-cap order against origin/main's deeplink-agent.ts (the page does the same), and confirmed counted.current matches base byte for byte in both the ref and its assignment. Its two residual notes are informational: truncatedLinkText's 200-character cap can cut the worst-case 8x32-character toast (pre-existing, and the schema delegates the cap), and the agentPathKeys union is unconditional where the page suppresses on a usable value (equivalent for every reachable input, since .agent requires both path segments to validate).

  • A stale local src/agent-session/acpmux/generated/strings.json made bun run check report 66 bogus StringKey type errors after the first base merge; bun run strings regenerates it and the check is clean. No source defect, and nothing committed.

Observed / Not run

  • No capture: the native half adds no surface and changes no layout — a URL reader, one hop in DeepLinkNavigator, and one more sentence in the existing refusal HUD (its text is pinned in ToastCopyTests, its 21 locales in Refusals.xcstrings). What a reader sees is the agent pane scrolling to a message and marking it, which is webview, and pr_capture_check.py leaves webview changes to the gallery advisory run. Driving the real toast or scroll needs a Mac, and this lane runs on Linux.
  • Observed: the webviews reveal tests red then green, the full webviews check, the prepush gate, and the localization check.
  • Observed: the fold cases red (9/3) then green (12/0) on the same command, and ToastCopyTests.anIgnoredParameterNameCannotCarryTextIntoTheToast added for the sanitized names (CI's lane runs it; nothing Swift runs on this Linux host).
  • Not run here, CI's: the three review fixes each come with a Swift case — theNamesOfAnAgentLinksPathAreNeverSaidToBeIgnored (an agent link's ?session=x&harness=y&cwd=z is ignored as session, harness, cwd yet usedParameters covers the first two, and another kind's ?session=x is still a stray name) and twoStrayNamesShownTheSameAreSaidOnce (cwd and cwd\t say cwd once; two unprintable names say ? once). They compile on the fleet check above but run in cmux-next swift test, since no Swift runs on this host. The palette gate change has no test: what it turns on is a HUD in a built app.
  • Not covered: DeepLinkNavigator.openAgent itself has no test (an app-level test needs the App target's fakes, and nothing can be built here); the reveal it drives is covered in webviews, and the parser in AgentLinkSchemaTests.
  • Scope: the reveal looks in the transcript the pane has loaded. Attach pages in the chat's tail (direct.ts asks for 400 rows) and older history pages in on demand, so a link naming a message above the loaded window opens the chat and leaves the view where it is, like an id this machine never saw. The obvious follow-up is to page older history in while a message reveal is pending (loadOlder, which the foldOlder merge fix here makes safe).
  • Scope: nothing in the app writes a message link yet; the only producer is cmux.com (#22940), and the two agree on the id space through this schema rather than through an end-to-end test.
  • Not run: swift build / swift test and any Mac work (agents never build the app on Leo's Macs; this host is Linux) — so AgentLinkSchemaTests, DeepLinkAgentTests and LinkExportTests are CI's compile and test lanes. No dogfood run of the real toast or scroll in a built app.

Changelog

Added: cmux://agent links can name one message, and the chat scrolls to it
Fixed: a link with a parameter cmux doesn't use opens anyway and says what it ignored

Agent session: big-red

🤖 Generated with Claude Code


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


Summary by cubic

cmux://agent links can now carry ?message=<harness message id>, and the chat tab they open scrolls to and highlights that message. A parameter cmux can't use no longer drops the link: the link opens, and a toast names what was ignored.

  • The message parameter rides the same reveal channel as #turn-<id> links: the reveal finds its row by its place in the chat, waits for the transcript to draw it, and lets go after a moment, so it works however far up the chat the message sits and never fights a Find reveal or a later link. A row still waiting to be drawn is dropped if the pane switches chats, since row ids restart per session.
  • schemas/deeplinks/agent-link.json is the single schema for these links, shared with cmux.com's deeplink page so the emitter and the parser can't drift; tests hold every charset probe for probe and run the schema's vectors, including the order both sides write queries and name ignored parameters in.
  • On an agent link a malformed, repeated, or valueless machine= is ignored and named rather than refused; other link kinds keep their old reading, a valueless one refusing nothing. The ignored-parameter toast is agent links only, shown only after the link opens.

Testing updates

  • AgentLinkSchemaTests did not build: a nested try #require(URL(...)) (recursive macro expansion) and a String where #expect takes a Comment?. URLs are their own let now and the subject is interpolated, so cmux-next fast tier and cmux-next generated files are green again.
  • plans/cmux-next/links.json is regenerated (autofix script, not hand-edited) with the message parameter's example and schema pointer; the same run's other regenerated files carry only other lanes' drift and stay out of this branch.
  • The new webviews test file shifted CI's deterministic shards, moving composer-row-layout.test.ts onto one it timed out on; it now names its own 300 s time limit like its siblings.