From ef79aa145926e9f798ea182a0e0db51baeb5709c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 09:01:00 +0000 Subject: [PATCH 1/4] test: agent pane checkpoints stay online after a later emit's reason Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-2f629b93d5a20c58e6f8 --- webviews/test/agent-pane-link-state.test.tsx | 138 +++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 webviews/test/agent-pane-link-state.test.tsx diff --git a/webviews/test/agent-pane-link-state.test.tsx b/webviews/test/agent-pane-link-state.test.tsx new file mode 100644 index 000000000000..3ddf5f69110f --- /dev/null +++ b/webviews/test/agent-pane-link-state.test.tsx @@ -0,0 +1,138 @@ +// l10n-allow-file: test fixture rows, not shipped UI. +// The pane's checkpoints read whether the link to acpmux is up from the snapshot's `connection`, +// which names why the client last emitted. A read of `=== "connected"` took every later emit +// ("session changed", a streamed event's kind) for a lost link and marked checkpoints unsupported. +import { afterAll, expect, test } from "bun:test"; +import { JSDOM, VirtualConsole } from "jsdom"; + +const dom = new JSDOM("
", { + url: "cmux-agent://pane/index.html", + pretendToBeVisual: true, + virtualConsole: new VirtualConsole(), +}); +const scope = globalThis as Record; +const replacements: Record = { + window: dom.window, + document: dom.window.document, + location: dom.window.location, + navigator: dom.window.navigator, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + Node: dom.window.Node, + Event: dom.window.Event, + KeyboardEvent: dom.window.KeyboardEvent, + MouseEvent: dom.window.MouseEvent, + customElements: dom.window.customElements, + MutationObserver: dom.window.MutationObserver, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + IntersectionObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + IS_REACT_ACT_ENVIRONMENT: true, +}; +for (const key of Object.getOwnPropertyNames(dom.window)) + if (/^(HTML|SVG|CSS|Shadow|Document|Mutation)/.test(key) && !(key in scope)) + replacements[key] = (dom.window as unknown as Record)[key]; +const saved = Object.keys(replacements).map((key) => [key, key in scope, scope[key]] as const); +Object.assign(scope, replacements); +(dom.window as unknown as { matchMedia: unknown }).matchMedia = replacements.matchMedia; +// jsdom has no layout. A tall viewport mounts every row of this small chat. +Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { configurable: true, get: () => 10_000 }); +Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", { configurable: true, get: () => 760 }); + +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const fixtures = await import("../src/gallery/fixtures/acpmux"); +const { AcpmuxApp } = await import("../src/agent-session/acpmux/App"); + +afterAll(() => { + dom.window.close(); + for (const [key, had, value] of saved) { + if (had) scope[key] = value; + else delete scope[key]; + } +}); + +type Snapshot = import("../src/agent-session/acpmux/model").AcpmuxSnapshot; +const window_ = dom.window as unknown as { + webkit: unknown; + cmuxAcpmuxBridge?: { receive(value: unknown): void }; +}; + +/// The pane on a local chat in a folder whose repository takes checkpoints. The app's bridge +/// answers `ready`, `git.capabilities` says checkpoints are served, and each +/// `pane.checkpointAvailability` the pane sends is recorded. +async function pane() { + const { assistant, chat, user } = fixtures; + const snapshot = chat([user("Fix the build", 3, { id: "u1" }), assistant("Fixed.", 2.8, { id: "r1" })]); + const availability: boolean[] = []; + window_.webkit = { + messageHandlers: { + agentSession: { + postMessage: (message: { method?: string; params?: Record }) => { + if (message.method === "ready") { + setTimeout(() => window_.cmuxAcpmuxBridge?.receive(structuredClone(snapshot)), 0); + return Promise.resolve({ + ok: true, + value: { protocolVersion: 1, transport: "preview", machineName: "Mac" }, + }); + } + if (message.method === "git.capabilities") return Promise.resolve({ ok: true, value: { checkpoints: true } }); + if (message.method === "pane.checkpointAvailability") availability.push(message.params?.available === true); + return Promise.resolve({ ok: true, value: null }); + }, + }, + }, + }; + const root = createRoot(dom.window.document.getElementById("root")!); + const settle = async () => { + for (let pass = 0; pass < 20; pass += 1) await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + }; + await act(async () => root.render()); + for (let tries = 0; tries < 500 && availability.at(-1) !== true; tries += 1) + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + /// The client's next emit for the same chat, for `reason` (snapshot `connection`). + const emit = async (reason: string) => { + await act(async () => + window_.cmuxAcpmuxBridge?.receive({ ...structuredClone(snapshot), connection: reason } as Snapshot), + ); + await settle(); + }; + return { root, availability, emit }; +} + +// `connection` is why the client emitted, not whether the link is up (newtab/chatStore.ts): after +// the attach's "connected" come "session changed" (#31983 sends it right after a local chat opens) +// and every streamed event's kind. None of them takes the link down. +for (const reason of ["session changed", "agent_message_chunk", "history"]) + test(`checkpoints stay available when the chat's last emit was "${reason}"`, async () => { + const view = await pane(); + try { + expect(view.availability.at(-1)).toBe(true); + await view.emit(reason); + expect(view.availability.at(-1)).toBe(true); + } finally { + await act(async () => view.root.unmount()); + } + }); + +test("checkpoints go offline when the link does", async () => { + const view = await pane(); + try { + expect(view.availability.at(-1)).toBe(true); + await view.emit("disconnected"); + expect(view.availability.at(-1)).toBe(false); + } finally { + await act(async () => view.root.unmount()); + } +}); From 0c7b68e062dba482b7de2abcaa4edfb904918349 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 09:02:53 +0000 Subject: [PATCH 2/4] Agent pane: checkpoints and cloud recovery read the link state, not the last emit's reason snapshot.connection names why the client emitted, not whether the link is up. Checkpoints and cloud chat recovery compared it with "connected", so any later emit ("session changed", a streamed event's kind) read as offline. Both now use one linkUp() helper, the derivation the footer's `connected` already used. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-2f629b93d5a20c58e6f8 --- webviews/src/agent-session/acpmux/App.tsx | 11 ++++++----- webviews/src/agent-session/acpmux/model.ts | 8 ++++++++ webviews/test/agent-pane-link-state.test.tsx | 8 ++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 0cf8464e3a2a..f164530cbbb8 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -65,6 +65,7 @@ import { isLineRow, isLiveStep, restRoom, + linkUp, } from "./model"; import { lacksImageBytes, ownQueue, queuedAttachments, queuedMessageRows } from "./conversation/queuedMessages"; import { isVisiblyEmptyMessage } from "./conversation/visibleMessage"; @@ -6166,6 +6167,9 @@ function AcpmuxPane() { const handoffLabels = useMemo(() => handoffStrings(t), [t]); const checkpointLabels = useMemo(() => checkpointStrings(t), [t]); const [checkpointVariant, setCheckpointVariant] = useState<"compact" | "expanded">("compact"); + // Footer actions and checkpoints show only while acpmux is reachable. The client reports failures + // in the transcript; a bridge that cannot route an action has nothing to add. + const connected = linkUp(snapshot.connection); const checkpoints = useCheckpoints({ request: callNative, target: snapshot.summary?.cwd @@ -6175,7 +6179,7 @@ function AcpmuxPane() { hostKind: snapshot.summary.hostKind, } : undefined, - online: snapshot.connection === "connected", + online: connected, strings: checkpointLabels, variant: checkpointVariant, }); @@ -6234,9 +6238,6 @@ function AcpmuxPane() { }; const revealRef = useRef(reveal); revealRef.current = reveal; - // Footer actions show only while acpmux is reachable. The client reports failures in the - // transcript; a bridge that cannot route an action has nothing to add. - const connected = snapshot.connection !== "disconnected" && !snapshot.connection.startsWith("connecting"); const forkable = canFork(snapshot); const forkSeq = latestForkSeq(snapshot.rows); const forkAny = forksAnyTurn(snapshot.summary?.family); @@ -8944,7 +8945,7 @@ function AcpmuxPane() { !!snapshot.unavailable, snapshot.sessionId, snapshot.rows.length, - snapshot.connection === "connected", + connected, freshView, ), recheck: recheckCloudChat, diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index 1b322654dcc2..3191f550c2c3 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -764,6 +764,14 @@ export type AcpmuxWorkflowWire = { * (`notListedAtOpen`), or it left the list while the pane showed it (`leftListWhileShown`). */ export type MissingSessionReason = "notListedAtOpen" | "leftListWhileShown"; +/// Whether the pane's link to acpmux is up. A snapshot's `connection` names why the client emitted +/// it ("connected", "session changed", a streamed event's kind, "failed" for a refused request on +/// an open socket), not the link's state: only a lost ("disconnected") or opening ("connecting…") +/// connection is down. Never compare it with "connected" to ask whether the link is up. +export function linkUp(connection: string): boolean { + return connection !== "disconnected" && !connection.startsWith("connecting"); +} + export type AcpmuxSnapshot = { type: "snapshot"; /** The selected chat's daemon-owned retry, including the last automatic attempt. */ diff --git a/webviews/test/agent-pane-link-state.test.tsx b/webviews/test/agent-pane-link-state.test.tsx index 3ddf5f69110f..d691be452953 100644 --- a/webviews/test/agent-pane-link-state.test.tsx +++ b/webviews/test/agent-pane-link-state.test.tsx @@ -136,3 +136,11 @@ test("checkpoints go offline when the link does", async () => { await act(async () => view.root.unmount()); } }); + +// Cloud chat recovery (App.tsx `cloudChatRecoveryActive`) and checkpoints read the same `linkUp`. +test("the link is up for every emit reason but a lost or opening connection", async () => { + const { linkUp } = await import("../src/agent-session/acpmux/model"); + for (const reason of ["connected", "session changed", "agent_message_chunk", "history", "failed"]) + expect(linkUp(reason)).toBe(true); + for (const reason of ["disconnected", "connecting", "connecting: socket closed"]) expect(linkUp(reason)).toBe(false); +}); From 39104fb4c0a4c878ab26f64305d6d5a5a497db2c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 09:17:02 +0000 Subject: [PATCH 3/4] test: a Cloud chat whose open failed on an open socket keeps its Resume card Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-2f629b93d5a20c58e6f8 --- webviews/test/agent-pane-link-state.test.tsx | 62 ++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/webviews/test/agent-pane-link-state.test.tsx b/webviews/test/agent-pane-link-state.test.tsx index d691be452953..ee4407f5354e 100644 --- a/webviews/test/agent-pane-link-state.test.tsx +++ b/webviews/test/agent-pane-link-state.test.tsx @@ -144,3 +144,65 @@ test("the link is up for every emit reason but a lost or opening connection", as expect(linkUp(reason)).toBe(true); for (const reason of ["disconnected", "connecting", "connecting: socket closed"]) expect(linkUp(reason)).toBe(false); }); + +// A Cloud chat whose open failed on a socket that stayed open (direct.ts emits "failed", with no +// rows and no `unavailable`: that is set only while no chat is selected) still reads its machine, +// and a paused machine shows the Resume card. "failed" is not a lost link, but the chat was never +// read, so cloud recovery treats it as one (#27031). +test("a Cloud chat whose open failed on an open socket shows its paused machine's Resume card", async () => { + const { chat } = fixtures; + const base = chat([]); + const snapshot: Snapshot = { + ...base, + connection: "failed", + summary: { ...base.summary!, hostKind: "cloud", turnCount: 3 }, + }; + const account = ["user", null]; + const reads: string[] = []; + window_.webkit = { + messageHandlers: { + agentSession: { + postMessage: (message: { method?: string }) => { + if (message.method === "ready") { + setTimeout(() => window_.cmuxAcpmuxBridge?.receive(structuredClone(snapshot)), 0); + return Promise.resolve({ + ok: true, + value: { + protocolVersion: 1, + transport: "preview", + machineName: "Demo machine", + cloudMachineID: "vm-demo", + cloudAccount: account, + }, + }); + } + if (message.method === "cloudMachine.recoveryList") { + reads.push(message.method); + return Promise.resolve({ + ok: true, + value: { + signedIn: true, + loaded: true, + complete: true, + account, + machines: [{ id: "vm-demo", name: "Demo machine", stage: "paused" }], + }, + }); + } + return Promise.resolve({ ok: true, value: null }); + }, + }, + }, + }; + const root = createRoot(dom.window.document.getElementById("root")!); + const resume = () => dom.window.document.querySelector('[data-action="resumeMachine"]'); + try { + await act(async () => root.render()); + for (let tries = 0; tries < 500 && !resume(); tries += 1) + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + expect(reads.length).toBeGreaterThan(0); + expect(resume()).not.toBeNull(); + } finally { + await act(async () => root.unmount()); + } +}); From 93ebdfe77670862d0151ca9bb74d1cebf028c570 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 09:24:12 +0000 Subject: [PATCH 4/4] Agent pane: a Cloud chat whose open failed on an open socket keeps cloud recovery direct.ts emits "failed" when an open fails while the socket stays open, with no rows and no `unavailable` (set only while no chat is selected). The link is up, but the chat was never read, so recovery reads its machine as before #32044's linkUp change; checkpoints keep plain linkUp. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-2f629b93d5a20c58e6f8 --- webviews/src/agent-session/acpmux/App.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index f164530cbbb8..c75f8ab15f12 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -8945,7 +8945,9 @@ function AcpmuxPane() { !!snapshot.unavailable, snapshot.sessionId, snapshot.rows.length, - connected, + // An open that failed on a socket still open (direct.ts emits "failed") read no chat: its + // machine may be paused, so recovery reads it as it does a lost link. + connected && snapshot.connection !== "failed", freshView, ), recheck: recheckCloudChat,