diff --git a/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts b/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts index 17c4d3dd45..969f330520 100644 --- a/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts +++ b/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts @@ -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, diff --git a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts index 227e2c8bc1..18a9704ddf 100644 --- a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts @@ -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({ diff --git a/packages/app/e2e/support/helpers/workspace-setup.ts b/packages/app/e2e/support/helpers/workspace-setup.ts index dd1bc779bd..eda1d8d023 100644 --- a/packages/app/e2e/support/helpers/workspace-setup.ts +++ b/packages/app/e2e/support/helpers/workspace-setup.ts @@ -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"); +} diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index 6903aa2cd1..d3b3ce26df 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -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"); +}); diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index 80208fe63d..2d5c60a752 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -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(); + const setupWireTypes = new Set(); + const setupWireDetails: Array<{ log: string; commands: Array<{ log: string }> }> = []; const observations: Array<{ release(): Promise }> = []; 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 }); + } +}); diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index 5e5ac1762e..42f48dbe22 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -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; diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 4bd49b1c2d..861ca185a0 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -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", diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index b146c59f31..4fddf0dbbd 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -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'\"",