Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
isLineRow,
isLiveStep,
restRoom,
linkUp,
} from "./model";
import { lacksImageBytes, ownQueue, queuedAttachments, queuedMessageRows } from "./conversation/queuedMessages";
import { isVisiblyEmptyMessage } from "./conversation/visibleMessage";
Expand Down Expand Up @@ -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
Expand All @@ -6175,7 +6179,7 @@ function AcpmuxPane() {
hostKind: snapshot.summary.hostKind,
}
: undefined,
online: snapshot.connection === "connected",
online: connected,
strings: checkpointLabels,
variant: checkpointVariant,
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -8944,7 +8945,9 @@ function AcpmuxPane() {
!!snapshot.unavailable,
snapshot.sessionId,
snapshot.rows.length,
snapshot.connection === "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,
Expand Down
8 changes: 8 additions & 0 deletions webviews/src/agent-session/acpmux/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
208 changes: 208 additions & 0 deletions webviews/test/agent-pane-link-state.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// 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("<!doctype html><div id=root></div>", {
url: "cmux-agent://pane/index.html",
pretendToBeVisual: true,
virtualConsole: new VirtualConsole(),
});
const scope = globalThis as Record<string, unknown>;
const replacements: Record<string, unknown> = {
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<string, unknown>)[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<string, unknown> }) => {
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(<AcpmuxApp />));
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());
}
});

// 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);
});

// 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(<AcpmuxApp />));
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());
}
});
Loading