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
3 changes: 3 additions & 0 deletions scripts/cmux-next/build-agent-pane-web.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ CSP="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'unsafe
printf '<meta http-equiv="Content-Security-Policy" content="%s" />\n' "$CSP"
printf '<meta name="viewport" content="width=device-width, initial-scale=1" />\n'
printf '<title>cmux Agent</title>\n'
# Fetch and parse the entry while the locale scripts and inline styles load. modulepreload
# never evaluates it: the module below still runs after the selected language is installed.
printf '<link rel="modulepreload" href="pane.js" />\n'
printf '%s\n' "$LOADER"
printf '<style>\n'
cat "$WORK/styles.css"
Expand Down
21 changes: 14 additions & 7 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,8 @@ import { NewChatPrewarm, type PrewarmTarget } from "./newChatPrewarm";
import { harnessProfiles } from "./harnessProfiles";
import { setStartModeSource } from "./startMode";
import { setPromptSurfaces } from "./surfaces/promptSurface";
import { MockAcpmuxSocket, mockHost, type MockScript } from "./mock";
import type { MockScript } from "./mock";
import { preparedMockTransport } from "./mockTransport";
import { BridgeSocket } from "./bridgeSocket";
import { useComposerKeyboard } from "./composerFocus";
import { createAcpmuxDebug, type AcpmuxDebug } from "./debug";
Expand Down Expand Up @@ -10902,13 +10903,19 @@ function AcpmuxPane() {
setHostErrorShown(false);
return;
}
// Native chats never load the in-page daemon or its fixture transcripts. Keep this
// await in the mock branch so the native handshake still starts in the same turn.
const mockModule = mock ? (preparedMockTransport() ?? (await import("./mock"))) : undefined;
if (cancelled) return;
// A new chat in mock mode starts without a session too, as against a real daemon.
const mockConfig: AcpmuxHostConfig = host.newSession
? { ...mockHost, sessionId: undefined, newSession: true }
: mockHost;
const config: AcpmuxHostConfig = mockModule
? host.newSession
? { ...mockModule.mockHost, sessionId: undefined, newSession: true }
: mockModule.mockHost
: (host as AcpmuxHostConfig);
let connectingClient: AcpmuxDirectClient | undefined;
const client = await AcpmuxDirectClient.connect(
mock ? mockConfig : (host as AcpmuxHostConfig),
config,
(next) => {
harnessSwitch.reconcile(next);
rowsRef.current = next.rows;
Expand Down Expand Up @@ -10965,8 +10972,8 @@ function AcpmuxPane() {
retryTimer = window.setTimeout(() => void connectHost(), retryDelay);
retryDelay = nextRetryDelay(retryDelay, true);
},
mock
? () => new MockAcpmuxSocket(undefined, window.cmuxAcpmuxMockScript) as unknown as WebSocket
mockModule
? () => new mockModule.MockAcpmuxSocket(undefined, window.cmuxAcpmuxMockScript) as unknown as WebSocket
: bridge
? () => new BridgeSocket() as unknown as WebSocket
: undefined,
Expand Down
45 changes: 26 additions & 19 deletions webviews/src/agent-session/acpmux/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,8 @@ import type { AcpmuxRow } from "./model";
import type { PermissionDecision } from "./permissions/protocol";
import { acpmuxPerf, frameStats, isBlank, median, round2, typingSummary } from "./perf";
import { openPicker, pickerLabels } from "./pickerOpeners";
import { syntheticRows } from "./synthetic";
import { runStream, type StreamOptions } from "./streamDebug";
import { loadRun, loadSeed, type LoadRunOptions } from "./loadDebug";
import { workedTurnRows } from "./workedTurn";
import { incomingRows } from "./incomingFixture";
import { linkRows } from "./linkFixture";
import { organizationRows } from "./organizationFixture";
import { SUBAGENT_TRAY_STEPS, subagentTrayRows } from "./subagentsFixture";
import { SHARED_PRESENCE, sharedRows } from "./multiplayer/sharedFixture";
import type { StreamOptions } from "./streamDebug";
import type { LoadRunOptions } from "./loadDebug";
import { shownPresence } from "./multiplayer/LivePresence";
import { acpWire, type AcpWireLog } from "./wire";
import { followTrace } from "./followTrace";
Expand Down Expand Up @@ -143,14 +136,25 @@ export function createAcpmuxDebug(

// The subagents fixture's steps share the time its step 0 began, so its rows keep their places.
let subagentsAt: number | undefined;
const trayStep = (count: number) => (Number.isInteger(count) && count > 0 && count < SUBAGENT_TRAY_STEPS ? count : 0);
const trayOrigin = (count: number) => {
if (trayStep(count) === 0 || subagentsAt === undefined) subagentsAt = Date.now() - 60_000;
return subagentsAt;
};

return {
async seedRows(count = 5000, fixture, agents) {
const {
syntheticRows,
workedTurnRows,
incomingRows,
linkRows,
organizationRows,
SUBAGENT_TRAY_STEPS,
subagentTrayRows,
SHARED_PRESENCE,
sharedRows,
} = await import("./debugFixtures");
const trayStep = (count: number) =>
Number.isInteger(count) && count > 0 && count < SUBAGENT_TRAY_STEPS ? count : 0;
const trayOrigin = (count: number) => {
if (trayStep(count) === 0 || subagentsAt === undefined) subagentsAt = Date.now() - 60_000;
return subagentsAt;
};
acpmuxPerf.enable();
const rows =
fixture === "subagents"
Expand Down Expand Up @@ -374,15 +378,18 @@ export function createAcpmuxDebug(
layoutWatch(options = {}) {
return layoutWatch(options);
},
stream(options = {}) {
async stream(options = {}) {
const { runStream } = await import("./streamDebug");
return runStream(host.replaceRows, options);
},
loadSeed(turns = 5000) {
async loadSeed(turns = 5000) {
const automationHost = host.automation;
if (!automationHost) return Promise.resolve(NO_AUTOMATION);
if (!automationHost) return NO_AUTOMATION;
const { loadSeed } = await import("./loadDebug");
return loadSeed({ selectSession: automationHost.selectSession, rowCount: host.rowCount }, turns);
},
loadRun(options = {}) {
async loadRun(options = {}) {
const { loadRun } = await import("./loadDebug");
return loadRun({ sessionId: () => host.sessionId?.(), rowCount: host.rowCount }, options);
},
};
Expand Down
8 changes: 8 additions & 0 deletions webviews/src/agent-session/acpmux/debugFixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Loaded only by an explicit debug seed; native chats never evaluate these fixtures.
export { syntheticRows } from "./synthetic";
export { workedTurnRows } from "./workedTurn";
export { incomingRows } from "./incomingFixture";
export { linkRows } from "./linkFixture";
export { organizationRows } from "./organizationFixture";
export { SUBAGENT_TRAY_STEPS, subagentTrayRows } from "./subagentsFixture";
export { SHARED_PRESENCE, sharedRows } from "./multiplayer/sharedFixture";
8 changes: 7 additions & 1 deletion webviews/src/agent-session/acpmux/dev.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import "./harnessStatus.css";
import "../../markdown-task-checkbox.css";
import { devHostParams, installDevHost } from "../../dev-host/host";
import { seedDevRecents } from "./devRecents";
import { prepareMockTransport } from "./mockTransport";

// `?mock` runs the page in a plain browser against the in-page mock daemon (no cmux host), the
// way the screenshot harness stubs the bridge; `?recents=demo` seeds a few recent models so the
Expand All @@ -87,4 +88,9 @@ if (devHost) {
}
// The dev server has no locales/<code>.js: install the whole string table before the pane loads.
globalThis.__cmuxPaneStrings ??= (await import("./generated/strings.json")).default;
await import("./main");
// Prepare the standalone mock daemon before the pane can paint its first handshake.
// Keep main's import first in source order: the shipped stylesheet order is derived from it.
const mountPane = () => import("./main");
if (!devHost && params.has("mock") && !window.webkit?.messageHandlers?.agentSession)
prepareMockTransport(await import("./mock"));
await mountPane();
13 changes: 13 additions & 0 deletions webviews/src/agent-session/acpmux/mockTransport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
type MockTransport = typeof import("./mock");

let prepared: MockTransport | undefined;

// Mock entry points prepare the daemon before mounting. Reading it synchronously keeps the
// ready handshake and connection in one turn; even an already loaded import() would yield.
export function prepareMockTransport(transport: MockTransport): void {
prepared = transport;
}

export function preparedMockTransport(): MockTransport | undefined {
return prepared;
}
4 changes: 4 additions & 0 deletions webviews/src/gallery/frame/agentPane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { StageContext } from "./context";
import { installChipHost } from "./chips";
import { resetSummaryCards } from "../../agent-session/acpmux/summary/cardState";
import { setChatDisplay } from "../../agent-session/acpmux/chatDisplay";
import { prepareMockTransport } from "../../agent-session/acpmux/mockTransport";

type Message = { id?: string; method?: string; params?: Record<string, unknown> };

Expand Down Expand Up @@ -170,6 +171,9 @@ export async function mountAgentPane(state: AgentPaneVariant, context: StageCont
await import("../../pages/shared/desktop");
await import("virtual:cmux-gallery/agent-pane.css");
await import("katex/dist/katex.min.css");
// Mock pages need their daemon before mounting: the ready reply schedules the fixture
// snapshot, which must not paint while the mock transport's chunk is still loading.
if (state.ready?.transport === "mock") prepareMockTransport(await import("../../agent-session/acpmux/mock"));
await import("../../agent-session/acpmux/main");
// main.tsx imports some pane sheets itself (styles.css, diffTokens.css, composerFooterShared.css,
// #26126). The app's bundle drops those imports and ships each sheet once in dev.tsx's order, but
Expand Down
90 changes: 90 additions & 0 deletions webviews/test/agent-pane-lazy-debug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Lazy fixture loading must keep the debug seed's state and wait for the pane's actual commit.
import { expect, spyOn, test } from "bun:test";
import { createAcpmuxDebug } from "../src/agent-session/acpmux/debug";
import type { AcpmuxRow } from "../src/agent-session/acpmux/model";
import { acpmuxPerf } from "../src/agent-session/acpmux/perf";

function pane() {
let rows: AcpmuxRow[] = [];
const debug = createAcpmuxDebug({
replaceRows(next) {
rows = next;
queueMicrotask(() => acpmuxPerf.commit(0, 0, [], performance.now()));
},
rowCount: () => rows.length,
});
return { debug, rows: () => rows };
}

test("lazy subagent seeds retain their origin across steps and reset independently per pane", async () => {
const now = spyOn(Date, "now");
const wasEnabled = acpmuxPerf.enabled;
const start = Date.UTC(2026, 10, 3, 12);
try {
now.mockReturnValue(start);
const first = pane();
await first.debug.seedRows(0, "subagents");
const origin = first.rows()[0]?.at;
expect(origin).toBe(start - 60_000);

now.mockReturnValue(start + 10_000);
await first.debug.seedRows(1, "subagents", 4);
const running = first.rows().find((row) => row.id === "subagents-group")?.subagents;
expect(running).toHaveLength(4);
expect(running?.map((agent) => agent.state)).toEqual(["running", "running", "running", "running"]);

now.mockReturnValue(start + 45_000);
await first.debug.seedRows(3, "subagents", 4);
const finished = first.rows().find((row) => row.id === "subagents-group")?.subagents;
expect(first.rows()[0]?.at).toBe(origin);
expect(finished?.map((agent) => agent.startedAt)).toEqual(running?.map((agent) => agent.startedAt));
expect(finished?.map((agent) => agent.state)).toEqual(["completed", "running", "running", "running"]);

const second = pane();
await second.debug.seedRows(1, "subagents");
expect(second.rows()[0]?.at).toBe(start + 45_000 - 60_000);
expect(first.rows()[0]?.at).toBe(origin);

now.mockReturnValue(start + 90_000);
await first.debug.seedRows(0, "subagents");
expect(first.rows()[0]?.at).toBe(start + 90_000 - 60_000);
expect(first.rows().some((row) => row.id === "subagents-group")).toBe(false);
} finally {
now.mockRestore();
acpmuxPerf.enabled = wasEnabled;
}
});

test("a lazy seed finishes only after the pane commits its replacement rows", async () => {
const wasEnabled = acpmuxPerf.enabled;
let rows: AcpmuxRow[] = [];
let replaced: (() => void) | undefined;
const receivedRows = new Promise<void>((resolve) => {
replaced = resolve;
});
const debug = createAcpmuxDebug({
replaceRows(next) {
rows = next;
replaced?.();
},
rowCount: () => rows.length,
});
let settled = false;
const seed = debug.seedRows(3, "short").then((result) => {
settled = true;
return result;
});
try {
await receivedRows;
await Promise.resolve();
expect(rows.length).toBeGreaterThan(0);
expect(settled).toBe(false);
acpmuxPerf.commit(0, 0, [], performance.now());
const result = await seed;
expect(result.rows).toBe(rows.length);
expect(typeof result.commit_ms).toBe("number");
} finally {
acpmuxPerf.commit(0, 0, [], performance.now());
acpmuxPerf.enabled = wasEnabled;
}
});
46 changes: 46 additions & 0 deletions webviews/test/agent-pane-startup-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Read the shipped JavaScript, including every eager shared chunk: moving mock code out of
// pane.js alone must not pass if the page still imports it before its first frame.
import { describe, expect, test } from "bun:test";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, resolve } from "node:path";

const bundle = resolve(import.meta.dir, "../../Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane");
const entry = resolve(bundle, "pane.js");
const built = existsSync(entry);
if (!built && process.env.CI) throw new Error("Build the agent pane before checking its startup bundle");

function eagerSources(): string[] {
const seen = new Set<string>();
const queue = [entry];
const sources: string[] = [];
const scanner = new Bun.Transpiler({ loader: "js" });
while (queue.length) {
const file = queue.pop()!;
if (seen.has(file)) continue;
seen.add(file);
const source = readFileSync(file, "utf8");
sources.push(source);
for (const item of scanner.scanImports(source)) {
if (item.kind === "import-statement" && item.path.startsWith(".")) queue.push(resolve(dirname(file), item.path));
}
}
return sources;
}

describe.skipIf(!built)("agent pane startup bundle", () => {
test("mock transport and shared-chat fixtures ship only in lazy chunks", () => {
const sources = eagerSources();
const eager = sources.join("\n");
const chunks = readdirSync(resolve(bundle, "chunks"))
.filter((name) => name.endsWith(".js"))
.map((name) => readFileSync(resolve(bundle, "chunks", name), "utf8"))
.join("\n");
console.info(JSON.stringify({ eager_files: sources.length, eager_bytes: Buffer.byteLength(eager) }));
// These are data the mock transport and shared fixture actually consume, not source symbols
// that a minifier can rename. The second assertion ensures the fixtures remain available.
for (const value of ["ws://mock.invalid/acp", "guest:shared-share.shared-guest"]) {
expect(eager.includes(value)).toBe(false);
expect(chunks.includes(value)).toBe(true);
}
});
});
2 changes: 1 addition & 1 deletion webviews/test/pane-english.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const TEXT_FIELDS = new Set([
]);
/** Files that are not shipped pane UI: the dev loop, fixtures, measurement and automation. */
const SKIP =
/\.test\.|\.d\.ts$|^(mock|mockFixture|mockFiles|mockGit|devHost|devRecents|dev|synthetic|workedTurn|debug|automation|perf|streamDebug|promptFieldTesting)\.tsx?$/;
/\.test\.|\.d\.ts$|^(mock|mockFixture|mockFiles|mockGit|devHost|devRecents|dev|synthetic|workedTurn|debug|debugFixtures|automation|perf|streamDebug|promptFieldTesting)\.tsx?$/;
const SKIP_DIRS = new Set(["prototype", "shiki", "generated"]);
const ALLOW = /l10n-allow:\s*\S/;
/** A file whose header says `l10n-allow-file: <reason>` is exempt as a whole (a wire validator). */
Expand Down
Loading