perf(workspaces): stop duplicating setup output in progress snapshots

Keep the required combined log field empty for setup clients, which display per-command output. Preserve concatenation only for legacy agent bootstrap timeline cards whose released renderers still read it directly.
This commit is contained in:
Mohamed Boudra
2026-10-20 07:32:31 +02:00
parent 3081b99b06
commit 56f0ad3000
8 changed files with 233 additions and 30 deletions
@@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
openWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
@@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
(payload) =>
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -18,6 +18,7 @@ import {
} from "../support/helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
@@ -92,7 +93,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -170,16 +172,19 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const initialRunning = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log === "",
(payload) => payload.status === "running" && payload.detail.commands.length === 0,
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
(payload) =>
payload.status === "running" &&
setupCommandOutput(payload.detail).includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
@@ -192,8 +197,8 @@ test.describe("Workspace setup streaming", () => {
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete");
expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete");
expect(completedPayload.error).toBeNull();
} finally {
await client.close();
@@ -215,7 +220,9 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
(payload) =>
payload.status === "failed" &&
setupCommandOutput(payload.detail).includes("setup failed"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
@@ -224,8 +231,8 @@ test.describe("Workspace setup streaming", () => {
});
const failedPayload = await failed;
expect(failedPayload.detail.log).toContain("starting setup");
expect(failedPayload.detail.log).toContain("setup failed");
expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
await openHomeWithProject(page, repo.path);
@@ -331,7 +338,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -377,7 +385,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const result = await client.createPaseoWorktree({
@@ -376,3 +376,7 @@ export function observeSetupPanelTraffic(page: Page) {
},
};
}
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
@@ -474,3 +474,54 @@ test("usage entries accept older failures and additive successful snapshots", ()
}).success,
).toBe(false);
});
// Frozen setup detail shape from v0.8.0: log is required, command output is optional.
const ReleasedSetupDetailSchema = z.object({
type: z.literal("worktree_setup"),
worktreePath: z.string(),
branchName: z.string(),
log: z.string(),
commands: z.array(
z.object({
index: z.number().int().positive(),
command: z.string(),
cwd: z.string(),
log: z.string().optional().default(""),
status: z.enum(["running", "completed", "failed"]),
exitCode: z.number().nullable(),
durationMs: z.number().nonnegative().optional(),
}),
),
truncated: z.boolean().optional(),
});
test("released setup clients parse empty combined logs and retain command output", () => {
const detail = {
type: "worktree_setup",
worktreePath: "/workspace",
branchName: "feature",
log: "",
commands: [
{
index: 1,
command: "echo ready",
cwd: "/workspace",
log: "ready\n",
status: "completed",
exitCode: 0,
},
],
};
const parsed = ReleasedSetupDetailSchema.parse(JSON.parse(JSON.stringify(detail)));
expect(parsed).toEqual(detail);
expect(parsed.commands[0]?.log).toBe("ready\n");
const { log: _log, ...withoutLegacyField } = detail;
expect(ReleasedSetupDetailSchema.safeParse(withoutLegacyField).success).toBe(false);
expect(
WorkspaceSetupSnapshotSchema.parse({
status: "completed",
detail: { ...detail, log: "old daemon combined output" },
error: null,
}).detail.log,
).toBe("old daemon combined output");
});
@@ -604,12 +604,18 @@ async function expectMissingLocalMergeTarget({ client, cwd }: RestorableWorkspac
).toContain("No local merge target is recorded");
}
function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
// Setup output is opt-in even for multiple sockets sharing one logical client.
test("setup details follow one workspace/source and restore after reconnect with minimal directory status", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: true });
const { repoDir, tempRoot } = createGitRepoWithBranch();
const clients: SocketDaemonClient[] = [];
const sockets = new Map<string, WebSocket[]>();
const setupWireTypes = new Set<string>();
const setupWireDetails: Array<{ log: string; commands: Array<{ log: string }> }> = [];
const observations: Array<{ release(): Promise<void> }> = [];
const workspaces: Array<{ id: string; workspaceDirectory?: string }> = [];
const mcp = new Client({ name: "setup-test", version: "1.0.0" });
@@ -653,6 +659,24 @@ const timer = setInterval(() => {
reconnect: { baseDelayMs: 10, maxDelayMs: 10 },
webSocketFactory: (url) => {
const socket = new WebSocket(url);
socket.on("message", (data, isBinary) => {
if (isBinary) return;
const frame = JSON.parse(data.toString());
const message = frame.message ?? frame;
if (message.type === "workspace_setup_progress") {
setupWireTypes.add(message.type);
setupWireDetails.push(message.payload.detail);
}
if (
message.type === "workspace_setup_status_response" ||
message.type === "workspace.setup.subscribe.response"
) {
if (message.payload.snapshot) {
setupWireTypes.add(message.type);
setupWireDetails.push(message.payload.snapshot.detail);
}
}
});
sockets.set(clientId, [...(sockets.get(clientId) ?? []), socket]);
return socket as unknown as WebSocketLike;
},
@@ -683,7 +707,10 @@ const timer = setInterval(() => {
})
.toMatchObject({
status: "running",
detail: { log: expect.stringContaining("setup started") },
detail: {
log: "",
commands: [expect.objectContaining({ log: expect.stringContaining("setup started") })],
},
});
};
for (const worktreeSlug of ["noisy-a", "noisy-b", "noisy-c"]) {
@@ -712,7 +739,10 @@ const timer = setInterval(() => {
const initial = await details.ready;
expect(initial.snapshot).toMatchObject({
status: "running",
detail: { log: expect.stringContaining("setup started") },
detail: {
log: "",
commands: [expect.objectContaining({ log: expect.stringContaining("setup started") })],
},
});
await expect
.poll(
@@ -745,7 +775,7 @@ const timer = setInterval(() => {
viewerMessages.filter(
(message) =>
message.type === "workspace_setup_progress" &&
message.payload.detail.log.includes("wave-0:"),
setupCommandOutput(message.payload.detail).includes("wave-0:"),
).length,
)
.toBeGreaterThan(0);
@@ -776,10 +806,12 @@ const timer = setInterval(() => {
.filter(
(message) =>
message.type === "workspace_setup_progress" &&
message.payload.detail.log.includes("wave-0:"),
setupCommandOutput(message.payload.detail).includes("wave-0:"),
)
.map((message) =>
message.type === "workspace_setup_progress" ? message.payload.detail.log : "",
message.type === "workspace_setup_progress"
? setupCommandOutput(message.payload.detail)
: "",
);
expect(waveLogs.length).toBe(new Set(waveLogs).size);
const restoredSnapshots: unknown[] = [];
@@ -792,7 +824,12 @@ const timer = setInterval(() => {
await expect.poll(() => restoredSnapshots.length, { timeout: 10000 }).toBe(2);
expect(details.subscriptionId).not.toBe(beforeReconnect);
expect(restoredSnapshots[1]).toMatchObject({
snapshot: { detail: { log: expect.stringContaining("wave-0:") } },
snapshot: {
detail: {
log: "",
commands: [expect.objectContaining({ log: expect.stringContaining("wave-0:") })],
},
},
});
const subscriptionId = details.subscriptionId;
await details.release();
@@ -804,7 +841,8 @@ const timer = setInterval(() => {
// Wait until the daemon has consumed the output before checking wire silence.
await expect
.poll(
async () => (await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot?.detail.log,
async () =>
(await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot?.detail.commands[0]?.log,
{
timeout: 30000,
},
@@ -815,8 +853,22 @@ const timer = setInterval(() => {
);
const resumed = viewer.observeWorkspaceSetup(workspace.id);
observations.push(resumed);
expect((await resumed.ready).snapshot?.detail.log).toContain("wave-1:");
expect((await resumed.ready).snapshot?.detail.commands[0]?.log).toContain("wave-1:");
expect(resumed.subscriptionId).not.toBe(subscriptionId);
expect(setupWireTypes).toEqual(
new Set([
"workspace_setup_progress",
"workspace_setup_status_response",
"workspace.setup.subscribe.response",
]),
);
expect(setupWireDetails.length).toBeGreaterThan(0);
expect(setupWireDetails.every((detail) => detail.log === "")).toBe(true);
expect(
setupWireDetails.some((detail) =>
detail.commands.some((command) => command.log.includes("wave-1:")),
),
).toBe(true);
writeFileSync(path.join(workspaces[1]!.workspaceDirectory!, "finish.txt"), "fail");
await expect
.poll(
@@ -849,3 +901,58 @@ const timer = setInterval(() => {
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}, 180000);
test("legacy agent git creation retains setup output in its readable chat timeline", async () => {
const daemon = await createTestPaseoDaemon();
const { repoDir, tempRoot } = createGitRepoWithBranch();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
writeFileSync(
path.join(repoDir, "paseo.json"),
JSON.stringify({ worktree: { setup: ["echo legacy-setup-output"] } }),
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "setup fixture"], {
cwd: repoDir,
stdio: "pipe",
});
await client.connect();
const agent = await client.createAgent({
provider: "codex",
cwd: repoDir,
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "legacy-setup",
worktreeSlug: "legacy-setup",
},
});
await expect
.poll(
async () => {
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
return timeline.entries
.map((entry) => entry.item)
.find(
(item) =>
item.type === "tool_call" &&
item.name === "paseo_worktree_setup" &&
item.status === "completed",
);
},
{ timeout: 10000 },
)
.toMatchObject({
detail: {
type: "worktree_setup",
log: expect.stringContaining("legacy-setup-output"),
commands: [expect.objectContaining({ log: "legacy-setup-output\n" })],
},
});
} finally {
await client.close();
await daemon.close();
await rm(tempRoot, { recursive: true, force: true });
}
});
@@ -5,7 +5,11 @@ import { join } from "path";
import { tmpdir } from "os";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
import { runAsyncWorktreeBootstrap, spawnWorkspaceScript } from "./worktree-bootstrap.js";
import {
buildWorktreeSetupDetail,
runAsyncWorktreeBootstrap,
spawnWorkspaceScript,
} from "./worktree-bootstrap.js";
import { ensureWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
import { ScriptRouteStore } from "./script-proxy.js";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
@@ -63,6 +67,30 @@ async function createBootstrapWorktreeForTest(
return { worktree, shouldBootstrap: true };
}
describe("workspace setup detail", () => {
it("serializes output only in command logs while retaining the legacy string field", () => {
const detail = buildWorktreeSetupDetail({
worktree: { worktreePath: "/workspace", branchName: "feature" },
results: [
{
command: "echo ready",
cwd: "/workspace",
stdout: "ready\n",
stderr: "",
exitCode: 0,
durationMs: 10,
},
],
});
const serialized = JSON.stringify(detail);
expect(JSON.parse(serialized)).toMatchObject({
log: "",
commands: [{ log: "ready\n", status: "completed", exitCode: 0 }],
});
expect(serialized.split("ready\\n")).toHaveLength(2);
});
});
describe("runAsyncWorktreeBootstrap", () => {
let tempDir: string;
let repoDir: string;
@@ -325,18 +325,15 @@ export function buildWorktreeSetupDetail(input: {
...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}),
};
});
const renderedLog = buildWorktreeSetupLog({
results: input.results,
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
});
return {
type: "worktree_setup",
worktreePath: input.worktree.worktreePath,
branchName: input.worktree.branchName,
log: renderedLog.log,
// COMPAT(workspaceSetupCombinedLog): added in v0.12.0, remove after 2027-04-20 once legacy wire schemas no longer require log.
// Setup clients render commands[].log; retain the required wire field without duplicating output.
log: "",
commands,
...(renderedLog.truncated || anyCommandTruncated ? { truncated: true } : {}),
...(anyCommandTruncated ? { truncated: true } : {}),
};
}
@@ -354,6 +351,10 @@ function buildSetupTimelineItem(input: {
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
});
// Legacy agent creation still emits timeline cards whose released renderers read only detail.log.
// Keep their output here rather than concatenating every workspace setup snapshot.
detail.log = buildWorktreeSetupLog(input).log;
if (input.status === "running") {
return {
type: "tool_call",
@@ -983,7 +983,7 @@ describe("runWorktreeSetupInBackground", () => {
const setupOutputMessage = runningMessages.find((message) =>
message.payload.detail.commands[0]?.log.includes("phase-one"),
);
expect(setupOutputMessage?.payload.detail.log).toContain("phase-one");
expect(setupOutputMessage?.payload.detail.log).toBe("");
expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({
index: 1,
command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"",
@@ -1001,7 +1001,7 @@ describe("runWorktreeSetupInBackground", () => {
branchName: "feature-running-setup",
},
});
expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two");
expect(progressMessages.at(-1)?.payload.detail.log).toBe("");
expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({
index: 1,
command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"",