mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-20 08:12:53 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62a5eac186 | ||
|
|
398c1a3b52 | ||
|
|
c138903a9c | ||
|
|
56f0ad3000 |
@@ -71,6 +71,8 @@ normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
|
||||
|
||||
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
@@ -31,6 +32,28 @@ import {
|
||||
setSidebarNavItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
|
||||
async function showDeveloperToast(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.getElementById("error-toast")?.remove();
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "error-toast";
|
||||
toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white";
|
||||
// Expo renders its unnamed dismiss button in an open shadow root.
|
||||
const button = document.createElement("button");
|
||||
button.style.cssText = "width:24px;height:24px";
|
||||
button.addEventListener("click", () => toast.remove());
|
||||
toast.attachShadow({ mode: "open" }).appendChild(button);
|
||||
document.body.appendChild(toast);
|
||||
});
|
||||
}
|
||||
|
||||
test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await showDeveloperToast(page);
|
||||
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
|
||||
await expect(page.locator("#error-toast")).toBeHidden();
|
||||
});
|
||||
|
||||
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
/** @param {import('@playwright/test').Page} page */
|
||||
export async function installDeveloperToastDismissal(page) {
|
||||
// Developer chrome must not cover app actions or move the test's pointer/focus.
|
||||
export async function hideDeveloperToasts(page) {
|
||||
// Keep developer chrome out of app interactions without Playwright locator
|
||||
// handlers: parallel app assertions can race while dismissing the same toast.
|
||||
// Console errors remain in the runner's logs.
|
||||
const dismiss = page.locator("#error-toast").getByRole("button", { name: "", exact: true });
|
||||
await page.addLocatorHandler(dismiss, () => dismiss.dispatchEvent("click"), {
|
||||
// A new logged error can replace the toast before the old one disappears.
|
||||
noWaitAfter: true,
|
||||
});
|
||||
function hideToasts() {
|
||||
const installStyle = () => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = "#error-toast { display: none !important; }";
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", installStyle, { once: true });
|
||||
} else {
|
||||
installStyle();
|
||||
}
|
||||
}
|
||||
await page.addInitScript(hideToasts);
|
||||
// Electron connects to an already loaded page; apply there as well.
|
||||
await page.evaluate(hideToasts);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { installDeveloperToastDismissal } from "./developer-ui.mjs";
|
||||
import { hideDeveloperToasts } from "./developer-ui.mjs";
|
||||
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
|
||||
import { startE2EWorker } from "./helpers/e2e-worker";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
@@ -131,7 +131,7 @@ const test = daemonTest.extend<{
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
await installDeveloperToastDismissal(page);
|
||||
await hideDeveloperToasts(page);
|
||||
|
||||
const entries: string[] = [];
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, type ReactNode } from "react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -186,48 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface WorktreeSetupDetailProps {
|
||||
log: string;
|
||||
branchName: string;
|
||||
worktreePath: string;
|
||||
ds: DetailStyles;
|
||||
}
|
||||
|
||||
function WorktreeSetupDetailSection({
|
||||
log,
|
||||
branchName,
|
||||
worktreePath,
|
||||
ds,
|
||||
}: WorktreeSetupDetailProps) {
|
||||
const setupLog = log.replace(/^\n+/, "");
|
||||
const hasLog = setupLog.length > 0;
|
||||
return (
|
||||
<View style={ds.sectionFillStyle}>
|
||||
<View style={ds.codeBlockFillStyle}>
|
||||
<ScrollView
|
||||
style={ds.codeVerticalScrollStyle}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSubAgentFallbackHeader(
|
||||
subAgentType: string | null | undefined,
|
||||
description: string | null | undefined,
|
||||
@@ -678,17 +636,7 @@ function buildDetailSections(
|
||||
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
|
||||
];
|
||||
}
|
||||
if (detail.type === "worktree_setup") {
|
||||
return [
|
||||
<WorktreeSetupDetailSection
|
||||
key="worktree-setup"
|
||||
log={detail.log}
|
||||
branchName={detail.branchName}
|
||||
worktreePath={detail.worktreePath}
|
||||
ds={ds}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
if (detail.type === "worktree_setup") return [];
|
||||
if (detail.type === "sub_agent") {
|
||||
return [
|
||||
<SubAgentDetailSection
|
||||
|
||||
@@ -183,32 +183,19 @@ function resolveSetupStatusLabel(
|
||||
return labels.waiting;
|
||||
}
|
||||
|
||||
function resolveCommandLog(
|
||||
command: SetupCommand,
|
||||
autoExpandIndex: number | null,
|
||||
log: string,
|
||||
): string {
|
||||
if ("log" in command && typeof command.log === "string") {
|
||||
return command.log;
|
||||
}
|
||||
if (command.index === autoExpandIndex) return log;
|
||||
return "";
|
||||
}
|
||||
|
||||
interface BuildCommandRowPropsArgs {
|
||||
command: SetupCommand;
|
||||
autoExpandIndex: number | null;
|
||||
log: string;
|
||||
expandedIndices: Set<number>;
|
||||
manuallyCollapsed: Set<number>;
|
||||
snapshotError: string | null | undefined;
|
||||
}
|
||||
|
||||
function buildCommandRowState(args: BuildCommandRowPropsArgs) {
|
||||
const { command, autoExpandIndex, log, expandedIndices, manuallyCollapsed, snapshotError } = args;
|
||||
const { command, autoExpandIndex, expandedIndices, manuallyCollapsed, snapshotError } = args;
|
||||
const isExpanded = expandedIndices.has(command.index);
|
||||
const hasError = command.status === "failed" && Boolean(snapshotError);
|
||||
const commandLog = resolveCommandLog(command, autoExpandIndex, log);
|
||||
const commandLog = command.log;
|
||||
const hasLog = commandLog.trim().length > 0;
|
||||
const isExpandable = command.status !== "running" || hasLog || hasError;
|
||||
const isAutoExpanded = command.index === autoExpandIndex && !manuallyCollapsed.has(command.index);
|
||||
@@ -285,9 +272,7 @@ function SetupPanel() {
|
||||
);
|
||||
|
||||
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
|
||||
const log = snapshot?.detail.log ?? "";
|
||||
const hasNoSetupCommands =
|
||||
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
|
||||
const hasNoSetupCommands = snapshot?.status === "completed" && commands.length === 0;
|
||||
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
|
||||
|
||||
const { expandedIndices, manuallyCollapsed, toggleExpanded } = useExpandedSetupCommands();
|
||||
@@ -323,7 +308,6 @@ function SetupPanel() {
|
||||
workspaceId={target.workspaceId}
|
||||
snapshot={snapshot}
|
||||
commands={commands}
|
||||
log={log}
|
||||
isWaiting={isWaiting}
|
||||
hasNoSetupCommands={hasNoSetupCommands}
|
||||
autoExpandIndex={autoExpandIndex}
|
||||
@@ -341,7 +325,6 @@ interface SetupPanelBodyProps {
|
||||
workspaceId: string;
|
||||
snapshot: WorkspaceSetupSnapshot | null;
|
||||
commands: SetupCommand[];
|
||||
log: string;
|
||||
isWaiting: boolean;
|
||||
hasNoSetupCommands: boolean;
|
||||
autoExpandIndex: number | null;
|
||||
@@ -352,7 +335,7 @@ interface SetupPanelBodyProps {
|
||||
|
||||
function SetupPanelBody(props: SetupPanelBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const { snapshot, commands, log } = props;
|
||||
const { snapshot, commands } = props;
|
||||
if (snapshot?.status === "blocked" && snapshot.blockedSource) {
|
||||
return (
|
||||
<BlockedSetupNotice
|
||||
@@ -389,7 +372,6 @@ function SetupPanelBody(props: SetupPanelBodyProps) {
|
||||
const rowState = buildCommandRowState({
|
||||
command,
|
||||
autoExpandIndex: props.autoExpandIndex,
|
||||
log,
|
||||
expandedIndices: props.expandedIndices,
|
||||
manuallyCollapsed: props.manuallyCollapsed,
|
||||
snapshotError: snapshot?.error,
|
||||
@@ -404,7 +386,6 @@ function SetupPanelBody(props: SetupPanelBodyProps) {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<StandaloneLogView commands={commands} log={log} />
|
||||
<TopLevelSetupError snapshotError={snapshot?.error ?? null} commands={commands} />
|
||||
</View>
|
||||
);
|
||||
@@ -523,25 +504,6 @@ function SetupCommandChevron({ showDetail }: { showDetail: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) {
|
||||
const { t } = useTranslation();
|
||||
if (commands.length !== 0 || log.trim().length === 0) return null;
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.logScroll}
|
||||
contentContainerStyle={styles.logScrollContent}
|
||||
showsVerticalScrollIndicator
|
||||
testID="workspace-setup-log"
|
||||
accessible
|
||||
accessibilityLabel={t("workspace.setup.accessibility.log")}
|
||||
>
|
||||
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
|
||||
{log}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function TopLevelSetupError({
|
||||
snapshotError,
|
||||
commands,
|
||||
|
||||
@@ -77,3 +77,16 @@ describe("tool-call detail state", () => {
|
||||
assert.strictEqual(hasMeaningfulToolCallDetail(detail), true);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps historical setup rows readable as history without a chat output view", () => {
|
||||
assert.strictEqual(
|
||||
hasMeaningfulToolCallDetail({
|
||||
type: "worktree_setup",
|
||||
branchName: "old",
|
||||
worktreePath: "/workspace",
|
||||
log: "historical output",
|
||||
commands: [],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract<ToolCallDetail, { type: "fetch
|
||||
return Boolean(detail.url || detail.result || detail.codeText);
|
||||
}
|
||||
|
||||
function hasMeaningfulWorktreeSetupDetail(
|
||||
detail: Extract<ToolCallDetail, { type: "worktree_setup" }>,
|
||||
): boolean {
|
||||
return Boolean(detail.branchName || detail.worktreePath || detail.log);
|
||||
}
|
||||
|
||||
function hasMeaningfulSubAgentDetail(
|
||||
detail: Extract<ToolCallDetail, { type: "sub_agent" }>,
|
||||
): boolean {
|
||||
@@ -81,7 +75,7 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
|
||||
case "fetch":
|
||||
return hasMeaningfulFetchDetail(detail);
|
||||
case "worktree_setup":
|
||||
return hasMeaningfulWorktreeSetupDetail(detail);
|
||||
return false;
|
||||
case "sub_agent":
|
||||
return hasMeaningfulSubAgentDetail(detail);
|
||||
case "plain_text":
|
||||
@@ -98,6 +92,7 @@ export function isPendingToolCallDetail(params: {
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
error: unknown;
|
||||
}): boolean {
|
||||
if (params.detail?.type === "worktree_setup") return false;
|
||||
const isRunning = params.status === "running" || params.status === "executing";
|
||||
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
|
||||
}
|
||||
|
||||
@@ -3335,62 +3335,6 @@ test("sends create_agent_request with workspace and caller identity", async () =
|
||||
await expect(createPromise).rejects.toThrow("compat test sentinel");
|
||||
});
|
||||
|
||||
test("sends worktree target and autoArchive in create_agent_request", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const createPromise = client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
worktree: {
|
||||
mode: "branch-off",
|
||||
newBranch: "agent-lifecycle-dispatch",
|
||||
base: "main",
|
||||
},
|
||||
autoArchive: true,
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "create_agent_request",
|
||||
worktree: {
|
||||
mode: "branch-off",
|
||||
newBranch: "agent-lifecycle-dispatch",
|
||||
base: "main",
|
||||
},
|
||||
autoArchive: true,
|
||||
}),
|
||||
);
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId: request.requestId,
|
||||
error: "worktree auto archive sentinel",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
|
||||
});
|
||||
|
||||
test("sends internal in create_agent_request when the host advertises internal agents", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
@@ -3609,120 +3553,6 @@ test("sends structured attachments with create_agent_request", async () => {
|
||||
await expect(createPromise).rejects.toThrow("attachment test sentinel");
|
||||
});
|
||||
|
||||
test("sends worktree base-ref fields in create_agent_request git options", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const createPromise = client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
requestId: "req-agent-ref",
|
||||
git: {
|
||||
createWorktree: true,
|
||||
worktreeSlug: "review-pr-123",
|
||||
refName: "feature/worktree-base-ref",
|
||||
action: "checkout",
|
||||
githubPrNumber: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.git).toEqual({
|
||||
createWorktree: true,
|
||||
worktreeSlug: "review-pr-123",
|
||||
refName: "feature/worktree-base-ref",
|
||||
action: "checkout",
|
||||
githubPrNumber: 123,
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId: request.requestId,
|
||||
error: "git ref fields sentinel",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(createPromise).rejects.toThrow("git ref fields sentinel");
|
||||
});
|
||||
|
||||
test("omitting create_agent_request worktree base-ref fields preserves legacy wire shape", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const createPromise = client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
requestId: "req-agent-legacy",
|
||||
git: {
|
||||
createWorktree: true,
|
||||
worktreeSlug: "feature-a",
|
||||
},
|
||||
});
|
||||
|
||||
expect(assertStr(mock.sent[0])).toBe(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "create_agent_request",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
git: {
|
||||
createWorktree: true,
|
||||
worktreeSlug: "feature-a",
|
||||
},
|
||||
labels: {},
|
||||
requestId: "req-agent-legacy",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId: "req-agent-legacy",
|
||||
error: "legacy git shape sentinel",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(createPromise).rejects.toThrow("legacy git shape sentinel");
|
||||
});
|
||||
|
||||
test("sends structured first-agent context attachments with create_paseo_worktree_request", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
@@ -7844,3 +7674,59 @@ test("measures an outbound message in UTF-8 bytes against the daemon's message l
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
expect(client.getConnectionState().status).toBe("connected");
|
||||
});
|
||||
|
||||
test("sends worktree target and autoArchive in create_agent_request", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const createPromise = client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
worktree: {
|
||||
mode: "branch-off",
|
||||
newBranch: "agent-lifecycle-dispatch",
|
||||
base: "main",
|
||||
},
|
||||
autoArchive: true,
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "create_agent_request",
|
||||
worktree: {
|
||||
mode: "branch-off",
|
||||
newBranch: "agent-lifecycle-dispatch",
|
||||
base: "main",
|
||||
},
|
||||
autoArchive: true,
|
||||
}),
|
||||
);
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId: request.requestId,
|
||||
error: "worktree auto archive sentinel",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
|
||||
});
|
||||
|
||||
@@ -459,12 +459,12 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
||||
outputSchema?: Record<string, unknown>;
|
||||
images?: CreateAgentRequestMessage["images"];
|
||||
attachments?: CreateAgentRequestMessage["attachments"];
|
||||
/** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */
|
||||
git?: GitSetupOptions;
|
||||
worktree?: CreateAgentRequestMessage["worktree"];
|
||||
autoArchive?: CreateAgentRequestMessage["autoArchive"];
|
||||
background?: CreateAgentRequestMessage["background"];
|
||||
// COMPAT(createAgentWorktree): low-level old callers may still send the
|
||||
// create-agent worktree field. Added in v0.2.0; remove after 2027-01-17.
|
||||
/** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */
|
||||
worktreeName?: string;
|
||||
requestId?: string;
|
||||
labels?: Record<string, string>;
|
||||
@@ -2924,6 +2924,11 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> {
|
||||
if (options.git !== undefined || options.worktreeName !== undefined) {
|
||||
throw new Error(
|
||||
"Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) or create a workspace first and pass workspaceId.",
|
||||
);
|
||||
}
|
||||
if (options.background !== undefined) this.requireBackgroundWorkspaces();
|
||||
const result = await this.creations.createAgent({
|
||||
...options,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { experimental_createMCPClient } from "ai";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { chromium } from "playwright";
|
||||
import { installDeveloperToastDismissal } from "../../app/e2e/support/developer-ui.mjs";
|
||||
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
||||
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
||||
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
|
||||
|
||||
@@ -1189,7 +1189,7 @@ async function main() {
|
||||
|
||||
browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`);
|
||||
const page = await waitForAppPage(browser, expoPort);
|
||||
await installDeveloperToastDismissal(page);
|
||||
await hideDeveloperToasts(page);
|
||||
const status = await waitForDesktopStatus(page);
|
||||
|
||||
const checkPluginLinks = () =>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -787,7 +787,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("create_agent with worktree is async and boots terminals only after setup success", async () => {
|
||||
test("workspace creation owns async setup and terminal bootstrap before agent attachment", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-"));
|
||||
@@ -843,21 +843,30 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const result = await withTimeout({
|
||||
const workspaceResult = await withTimeout({
|
||||
promise: client.callTool({
|
||||
name: "create_agent",
|
||||
name: "create_workspace",
|
||||
args: {
|
||||
cwd: repoRoot,
|
||||
title: "MCP worktree setup terminals",
|
||||
provider: "claude/claude-test-model",
|
||||
mode: "bypassPermissions",
|
||||
initialPrompt: "say done and stop",
|
||||
worktreeName: "mcp-worktree-setup-test",
|
||||
isolation: "worktree",
|
||||
path: repoRoot,
|
||||
worktreeSlug: "mcp-worktree-setup-test",
|
||||
baseBranch: "main",
|
||||
},
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "create_agent should not block on setup",
|
||||
label: "create_workspace should not block on setup",
|
||||
});
|
||||
const workspace = getStructuredContent(workspaceResult);
|
||||
if (typeof workspace?.workspaceId !== "string") throw new Error("Expected created workspace");
|
||||
const result = await client.callTool({
|
||||
name: "create_agent",
|
||||
args: {
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "MCP worktree setup terminals",
|
||||
provider: "claude/claude-test-model",
|
||||
settings: { modeId: "bypassPermissions" },
|
||||
initialPrompt: "say done and stop",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = getStructuredContent(result);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type pino from "pino";
|
||||
|
||||
@@ -7,11 +8,13 @@ import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-se
|
||||
import type {
|
||||
CreatePaseoWorktreeWorkflowFn,
|
||||
CreatePaseoWorktreeWorkflowResult,
|
||||
CreatePaseoWorktreeSetupContinuationInput,
|
||||
NormalizedGitOptions,
|
||||
} from "../worktree-session.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
import type { FirstAgentContext, SessionOutboundMessage } from "../messages.js";
|
||||
import type {
|
||||
FirstAgentContext,
|
||||
CreateAgentWorktreeTarget,
|
||||
SessionOutboundMessage,
|
||||
} from "../messages.js";
|
||||
import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
|
||||
@@ -57,33 +60,39 @@ export class CreateAgentLifecycleDispatch {
|
||||
|
||||
async createWorktreeForRequest(input: {
|
||||
cwd: string;
|
||||
target: NormalizedGitOptions | null;
|
||||
target: CreateAgentWorktreeTarget | undefined;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
}): Promise<CreatePaseoWorktreeWorkflowResult | null> {
|
||||
const target = input.target;
|
||||
if (!target?.createWorktree) return null;
|
||||
if (!target) return null;
|
||||
const placement: CreatePaseoWorktreeInput = { cwd: input.cwd };
|
||||
switch (target.mode) {
|
||||
case "branch-off":
|
||||
placement.action = "branch-off";
|
||||
placement.worktreeSlug = target.newBranch;
|
||||
placement.refName = target.base;
|
||||
break;
|
||||
case "checkout-branch":
|
||||
placement.action = "checkout";
|
||||
placement.refName = target.branch;
|
||||
break;
|
||||
case "checkout-pr":
|
||||
placement.githubPrNumber = target.prNumber;
|
||||
break;
|
||||
}
|
||||
const base = target.mode === "branch-off" ? target.base : undefined;
|
||||
return this.dependencies.createPaseoWorktreeWorkflow(
|
||||
{
|
||||
cwd: input.cwd,
|
||||
...placement,
|
||||
background: input.background,
|
||||
callerWorkspaceId: input.callerWorkspaceId,
|
||||
firstAgentContext: input.firstAgentContext,
|
||||
worktreeSlug: target.worktreeSlug,
|
||||
refName: target.refName,
|
||||
action: target.action,
|
||||
checkoutSource: target.checkoutSource,
|
||||
githubPrNumber: target.githubPrNumber,
|
||||
runSetup: false,
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
worktreesRoot: this.dependencies.worktreesRoot,
|
||||
},
|
||||
{
|
||||
...(target.baseBranch ? { resolveDefaultBranch: async () => target.baseBranch! } : {}),
|
||||
setupContinuation: input.setupContinuation,
|
||||
},
|
||||
base ? { resolveDefaultBranch: async () => base } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { AgentStorage } from "../agent-storage.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import { createAgentCommand } from "./create.js";
|
||||
import type { ManagedAgent } from "../agent-manager.js";
|
||||
import type { AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
|
||||
const logger = createTestLogger();
|
||||
|
||||
@@ -53,7 +52,6 @@ function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: strin
|
||||
workspace: { workspaceId: args.createdWorkspaceId, cwd: workspaceCwd },
|
||||
repoRoot: args.repoRoot,
|
||||
created: true,
|
||||
setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} },
|
||||
}) as unknown as CreatePaseoWorktreeWorkflowResult;
|
||||
}
|
||||
|
||||
@@ -88,7 +86,6 @@ test("session create forwards clientMessageId to the initial prompt run options"
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
});
|
||||
|
||||
expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", {
|
||||
@@ -125,7 +122,6 @@ test("session create validates the requested mode against the provider's modes",
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
}),
|
||||
).rejects.toThrow("Invalid mode 'plan'");
|
||||
|
||||
@@ -169,7 +165,6 @@ test("session create applies the resolved mode from the provider create config",
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
});
|
||||
|
||||
expect(createAgent).toHaveBeenCalledWith(
|
||||
@@ -247,7 +242,6 @@ test("session create stamps the requested workspaceId when no worktree setup run
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -258,41 +252,6 @@ test("session create stamps the requested workspaceId when no worktree setup run
|
||||
}
|
||||
});
|
||||
|
||||
test("session create stamps the new worktree's workspaceId when a setup continuation runs", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const agentManager = createRealAgentManager(storage);
|
||||
|
||||
try {
|
||||
const { snapshot } = await createAgentCommand(
|
||||
{
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
},
|
||||
{
|
||||
kind: "session",
|
||||
config: { provider: "codex", cwd: workdir },
|
||||
workspaceId: "ws-source",
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({
|
||||
sessionConfig: config,
|
||||
setupContinuation: { kind: "agent", startAfterAgentCreate: () => {} },
|
||||
createdWorkspaceId: "ws-new-worktree",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const stored = await storage.get(snapshot.id);
|
||||
expect(stored?.workspaceId).toBe("ws-new-worktree");
|
||||
} finally {
|
||||
await removeRealAgentManagerWorkdir({ agentManager, storage, workdir });
|
||||
}
|
||||
});
|
||||
|
||||
test("mcp create stamps the new worktree's workspaceId, not the parent's", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
@@ -309,7 +268,6 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -416,7 +374,6 @@ test("session create keeps the prompt title after the initial prompt settles", a
|
||||
labels: {},
|
||||
provisionalTitle: title,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -454,7 +411,6 @@ test("session create keeps an explicit title after the initial prompt settles",
|
||||
labels: {},
|
||||
provisionalTitle: title,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -492,7 +448,6 @@ test("session create with an internal config skips provider session persistence"
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config: AgentSessionConfig) => ({ sessionConfig: config }),
|
||||
};
|
||||
|
||||
await createAgentCommand(dependencies, {
|
||||
|
||||
@@ -6,12 +6,10 @@ import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
|
||||
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
|
||||
import { toWorktreeRequestError } from "../../worktree-errors.js";
|
||||
import type {
|
||||
AgentWorktreeSetupContinuation,
|
||||
CreatePaseoWorktreeSetupContinuationInput,
|
||||
CreatePaseoWorktreeWorkflowFn,
|
||||
CreatePaseoWorktreeWorkflowResult,
|
||||
} from "../../worktree-session.js";
|
||||
import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js";
|
||||
import type { AgentAttachment, FirstAgentContext } from "../../messages.js";
|
||||
import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js";
|
||||
import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import type { AgentStorage } from "../agent-storage.js";
|
||||
@@ -22,20 +20,8 @@ import { resolveCreateAgentTitles } from "../create-agent-title.js";
|
||||
import { buildAgentPrompt } from "../prompt-attachments.js";
|
||||
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
||||
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
} from "../timeline-append.js";
|
||||
import { resolveCreateAgentIntent } from "./intent.js";
|
||||
|
||||
export interface CreateAgentSessionWorktreeResult {
|
||||
sessionConfig: AgentSessionConfig;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
// Set when this build created a fresh worktree workspace. The agent must be
|
||||
// stamped with it so workspaceId-scoped archive can find the agent later.
|
||||
createdWorkspaceId?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentCommandDependencies {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
@@ -61,24 +47,16 @@ export interface CreateAgentFromSessionInput {
|
||||
agentId?: string;
|
||||
config: AgentSessionConfig;
|
||||
workspaceId: string;
|
||||
worktreeName?: string;
|
||||
initialPrompt?: string;
|
||||
source?: AgentPromptSource;
|
||||
clientMessageId?: string;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: AgentAttachment[];
|
||||
git?: GitSetupOptions;
|
||||
labels: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
provisionalTitle: string | null;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
buildSessionConfig: (
|
||||
config: AgentSessionConfig,
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
) => Promise<CreateAgentSessionWorktreeResult>;
|
||||
}
|
||||
|
||||
export interface CreateAgentFromMcpInput {
|
||||
@@ -111,6 +89,7 @@ export interface CreateAgentFromMcpInput {
|
||||
allowCustomCwd?: boolean;
|
||||
childAgentDefaultLabels?: Record<string, string>;
|
||||
} | null;
|
||||
|
||||
worktree?: {
|
||||
worktreeName?: string;
|
||||
branchName?: string;
|
||||
@@ -167,7 +146,6 @@ interface ResolvedCreateAgent {
|
||||
prompt?: AgentPromptInput;
|
||||
source?: AgentPromptSource;
|
||||
runOptions?: AgentRunOptions;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
promptFailure: CreateAgentPromptFailureMode;
|
||||
promptLogger?: Logger;
|
||||
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
|
||||
@@ -200,7 +178,7 @@ export async function createAgentCommand(
|
||||
input.kind === "mcp" &&
|
||||
input.notifyOnFinish &&
|
||||
input.callerAgentId &&
|
||||
(resolved.prompt !== undefined || resolved.setupContinuation !== undefined)
|
||||
resolved.prompt !== undefined
|
||||
? setupFinishNotification({
|
||||
agentManager: dependencies.agentManager,
|
||||
agentStorage: dependencies.agentStorage,
|
||||
@@ -211,9 +189,6 @@ export async function createAgentCommand(
|
||||
})
|
||||
: undefined;
|
||||
|
||||
resolved.setupContinuation?.startAfterAgentCreate({
|
||||
agentId: snapshot.id,
|
||||
});
|
||||
if (resolved.prompt !== undefined) {
|
||||
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch((error) => {
|
||||
stopNotification?.();
|
||||
@@ -238,29 +213,7 @@ async function resolveSessionCreateAgent(
|
||||
input: CreateAgentFromSessionInput,
|
||||
): Promise<ResolvedCreateAgent> {
|
||||
const trimmedPrompt = input.initialPrompt?.trim();
|
||||
const {
|
||||
sessionConfig: builtSessionConfig,
|
||||
setupContinuation,
|
||||
createdWorkspaceId,
|
||||
} = await input.buildSessionConfig(
|
||||
input.config,
|
||||
input.git,
|
||||
input.worktreeName,
|
||||
input.firstAgentContext,
|
||||
);
|
||||
// Validate the requested mode against the provider's modes for the resolved
|
||||
// cwd. The app remembers mode preferences globally, so a saved mode can be
|
||||
// stale for a workspace whose provider config no longer defines it — reject
|
||||
// it here instead of letting the provider fail mid-turn.
|
||||
//
|
||||
// This runs after buildSessionConfig, which may already have created a
|
||||
// worktree and/or workspace record — cwd (required to resolve modes) is
|
||||
// only known once that step completes. If validation throws, any
|
||||
// worktree/workspace buildSessionConfig created is the caller's
|
||||
// responsibility to clean up (session.ts's handleCreateAgentRequest does
|
||||
// this for the worktree path via cleanupCreatedWorktreeAfterFailedAgentCreate;
|
||||
// this is a pre-existing gap for directory-only workspace creates, not
|
||||
// introduced by this validation).
|
||||
const builtSessionConfig = input.config;
|
||||
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||
cwd: builtSessionConfig.cwd,
|
||||
provider: builtSessionConfig.provider,
|
||||
@@ -284,7 +237,7 @@ async function resolveSessionCreateAgent(
|
||||
...(clientMessageId ? { clientMessageId } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const workspaceId = setupContinuation ? createdWorkspaceId : input.workspaceId;
|
||||
const workspaceId = input.workspaceId;
|
||||
|
||||
return {
|
||||
config: sessionConfig,
|
||||
@@ -296,15 +249,11 @@ async function resolveSessionCreateAgent(
|
||||
// Paseo never persists an internal agent, so nothing could resume its
|
||||
// provider session either. Same as the daemon's own ephemeral helpers.
|
||||
...(sessionConfig.internal ? { persistSession: false } : {}),
|
||||
// A legacy git/worktreeName worktree creates a fresh workspace, so the
|
||||
// agent belongs to that workspace, not the source one. createdWorkspaceId
|
||||
// is the freshly created worktree's workspace.
|
||||
workspaceId: requireResolvedWorkspaceId(workspaceId),
|
||||
},
|
||||
prompt: hasPromptContent ? prompt : undefined,
|
||||
source: input.source,
|
||||
runOptions,
|
||||
setupContinuation,
|
||||
// Registration commits creation. First-turn failures belong to the created agent.
|
||||
promptFailure: "return-error",
|
||||
promptLogger: dependencies.logger.child({
|
||||
@@ -323,18 +272,16 @@ async function resolveMcpCreateAgent(
|
||||
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
|
||||
: null;
|
||||
const cwd = resolveMcpInitialCwd(input, parentAgent);
|
||||
const { resolvedCwd, setupContinuation, createdWorkspaceId, createdWorktree } =
|
||||
await resolveMcpCwd({
|
||||
dependencies,
|
||||
cwd,
|
||||
worktree: input.worktree,
|
||||
initialPrompt: input.initialPrompt ?? "",
|
||||
callerWorkspaceId: parentAgent?.workspaceId,
|
||||
});
|
||||
const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({
|
||||
dependencies,
|
||||
cwd,
|
||||
worktree: input.worktree,
|
||||
initialPrompt: input.initialPrompt ?? "",
|
||||
callerWorkspaceId: parentAgent?.workspaceId,
|
||||
});
|
||||
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
|
||||
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
explicitWorkspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
|
||||
explicitWorkspaceId: createdWorkspaceId ?? input.workspaceId,
|
||||
caller: parentAgent
|
||||
? { id: parentAgent.id, cwd: parentAgent.cwd, workspaceId: parentAgent.workspaceId }
|
||||
: null,
|
||||
@@ -383,7 +330,6 @@ async function resolveMcpCreateAgent(
|
||||
source: input.callerAgentId
|
||||
? { kind: "agent-message", agentId: input.callerAgentId }
|
||||
: undefined,
|
||||
setupContinuation,
|
||||
createdWorktree,
|
||||
promptFailure: input.promptFailure ?? "log",
|
||||
};
|
||||
@@ -535,6 +481,28 @@ function resolveChildAgentCwd(params: {
|
||||
return resolvePathFromBase(params.parentCwd, requestedCwd);
|
||||
}
|
||||
|
||||
interface CreateMcpWorktreeOptions {
|
||||
input: CreatePaseoWorktreeInput;
|
||||
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
}
|
||||
|
||||
async function createMcpWorktree(
|
||||
options: CreateMcpWorktreeOptions,
|
||||
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||
try {
|
||||
if (!options.createPaseoWorktree) {
|
||||
throw new Error("Paseo worktree service is not configured");
|
||||
}
|
||||
return await options.createPaseoWorktree(
|
||||
options.input,
|
||||
options.resolveDefaultBranch ? { resolveDefaultBranch: options.resolveDefaultBranch } : {},
|
||||
);
|
||||
} catch (error) {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveMcpCwd(params: {
|
||||
dependencies: CreateAgentCommandDependencies;
|
||||
cwd: string;
|
||||
@@ -543,7 +511,6 @@ async function resolveMcpCwd(params: {
|
||||
worktree: CreateAgentFromMcpInput["worktree"];
|
||||
}): Promise<{
|
||||
resolvedCwd: string;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
createdWorkspaceId?: string;
|
||||
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
|
||||
}> {
|
||||
@@ -577,59 +544,16 @@ async function resolveMcpCwd(params: {
|
||||
action: worktree.action,
|
||||
githubPrNumber: worktree.githubPrNumber,
|
||||
firstAgentContext: { prompt: params.initialPrompt },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: dependencies.worktreesRoot,
|
||||
},
|
||||
createPaseoWorktree: dependencies.createPaseoWorktree,
|
||||
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
terminalManager: dependencies.terminalManager ?? null,
|
||||
appendTimelineItem: ({ agentId, item }) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
agentManager: dependencies.agentManager,
|
||||
agentId,
|
||||
item,
|
||||
}),
|
||||
emitLiveTimelineItem: ({ agentId, item }) =>
|
||||
emitLiveTimelineItemIfAgentKnown({
|
||||
agentManager: dependencies.agentManager,
|
||||
agentId,
|
||||
item,
|
||||
}),
|
||||
logger: dependencies.logger,
|
||||
},
|
||||
});
|
||||
return {
|
||||
resolvedCwd: createdWorktree.workspace.cwd,
|
||||
setupContinuation: createdWorktree.setupContinuation,
|
||||
createdWorkspaceId: createdWorktree.workspace.workspaceId,
|
||||
createdWorktree,
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateMcpWorktreeOptions {
|
||||
input: CreatePaseoWorktreeInput;
|
||||
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
}
|
||||
|
||||
async function createMcpWorktree(
|
||||
options: CreateMcpWorktreeOptions,
|
||||
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||
try {
|
||||
if (!options.createPaseoWorktree) {
|
||||
throw new Error("Paseo worktree service is not configured");
|
||||
}
|
||||
return await options.createPaseoWorktree(options.input, {
|
||||
...(options.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
||||
: {}),
|
||||
...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol
|
||||
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
|
||||
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
|
||||
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
|
||||
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
|
||||
const REPO_CWD = resolvePath("/tmp/repo");
|
||||
@@ -180,20 +179,6 @@ async function waitForWorkspaceTitle(
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForWorkspaceBranch(
|
||||
workspaceRecords: Map<string, PersistedWorkspaceRecord>,
|
||||
workspaceId: string,
|
||||
branch: string,
|
||||
): Promise<void> {
|
||||
await vi.waitFor(() => expect(workspaceRecords.get(workspaceId)?.branch).toBe(branch), {
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForUnexpectedWorkspaceNamingSideEffects(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
async function removeTempDir(path: string): Promise<void> {
|
||||
await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
@@ -693,7 +678,6 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
createdWorkspaceIds?: string[];
|
||||
workspaceRecords?: Map<string, PersistedWorkspaceRecord>;
|
||||
generateWorkspaceName?: () => Promise<GeneratedWorkspaceName | null>;
|
||||
setupContinuations?: Array<"workspace" | "agent" | undefined>;
|
||||
startedAgentSetupIds?: string[];
|
||||
}): CreatePaseoWorktreeWorkflowFn {
|
||||
const projects = new Map<string, PersistedProjectRecord>();
|
||||
@@ -790,7 +774,6 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
});
|
||||
|
||||
return async (input, serviceOptions) => {
|
||||
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
|
||||
const result = await createPaseoWorktreeWorkflow(
|
||||
{
|
||||
paseoHome: options.paseoHome,
|
||||
@@ -824,17 +807,6 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
);
|
||||
options.broadcasts.push(result.workspace.workspaceId);
|
||||
options.createdWorkspaceIds?.push(result.workspace.workspaceId);
|
||||
if (serviceOptions?.setupContinuation?.kind === "agent") {
|
||||
return {
|
||||
...result,
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
startAfterAgentCreate: ({ agentId }) => {
|
||||
options.startedAgentSetupIds?.push(agentId);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -1466,16 +1438,6 @@ describe("create_agent MCP tool", () => {
|
||||
relationship: { kind: "detached" as const },
|
||||
workspace: { kind: "create" as const, source: { kind: "directory" as const, path } },
|
||||
});
|
||||
const detachedWorktreeWorkspace = (
|
||||
cwd: string,
|
||||
target:
|
||||
| { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string }
|
||||
| { kind: "checkout-branch"; branch: string }
|
||||
| { kind: "checkout-pr"; githubPrNumber: number },
|
||||
) => ({
|
||||
relationship: { kind: "detached" as const },
|
||||
workspace: { kind: "create" as const, source: { kind: "worktree" as const, cwd, target } },
|
||||
});
|
||||
const subagentCurrentWorkspace = (cwd?: string) => ({
|
||||
relationship: { kind: "subagent" as const },
|
||||
workspace: { kind: "current" as const, ...(cwd ? { cwd } : {}) },
|
||||
@@ -1902,29 +1864,6 @@ describe("create_agent MCP tool", () => {
|
||||
).rejects.toThrow("Unrecognized key");
|
||||
});
|
||||
|
||||
it("accepts worktree workspace intent in create_agent input validation", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
...detachedWorktreeWorkspace(existingCwd, {
|
||||
kind: "checkout-pr",
|
||||
githubPrNumber: 42,
|
||||
}),
|
||||
title: "Short title",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "test",
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes workspace tools instead of worktree tools", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -2082,444 +2021,6 @@ describe("create_agent MCP tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("registers and broadcasts a workspace when create_agent creates a worktree", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-worktree-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
const createdWorkspaceIds: string[] = [];
|
||||
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
||||
const startedAgentSetupIds: string[] = [];
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-with-worktree",
|
||||
cwd: config.cwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Worktree agent" },
|
||||
}));
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({
|
||||
paseoHome,
|
||||
broadcasts,
|
||||
createdWorkspaceIds,
|
||||
setupContinuations,
|
||||
startedAgentSetupIds,
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
...detachedWorktreeWorkspace(repoDir, {
|
||||
kind: "branch-off",
|
||||
worktreeSlug: "agent-worktree",
|
||||
branchName: "feature/agent-worktree",
|
||||
baseBranch: "main",
|
||||
}),
|
||||
title: "Worktree agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
});
|
||||
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
expect(createdWorkspaceIds).toHaveLength(1);
|
||||
expect(broadcasts[0]).toBe(createdWorkspaceIds[0]);
|
||||
expect(setupContinuations).toEqual(["agent"]);
|
||||
expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]);
|
||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||
const branchName = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: agentCwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(branchName).toBe("feature/agent-worktree");
|
||||
// The agent is stamped with the freshly created worktree's workspaceId so
|
||||
// workspaceId-scoped archive can find and tear it down later.
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringContaining("agent-worktree"),
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: createdWorkspaceIds[0] },
|
||||
);
|
||||
} finally {
|
||||
await removeTempDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a create_agent branch-off worktree without invoking the legacy metadata branch rename", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-name-context-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => {
|
||||
throw new Error("agent metadata branch rename should not run");
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-auto-named-worktree",
|
||||
cwd: config.cwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Worktree agent" },
|
||||
}));
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees"
|
||||
>,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
...detachedWorktreeWorkspace(repoDir, {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
}),
|
||||
title: "Worktree agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Fix workspace creation naming",
|
||||
});
|
||||
|
||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||
const initialBranch = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: agentCwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(initialBranch).not.toBe("");
|
||||
expect(initialBranch).not.toBe("main");
|
||||
await waitForUnexpectedWorkspaceNamingSideEffects();
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
} finally {
|
||||
await removeTempDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-titles and renames an agent-created branch-off worktree from the initial prompt", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-auto-title-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
const createdWorkspaceIds: string[] = [];
|
||||
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-auto-titled-worktree",
|
||||
cwd: config.cwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Agent title" },
|
||||
}));
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({
|
||||
paseoHome,
|
||||
broadcasts,
|
||||
createdWorkspaceIds,
|
||||
workspaceRecords,
|
||||
generateWorkspaceName: async () => ({
|
||||
title: "Workspace Auto Title Flow",
|
||||
branch: "workspace-auto-title-flow",
|
||||
}),
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
...detachedWorktreeWorkspace(repoDir, {
|
||||
kind: "branch-off",
|
||||
branchName: "feat/placeholder-auto-title",
|
||||
baseBranch: "main",
|
||||
}),
|
||||
title: "Agent title",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Build a workspace auto title flow",
|
||||
});
|
||||
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
|
||||
await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Workspace Auto Title Flow");
|
||||
|
||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||
const workspace = workspaceRecords.get(workspaceId);
|
||||
const branchName = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: agentCwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
const metadata = readPaseoWorktreeMetadata(agentCwd);
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: "feat/placeholder-auto-title",
|
||||
},
|
||||
});
|
||||
expect(branchName).toBe("workspace-auto-title-flow");
|
||||
expect(workspace).toMatchObject({
|
||||
title: "Workspace Auto Title Flow",
|
||||
branch: "workspace-auto-title-flow",
|
||||
});
|
||||
} finally {
|
||||
await removeTempDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a manual workspace title when agent-created worktree naming finishes later", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-manual-title-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
const createdWorkspaceIds: string[] = [];
|
||||
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-manual-title-worktree",
|
||||
cwd: config.cwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Agent title" },
|
||||
}));
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({
|
||||
paseoHome,
|
||||
broadcasts,
|
||||
createdWorkspaceIds,
|
||||
workspaceRecords,
|
||||
generateWorkspaceName: async () => ({
|
||||
title: "Generated Manual Race Title",
|
||||
branch: "generated-manual-race-title",
|
||||
}),
|
||||
}),
|
||||
workspaceRegistry: {
|
||||
get: async (workspaceId) => workspaceRecords.get(workspaceId) ?? null,
|
||||
upsert: async (record) => {
|
||||
workspaceRecords.set(record.workspaceId, record);
|
||||
},
|
||||
},
|
||||
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
|
||||
broadcasts.push(...workspaceIds);
|
||||
},
|
||||
logger,
|
||||
});
|
||||
const createAgentTool = registeredTool(server, "create_agent");
|
||||
await createAgentTool.handler({
|
||||
...detachedWorktreeWorkspace(repoDir, {
|
||||
kind: "branch-off",
|
||||
branchName: "feat/manual-title-placeholder",
|
||||
baseBranch: "main",
|
||||
}),
|
||||
title: "Agent title",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Keep the manually renamed workspace title",
|
||||
});
|
||||
const renameWorkspaceTool = registeredTool(server, "rename_workspace");
|
||||
await renameWorkspaceTool.handler({
|
||||
workspaceId: createdWorkspaceIds[0],
|
||||
title: "Manual Workspace Title",
|
||||
});
|
||||
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
|
||||
await waitForWorkspaceBranch(workspaceRecords, workspaceId, "generated-manual-race-title");
|
||||
|
||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||
const workspace = workspaceRecords.get(workspaceId);
|
||||
const branchName = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: agentCwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
expect(branchName).toBe("generated-manual-race-title");
|
||||
expect(workspace).toMatchObject({
|
||||
title: "Manual Workspace Title",
|
||||
branch: "generated-manual-race-title",
|
||||
});
|
||||
} finally {
|
||||
await removeTempDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses create_agent title for the agent while still auto-titling the worktree workspace", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-title-workspace-title-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
const createdWorkspaceIds: string[] = [];
|
||||
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-explicit-title-worktree",
|
||||
cwd: config.cwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Explicit Agent Title" },
|
||||
}));
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({
|
||||
paseoHome,
|
||||
broadcasts,
|
||||
createdWorkspaceIds,
|
||||
workspaceRecords,
|
||||
generateWorkspaceName: async () => ({
|
||||
title: "Generated Workspace Title",
|
||||
branch: "generated-workspace-title",
|
||||
}),
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
...detachedWorktreeWorkspace(repoDir, {
|
||||
kind: "branch-off",
|
||||
branchName: "feat/agent-title-placeholder",
|
||||
baseBranch: "main",
|
||||
}),
|
||||
title: "Explicit Agent Title",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Generate the workspace title anyway",
|
||||
});
|
||||
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
|
||||
await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Generated Workspace Title");
|
||||
|
||||
const workspace = workspaceRecords.get(workspaceId);
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Explicit Agent Title",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId },
|
||||
);
|
||||
expect(workspace).toMatchObject({
|
||||
title: "Generated Workspace Title",
|
||||
branch: "generated-workspace-title",
|
||||
});
|
||||
} finally {
|
||||
await removeTempDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-titles an agent-created directory workspace from the initial prompt", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-directory-auto-title-"));
|
||||
@@ -2630,221 +2131,12 @@ describe("create_agent MCP tool", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-titles without renaming a create_agent checkout worktree from the initial prompt", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-checkout-name-context-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
const createdWorkspaceIds: string[] = [];
|
||||
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
|
||||
let generateCalls = 0;
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => {
|
||||
throw new Error("agent metadata branch rename should not run");
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["checkout", "-b", "existing-feature"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await writeFile(join(repoDir, "feature.txt"), "feature\n");
|
||||
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-checkout-worktree",
|
||||
cwd: config.cwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Checkout agent" },
|
||||
}));
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({
|
||||
paseoHome,
|
||||
broadcasts,
|
||||
createdWorkspaceIds,
|
||||
workspaceRecords,
|
||||
generateWorkspaceName: async () => {
|
||||
generateCalls += 1;
|
||||
return {
|
||||
title: "Generated Checkout Workspace Title",
|
||||
branch: "generated-checkout-workspace-title",
|
||||
};
|
||||
},
|
||||
}),
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees"
|
||||
>,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
...detachedWorktreeWorkspace(repoDir, {
|
||||
kind: "checkout-branch",
|
||||
branch: "existing-feature",
|
||||
}),
|
||||
title: "Checkout agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Rename this checkout from the prompt",
|
||||
});
|
||||
|
||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
|
||||
await waitForWorkspaceTitle(
|
||||
workspaceRecords,
|
||||
workspaceId,
|
||||
"Generated Checkout Workspace Title",
|
||||
);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("existing-feature");
|
||||
expect(workspaceRecords.get(workspaceId)).toMatchObject({
|
||||
title: "Generated Checkout Workspace Title",
|
||||
branch: "existing-feature",
|
||||
});
|
||||
expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "main",
|
||||
});
|
||||
expect(generateCalls).toBe(1);
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(broadcasts).toEqual([workspaceId, workspaceId]);
|
||||
} finally {
|
||||
await removeTempDir(tempDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes create_agent GitHub PR worktrees through workspace creation without metadata branch rename", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const startedAgentSetupIds: string[] = [];
|
||||
const createPaseoWorktree = vi.fn(
|
||||
async (
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: Parameters<CreatePaseoWorktreeWorkflowFn>[1],
|
||||
) => ({
|
||||
worktree: {
|
||||
branchName: "pr-123",
|
||||
worktreePath: "/tmp/worktrees/pr-123",
|
||||
},
|
||||
intent: {
|
||||
kind: "checkout-github-pr" as const,
|
||||
githubPrNumber: input.githubPrNumber ?? 123,
|
||||
headRef: "pr-123",
|
||||
baseRefName: "main",
|
||||
},
|
||||
workspace: {
|
||||
workspaceId: "ws-pr-123",
|
||||
projectId: REPO_CWD,
|
||||
cwd: "/tmp/worktrees/pr-123",
|
||||
kind: "worktree" as const,
|
||||
displayName: "pr-123",
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
repoRoot: REPO_CWD,
|
||||
created: true,
|
||||
...(options?.setupContinuation?.kind === "agent"
|
||||
? {
|
||||
setupContinuation: {
|
||||
kind: "agent" as const,
|
||||
startAfterAgentCreate: ({ agentId }: { agentId: string }) => {
|
||||
startedAgentSetupIds.push(agentId);
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => {
|
||||
throw new Error("agent metadata branch rename should not run");
|
||||
}),
|
||||
};
|
||||
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
|
||||
id: "agent-pr-worktree",
|
||||
cwd: config.cwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "PR agent" },
|
||||
}));
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
createPaseoWorktree,
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees"
|
||||
>,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await tool.handler({
|
||||
...detachedWorktreeWorkspace(REPO_CWD, {
|
||||
kind: "checkout-pr",
|
||||
githubPrNumber: 123,
|
||||
}),
|
||||
title: "PR agent",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Rename this PR branch from prompt",
|
||||
});
|
||||
|
||||
expect(createPaseoWorktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
githubPrNumber: 123,
|
||||
firstAgentContext: { prompt: "Rename this PR branch from prompt" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
setupContinuation: expect.objectContaining({ kind: "agent" }),
|
||||
}),
|
||||
);
|
||||
expect(startedAgentSetupIds).toEqual(["agent-pr-worktree"]);
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/tmp/worktrees/pr-123" }),
|
||||
undefined,
|
||||
{ workspaceId: "ws-pr-123" },
|
||||
);
|
||||
await waitForUnexpectedWorkspaceNamingSideEffects();
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a worktree-isolated workspace", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
|
||||
const repoDir = join(tempDir, "repo");
|
||||
const paseoHome = join(tempDir, ".paseo");
|
||||
const broadcasts: string[] = [];
|
||||
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
@@ -2875,7 +2167,6 @@ describe("create_agent MCP tool", () => {
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({
|
||||
paseoHome,
|
||||
broadcasts,
|
||||
setupContinuations,
|
||||
}),
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
@@ -2896,7 +2187,6 @@ describe("create_agent MCP tool", () => {
|
||||
expect(response.structuredContent.cwd).toContain("tool-worktree");
|
||||
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(setupContinuations).toEqual([undefined]);
|
||||
expect(broadcasts).toHaveLength(1);
|
||||
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||
} finally {
|
||||
@@ -7378,3 +6668,40 @@ describe("agent snapshot MCP serialization", () => {
|
||||
expect(content).not.toContain("first answer");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects hidden agent-worktree placement and directs callers to workspace creation", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await expect(
|
||||
tool.handler({
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "Worktree agent",
|
||||
initialPrompt: "test",
|
||||
relationship: { kind: "detached" },
|
||||
workspace: {
|
||||
kind: "create",
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd: process.cwd(),
|
||||
target: { kind: "branch-off", worktreeSlug: "legacy" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("create_workspace");
|
||||
await expect(
|
||||
tool.handler({
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "Worktree agent",
|
||||
initialPrompt: "test",
|
||||
cwd: process.cwd(),
|
||||
worktreeName: "legacy",
|
||||
baseBranch: "main",
|
||||
}),
|
||||
).rejects.toThrow("create_workspace");
|
||||
});
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
|
||||
export interface AppendTimelineItemIfAgentKnownOptions {
|
||||
agentManager: AgentManager;
|
||||
agentId: string;
|
||||
item: AgentTimelineItem;
|
||||
}
|
||||
|
||||
export async function appendTimelineItemIfAgentKnown(
|
||||
options: AppendTimelineItemIfAgentKnownOptions,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await options.agentManager.appendTimelineItem(options.agentId, options.item);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("Unknown agent")) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function emitLiveTimelineItemIfAgentKnown(
|
||||
options: AppendTimelineItemIfAgentKnownOptions,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await options.agentManager.emitLiveTimelineItem(options.agentId, options.item);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("Unknown agent")) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
requireActiveWorkspaceForArchive,
|
||||
type ArchiveDependencies,
|
||||
} from "../../workspace-archive-service.js";
|
||||
import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js";
|
||||
import { createAgentCommand } from "../create-agent/create.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js";
|
||||
import type { FirstAgentContext } from "../../messages.js";
|
||||
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
|
||||
@@ -1030,43 +1030,6 @@ function createToolCatalog(
|
||||
.strict()
|
||||
.describe("Create a root agent that does not appear in this agent's subagent track."),
|
||||
]);
|
||||
const AgentCreateWorktreeTargetInputSchema = z.discriminatedUnion("kind", [
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("branch-off"),
|
||||
worktreeSlug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Optional worktree slug/path label. Omit to let Paseo generate one."),
|
||||
branchName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Optional git branch name. Defaults to the worktree slug."),
|
||||
baseBranch: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Optional base branch. Defaults to the repository default branch."),
|
||||
})
|
||||
.strict()
|
||||
.describe("Create a new branch in a new Paseo worktree."),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("checkout-branch"),
|
||||
branch: z.string().min(1).describe("Existing branch to check out."),
|
||||
})
|
||||
.strict()
|
||||
.describe("Check out an existing branch in a new Paseo worktree."),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("checkout-pr"),
|
||||
githubPrNumber: z.number().int().positive().describe("GitHub pull request number."),
|
||||
})
|
||||
.strict()
|
||||
.describe("Check out a GitHub pull request in a new Paseo worktree."),
|
||||
]);
|
||||
const AgentWorkspaceInputSchema = z.discriminatedUnion("kind", [
|
||||
z
|
||||
.object({
|
||||
@@ -1099,16 +1062,6 @@ function createToolCatalog(
|
||||
.describe("Optional directory path. Defaults to the caller's cwd."),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("worktree"),
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional source repository. Defaults to the caller's cwd."),
|
||||
target: AgentCreateWorktreeTargetInputSchema,
|
||||
})
|
||||
.strict(),
|
||||
]),
|
||||
})
|
||||
.strict()
|
||||
@@ -1195,32 +1148,6 @@ function createToolCatalog(
|
||||
.record(z.string(), z.unknown())
|
||||
.optional()
|
||||
.describe("Legacy feature values. Prefer settings.features."),
|
||||
worktreeName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Legacy worktree slug. Prefer workspace.source.target.worktreeSlug."),
|
||||
branchName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Legacy branch name. Prefer workspace.source.target.branchName."),
|
||||
baseBranch: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Legacy base branch. Prefer workspace.source.target.baseBranch."),
|
||||
refName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Legacy branch/ref to check out. Prefer workspace.source.target.branch."),
|
||||
githubPrNumber: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."),
|
||||
};
|
||||
const createAgentInputSchema = z
|
||||
.object(scope === "agent" ? agentToAgentInputSchema : canonicalTopLevelInputSchema)
|
||||
@@ -1560,7 +1487,7 @@ function createToolCatalog(
|
||||
},
|
||||
async (args: unknown) => {
|
||||
const resolvedArgs = await resolveCreateAgentToolArgs(args);
|
||||
const { parsedArgs, worktree } = resolvedArgs;
|
||||
const { parsedArgs } = resolvedArgs;
|
||||
let notifyOnFinish: boolean;
|
||||
if (resolvedArgs.kind === "agent-scoped") {
|
||||
notifyOnFinish = parsedArgs.notifyOnFinish;
|
||||
@@ -1600,7 +1527,6 @@ function createToolCatalog(
|
||||
detached: resolvedArgs.detached,
|
||||
callerAgentId,
|
||||
callerContext,
|
||||
worktree,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1634,7 +1560,6 @@ function createToolCatalog(
|
||||
detached: boolean;
|
||||
cwd: string | undefined;
|
||||
workspaceId: string | undefined;
|
||||
worktree: CreateAgentFromMcpInput["worktree"];
|
||||
}
|
||||
| {
|
||||
kind: "top-level";
|
||||
@@ -1642,16 +1567,38 @@ function createToolCatalog(
|
||||
detached: boolean;
|
||||
cwd: string | undefined;
|
||||
workspaceId: string | undefined;
|
||||
worktree: CreateAgentFromMcpInput["worktree"];
|
||||
};
|
||||
|
||||
async function resolveCreateAgentToolArgs(args: unknown): Promise<ResolvedCreateAgentToolArgs> {
|
||||
const placement = args as {
|
||||
worktreeName?: unknown;
|
||||
branchName?: unknown;
|
||||
baseBranch?: unknown;
|
||||
refName?: unknown;
|
||||
githubPrNumber?: unknown;
|
||||
workspace?: { source?: { kind?: unknown } };
|
||||
};
|
||||
if (
|
||||
placement &&
|
||||
(placement.workspace?.source?.kind === "worktree" ||
|
||||
[
|
||||
placement.worktreeName,
|
||||
placement.branchName,
|
||||
placement.baseBranch,
|
||||
placement.refName,
|
||||
placement.githubPrNumber,
|
||||
].some((value) => value !== undefined))
|
||||
) {
|
||||
throw new PaseoToolInputError(
|
||||
"Agent worktree placement was removed. Use create_workspace first, then create_agent with workspaceId.",
|
||||
);
|
||||
}
|
||||
if (callerAgentId) {
|
||||
if (hasLegacyCreateAgentPlacement(args)) {
|
||||
// COMPAT(nestedCreateAgentPlacement): accept the old relationship/workspace shape without
|
||||
// advertising it to models. Added in v0.2.0; remove after 2027-01-17.
|
||||
const parsed = parseToolArguments(legacyAgentToAgentCreateAgentArgsSchema, args);
|
||||
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, {
|
||||
const { cwd, workspaceId } = await resolveCreateAgentWorkspace(parsed.workspace, {
|
||||
prompt: parsed.initialPrompt,
|
||||
});
|
||||
return {
|
||||
@@ -1660,7 +1607,6 @@ function createToolCatalog(
|
||||
detached: parsed.relationship.kind === "detached",
|
||||
cwd,
|
||||
workspaceId,
|
||||
worktree,
|
||||
};
|
||||
}
|
||||
const parsed = parseToolArguments(agentToAgentCreateAgentArgsSchema, args);
|
||||
@@ -1673,7 +1619,6 @@ function createToolCatalog(
|
||||
detached: false,
|
||||
cwd,
|
||||
workspaceId,
|
||||
worktree: undefined,
|
||||
};
|
||||
}
|
||||
if (hasLegacyCreateAgentPlacement(args)) {
|
||||
@@ -1689,17 +1634,15 @@ function createToolCatalog(
|
||||
if (!parsedArgs.workspace) {
|
||||
throw new PaseoToolInputError("Legacy create_agent placement could not be resolved");
|
||||
}
|
||||
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(
|
||||
parsedArgs.workspace,
|
||||
{ prompt: parsedArgs.initialPrompt },
|
||||
);
|
||||
const { cwd, workspaceId } = await resolveCreateAgentWorkspace(parsedArgs.workspace, {
|
||||
prompt: parsedArgs.initialPrompt,
|
||||
});
|
||||
return {
|
||||
kind: "top-level",
|
||||
parsedArgs,
|
||||
detached: true,
|
||||
cwd,
|
||||
workspaceId,
|
||||
worktree,
|
||||
};
|
||||
}
|
||||
const parsedArgs = parseToolArguments(canonicalTopLevelCreateAgentArgsSchema, args);
|
||||
@@ -1713,7 +1656,6 @@ function createToolCatalog(
|
||||
detached: false,
|
||||
cwd,
|
||||
workspaceId,
|
||||
worktree: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1770,18 +1712,7 @@ function createToolCatalog(
|
||||
function normalizeTopLevelCreateAgentArgs(
|
||||
args: LegacyTopLevelCreateAgentArgs,
|
||||
): LegacyTopLevelCreateAgentArgs {
|
||||
const {
|
||||
cwd,
|
||||
mode,
|
||||
thinking,
|
||||
features,
|
||||
worktreeName,
|
||||
branchName,
|
||||
baseBranch,
|
||||
refName,
|
||||
githubPrNumber,
|
||||
...canonicalCandidate
|
||||
} = args;
|
||||
const { cwd, mode, thinking, features, ...canonicalCandidate } = args;
|
||||
const settings = {
|
||||
...canonicalCandidate.settings,
|
||||
...(mode ? { modeId: mode } : {}),
|
||||
@@ -1804,29 +1735,10 @@ function createToolCatalog(
|
||||
throw new PaseoToolInputError("cwd is required for legacy top-level create_agent calls");
|
||||
}
|
||||
|
||||
const legacyWorktreeTarget = resolveLegacyCreateAgentWorktreeTarget({
|
||||
worktreeName,
|
||||
branchName,
|
||||
baseBranch,
|
||||
refName,
|
||||
githubPrNumber,
|
||||
});
|
||||
const workspace = legacyWorktreeTarget
|
||||
? {
|
||||
kind: "create" as const,
|
||||
source: {
|
||||
kind: "worktree" as const,
|
||||
cwd,
|
||||
target: legacyWorktreeTarget,
|
||||
},
|
||||
}
|
||||
: {
|
||||
kind: "create" as const,
|
||||
source: {
|
||||
kind: "directory" as const,
|
||||
path: cwd,
|
||||
},
|
||||
};
|
||||
const workspace = {
|
||||
kind: "create" as const,
|
||||
source: { kind: "directory" as const, path: cwd },
|
||||
};
|
||||
|
||||
return parseToolArguments(legacyTopLevelCreateAgentArgsSchema, {
|
||||
...canonicalCandidate,
|
||||
@@ -1836,39 +1748,6 @@ function createToolCatalog(
|
||||
});
|
||||
}
|
||||
|
||||
function resolveLegacyCreateAgentWorktreeTarget(input: {
|
||||
worktreeName?: string;
|
||||
branchName?: string;
|
||||
baseBranch?: string;
|
||||
refName?: string;
|
||||
githubPrNumber?: number;
|
||||
}): z.infer<typeof AgentCreateWorktreeTargetInputSchema> | null {
|
||||
if (input.githubPrNumber !== undefined) {
|
||||
return {
|
||||
kind: "checkout-pr",
|
||||
githubPrNumber: input.githubPrNumber,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.refName) {
|
||||
return {
|
||||
kind: "checkout-branch",
|
||||
branch: input.refName,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.worktreeName || input.branchName || input.baseBranch) {
|
||||
return {
|
||||
kind: "branch-off",
|
||||
worktreeSlug: input.worktreeName,
|
||||
branchName: input.branchName,
|
||||
baseBranch: input.baseBranch,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveCreateAgentWorkspace(
|
||||
workspace:
|
||||
| LegacyAgentToAgentCreateAgentArgs["workspace"]
|
||||
@@ -1877,7 +1756,6 @@ function createToolCatalog(
|
||||
): Promise<{
|
||||
cwd: string | undefined;
|
||||
workspaceId: string | undefined;
|
||||
worktree: CreateAgentFromMcpInput["worktree"];
|
||||
}> {
|
||||
if (workspace.kind === "current") {
|
||||
if (!callerAgentId) {
|
||||
@@ -1890,7 +1768,6 @@ function createToolCatalog(
|
||||
return {
|
||||
cwd: workspace.cwd,
|
||||
workspaceId: callerAgent.workspaceId,
|
||||
worktree: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1916,7 +1793,6 @@ function createToolCatalog(
|
||||
return {
|
||||
cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
worktree: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1930,42 +1806,12 @@ function createToolCatalog(
|
||||
workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, {
|
||||
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
|
||||
}),
|
||||
worktree: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const cwd = resolveScopedCwd(workspace.source.cwd, { required: true });
|
||||
return {
|
||||
cwd,
|
||||
workspaceId: undefined,
|
||||
worktree: resolveCreateAgentWorktree(workspace.source.target),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCreateAgentWorktree(
|
||||
target: z.infer<typeof AgentCreateWorktreeTargetInputSchema>,
|
||||
): NonNullable<CreateAgentFromMcpInput["worktree"]> {
|
||||
switch (target.kind) {
|
||||
case "branch-off":
|
||||
return {
|
||||
action: "branch-off",
|
||||
worktreeName: target.worktreeSlug,
|
||||
branchName: target.branchName,
|
||||
baseBranch: target.baseBranch,
|
||||
};
|
||||
case "checkout-branch":
|
||||
return {
|
||||
action: "checkout",
|
||||
refName: target.branch,
|
||||
};
|
||||
case "checkout-pr":
|
||||
return {
|
||||
action: "checkout",
|
||||
githubPrNumber: target.githubPrNumber,
|
||||
};
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
throw new PaseoToolInputError(
|
||||
"Use create_workspace first, then create_agent with workspaceId.",
|
||||
);
|
||||
}
|
||||
|
||||
const PROMPTED_AGENT_NOTIFICATION_GUIDANCE =
|
||||
|
||||
@@ -200,7 +200,7 @@ async function createPaseoOwnedWorktree(
|
||||
baseBranch: "main",
|
||||
branchName: worktreeSlug,
|
||||
},
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ async function createWorkspaceJourney() {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "workspace",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "workspace" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const workspaceId = "workspace-under-test";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
|
||||
import { WebSocket } from "ws";
|
||||
import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test, vi } from "vitest";
|
||||
@@ -395,3 +395,181 @@ test.each(["agent", "workspace"] as const)(
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
test("combined worktree and agent creation owns setup on the workspace and replays one identity", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "workspace-owned-setup-"));
|
||||
const daemon = await createCreationDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await initializeSetupRepository(directory);
|
||||
await client.connect();
|
||||
const input = {
|
||||
idempotencyKey: "combined-workspace-setup",
|
||||
source: { kind: "worktree" as const, cwd: directory, worktreeSlug: "combined-setup" },
|
||||
agent: { config: { provider: "codex", cwd: directory } },
|
||||
};
|
||||
const first = await client.createWorkspace(input);
|
||||
const replay = await client.createWorkspace(input);
|
||||
expect(first.error).toBeNull();
|
||||
expect(replay.error).toBeNull();
|
||||
expect(replay.workspace?.id).toBe(first.workspace?.id);
|
||||
expect(replay.agent?.id).toBe(first.agent?.id);
|
||||
if (!first.workspace || !first.agent) throw new Error("Expected combined creation resources");
|
||||
const observation = client.observeWorkspaceSetup(first.workspace.id);
|
||||
await observation.ready;
|
||||
await expect
|
||||
.poll(async () => (await client.fetchWorkspaceSetupStatus(first.workspace!.id)).snapshot, {
|
||||
timeout: 10000,
|
||||
})
|
||||
.toMatchObject({
|
||||
status: "completed",
|
||||
detail: {
|
||||
log: "",
|
||||
commands: [
|
||||
expect.objectContaining({
|
||||
log: expect.stringContaining("workspace setup output\n"),
|
||||
status: "completed",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await observation.release();
|
||||
expect(await readFile(join(first.workspace.workspaceDirectory!, "setup-runs"), "utf8")).toBe(
|
||||
"once\n",
|
||||
);
|
||||
const timeline = await client.fetchAgentTimeline(first.agent.id);
|
||||
expect(
|
||||
timeline.entries.map((entry) => entry.item).filter((item) => item.type === "tool_call"),
|
||||
).toEqual([]);
|
||||
expect(first.agent.workspaceId).toBe(first.workspace.id);
|
||||
const conflict = await client.createWorkspace({
|
||||
...input,
|
||||
source: { ...input.source, worktreeSlug: "different-intent" },
|
||||
});
|
||||
expect(conflict.error).toBe("workspace_request_key_conflict");
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test.each(["create_agent_request", "agent.create.request"] as const)(
|
||||
"%s rejects agent worktree placement at the wire boundary",
|
||||
async (type) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "agent-worktree-rejected-"));
|
||||
const daemon = await createCreationDaemon();
|
||||
const peer = await connectCreationPeer(daemon.port);
|
||||
try {
|
||||
for (const placement of [{ git: { createWorktree: true } }, { worktreeName: "legacy" }]) {
|
||||
const response = await peer.request({
|
||||
type,
|
||||
requestId: randomUUID(),
|
||||
config: { provider: "codex", cwd: directory },
|
||||
...placement,
|
||||
});
|
||||
if (response.type !== "agent.create.response" && response.type !== "status")
|
||||
throw new Error("Expected agent creation response");
|
||||
const error = response.payload.error;
|
||||
expect(error).toContain("createWorkspace");
|
||||
}
|
||||
} finally {
|
||||
peer.close();
|
||||
await daemon.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function initializeSetupRepository(directory: string): Promise<void> {
|
||||
execFileSync("git", ["init", "-b", "main", directory], { stdio: "pipe" });
|
||||
await writeFile(
|
||||
join(directory, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [
|
||||
"node -e \"require('fs').appendFileSync('setup-runs', 'once\\n'); console.log('workspace setup output')\"",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: directory });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
"setup fixture",
|
||||
],
|
||||
{ cwd: directory, stdio: "pipe" },
|
||||
);
|
||||
}
|
||||
|
||||
test("Hub-shaped keyed agent worktree creation uses workspace setup and preserves resource identity", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "keyed-agent-workspace-setup-"));
|
||||
const daemon = await createCreationDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const peer = await connectCreationPeer(daemon.port);
|
||||
try {
|
||||
await initializeSetupRepository(directory);
|
||||
await client.connect();
|
||||
const input = {
|
||||
type: "create_agent_request" as const,
|
||||
idempotencyKey: "keyed-agent-worktree",
|
||||
config: { provider: "codex", cwd: directory },
|
||||
worktree: { mode: "branch-off" as const, newBranch: "keyed-agent-setup", base: "main" },
|
||||
};
|
||||
const first = await peer.request({ ...input, requestId: "first" });
|
||||
const replay = await peer.request({ ...input, requestId: "replay" });
|
||||
if (
|
||||
first.type !== "status" ||
|
||||
first.payload.status !== "agent_created" ||
|
||||
replay.type !== "status" ||
|
||||
replay.payload.status !== "agent_created"
|
||||
)
|
||||
throw new Error("Expected agent creation responses");
|
||||
expect(replay.payload.agent.id).toBe(first.payload.agent.id);
|
||||
expect(replay.payload.agent.workspaceId).toBe(first.payload.agent.workspaceId);
|
||||
const agent = first.payload.agent;
|
||||
if (!agent.workspaceId) throw new Error("Expected agent workspace ownership");
|
||||
await expect
|
||||
.poll(async () => (await client.fetchWorkspaceSetupStatus(agent.workspaceId!)).snapshot, {
|
||||
timeout: 10000,
|
||||
})
|
||||
.toMatchObject({
|
||||
status: "completed",
|
||||
detail: {
|
||||
log: "",
|
||||
commands: [
|
||||
expect.objectContaining({ log: expect.stringContaining("workspace setup output\n") }),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(await readFile(join(agent.cwd, "setup-runs"), "utf8")).toBe("once\n");
|
||||
expect(
|
||||
(await client.fetchAgentTimeline(agent.id)).entries
|
||||
.map((entry) => entry.item)
|
||||
.filter((item) => item.type === "tool_call"),
|
||||
).toEqual([]);
|
||||
const conflict = await peer.request({
|
||||
...input,
|
||||
requestId: "conflict",
|
||||
worktree: { ...input.worktree, newBranch: "different-intent" },
|
||||
});
|
||||
expect(conflict).toMatchObject({
|
||||
type: "status",
|
||||
payload: { status: "agent_create_failed", error: "agent_request_key_conflict" },
|
||||
});
|
||||
} finally {
|
||||
peer.close();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
+2
-2
@@ -106,14 +106,14 @@ githubTest(
|
||||
cwd: repository,
|
||||
worktreeSlug: "merged-feature",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "merged-feature" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: context.daemon.paseoHome,
|
||||
});
|
||||
const unrelated = await createWorktree({
|
||||
cwd: repository,
|
||||
worktreeSlug: "never-pushed-unrelated",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "never-pushed-unrelated" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: context.daemon.paseoHome,
|
||||
});
|
||||
const mergedWorkspace = await context.client.createWorkspace({
|
||||
|
||||
@@ -215,7 +215,7 @@ describe("daemon checkout PR merge loop", () => {
|
||||
baseBranch: "main",
|
||||
branchName: "merge-pr-squash",
|
||||
},
|
||||
runSetup: true,
|
||||
|
||||
paseoHome: ctx.daemon.paseoHome,
|
||||
});
|
||||
worktreePath = worktree.worktreePath;
|
||||
|
||||
@@ -20,7 +20,7 @@ interface LegacyCreateWorktreeTestOptions {
|
||||
cwd: string;
|
||||
baseBranch: string;
|
||||
worktreeSlug: string;
|
||||
runSetup?: boolean;
|
||||
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ function createLegacyWorktreeForTest(
|
||||
baseBranch: options.baseBranch,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,74 +12,14 @@ import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
|
||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||
import { deriveWorktreeProjectHash } from "../../utils/worktree.js";
|
||||
import { isPlatform } from "../../test-utils/platform.js";
|
||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
import type { SessionOutboundMessage } from "../messages.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||
}
|
||||
|
||||
function findTimelineToolCall(
|
||||
messages: SessionOutboundMessage[],
|
||||
agentId: string,
|
||||
predicate: (item: AgentTimelineItem) => boolean,
|
||||
): AgentTimelineItem | null {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const msg = messages[i];
|
||||
if (msg?.type !== "agent_stream") {
|
||||
continue;
|
||||
}
|
||||
if (msg.payload.agentId !== agentId) {
|
||||
continue;
|
||||
}
|
||||
const event = msg.payload.event as { type?: string; item?: AgentTimelineItem };
|
||||
if (event?.type !== "timeline") {
|
||||
continue;
|
||||
}
|
||||
const item = event.item as AgentTimelineItem;
|
||||
if (item?.type === "tool_call" && predicate(item)) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForTimelineToolCall(
|
||||
messages: SessionOutboundMessage[],
|
||||
agentId: string,
|
||||
predicate: (item: AgentTimelineItem) => boolean,
|
||||
timeoutMs = 10000,
|
||||
): Promise<Extract<AgentTimelineItem, { type: "tool_call" }>> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const existing = findTimelineToolCall(messages, agentId, predicate);
|
||||
if (existing && existing.type === "tool_call") {
|
||||
return existing;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
const recentToolCalls: Array<{ name: string; status?: string; callId?: string }> = [];
|
||||
for (let i = messages.length - 1; i >= 0 && recentToolCalls.length < 10; i -= 1) {
|
||||
const msg = messages[i];
|
||||
if (msg?.type !== "agent_stream") continue;
|
||||
if (msg.payload.agentId !== agentId) continue;
|
||||
const event = msg.payload.event as { type?: string; item?: AgentTimelineItem };
|
||||
if (event?.type !== "timeline") continue;
|
||||
const item = event.item as AgentTimelineItem;
|
||||
if (item?.type !== "tool_call") continue;
|
||||
recentToolCalls.push({ name: item.name, status: item.status, callId: item.callId });
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for timeline tool_call (${agentId}). Recent tool_calls: ${JSON.stringify(
|
||||
recentToolCalls,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForPathExists(options: {
|
||||
targetPath: string;
|
||||
timeoutMs: number;
|
||||
@@ -121,43 +61,17 @@ async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
interface WorktreeTerminalBootstrapEntry {
|
||||
name: string | null;
|
||||
command: string;
|
||||
status: "started" | "failed";
|
||||
terminalId: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function getWorktreeTerminalBootstrapEntries(
|
||||
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
|
||||
): WorktreeTerminalBootstrapEntry[] | null {
|
||||
const detail = item.detail;
|
||||
if (!detail || detail.type !== "unknown" || !detail.output) {
|
||||
return null;
|
||||
}
|
||||
const output = detail.output as Record<string, unknown>;
|
||||
const terminals = output.terminals;
|
||||
if (!Array.isArray(terminals)) {
|
||||
return null;
|
||||
}
|
||||
return terminals as WorktreeTerminalBootstrapEntry[];
|
||||
}
|
||||
|
||||
// Use gpt-5.4-mini with low thinking preset for faster test execution
|
||||
const CODEX_TEST_MODEL = "gpt-5.4-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
let collector: MessageCollector;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
collector = createMessageCollector(ctx.client);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
collector.unsubscribe();
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
@@ -423,7 +337,7 @@ test("returns isGit false for non-git directory", async () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000); // 1 minute timeout
|
||||
|
||||
test("runs paseo.json setup asynchronously and reports status via timeline tool_call", async () => {
|
||||
test("runs paseo.json setup asynchronously and reports workspace status", async () => {
|
||||
const repoRoot = tmpCwd();
|
||||
|
||||
const { execSync } = await import("child_process");
|
||||
@@ -461,13 +375,7 @@ test("runs paseo.json setup asynchronously and reports status via timeline tool_
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd: repoRoot,
|
||||
title: "Async Worktree Setup Test",
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
baseBranch: "main",
|
||||
newBranchName: "async-setup-test",
|
||||
worktreeSlug: "async-setup-test",
|
||||
},
|
||||
worktree: { mode: "branch-off", newBranch: "async-setup-test", base: "main" },
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "createAgent should not block on setup",
|
||||
@@ -478,19 +386,11 @@ test("runs paseo.json setup asynchronously and reports status via timeline tool_
|
||||
|
||||
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
|
||||
|
||||
const completed = await waitForTimelineToolCall(
|
||||
collector.messages,
|
||||
agent.id,
|
||||
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
|
||||
20000,
|
||||
);
|
||||
|
||||
expect(completed.callId).toBeTruthy();
|
||||
expect(completed.detail.type).toBe("worktree_setup");
|
||||
if (completed.detail.type === "worktree_setup") {
|
||||
expect(completed.detail.commands.length).toBeGreaterThan(0);
|
||||
expect(completed.detail.log.length).toBeGreaterThan(0);
|
||||
}
|
||||
const completed = await waitForWorkspaceSetup(agent.workspaceId!, "completed");
|
||||
expect(completed.detail.log).toBe("");
|
||||
expect(completed.detail.commands).toEqual([
|
||||
expect.objectContaining({ status: "completed", exitCode: 0 }),
|
||||
]);
|
||||
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(true);
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
@@ -549,13 +449,7 @@ test("bootstraps configured worktree terminals after setup succeeds", async () =
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd: repoRoot,
|
||||
title: "Async Worktree Setup + Terminals Test",
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
baseBranch: "main",
|
||||
newBranchName: "async-setup-terminals-test",
|
||||
worktreeSlug: "async-setup-terminals-test",
|
||||
},
|
||||
worktree: { mode: "branch-off", newBranch: "async-setup-terminals-test", base: "main" },
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "createAgent should not block on setup",
|
||||
@@ -568,25 +462,12 @@ test("bootstraps configured worktree terminals after setup succeeds", async () =
|
||||
|
||||
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
|
||||
|
||||
await waitForTimelineToolCall(
|
||||
collector.messages,
|
||||
agent.id,
|
||||
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
|
||||
20000,
|
||||
);
|
||||
const terminalsBootstrapToolCall = await waitForTimelineToolCall(
|
||||
collector.messages,
|
||||
agent.id,
|
||||
(item) => item.name === "paseo_worktree_terminals" && item.status === "completed",
|
||||
30000,
|
||||
);
|
||||
const bootstrappedTerminals = getWorktreeTerminalBootstrapEntries(terminalsBootstrapToolCall);
|
||||
expect(bootstrappedTerminals).toBeTruthy();
|
||||
expect(bootstrappedTerminals?.length ?? 0).toBeGreaterThanOrEqual(2);
|
||||
const failedBootstraps =
|
||||
bootstrappedTerminals?.filter((terminal) => terminal.status === "failed") ?? [];
|
||||
expect(failedBootstraps).toEqual([]);
|
||||
|
||||
await waitForWorkspaceSetup(agent.workspaceId!, "completed");
|
||||
await expect
|
||||
.poll(async () => (await ctx.client.listTerminals(agent.cwd)).terminals.length, {
|
||||
timeout: 30000,
|
||||
})
|
||||
.toBe(2);
|
||||
const list = await ctx.client.listTerminals(agent.cwd);
|
||||
expect(list.error).toBeUndefined();
|
||||
expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true);
|
||||
@@ -639,7 +520,7 @@ test("bootstraps configured worktree terminals after setup succeeds", async () =
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
test("reports failures via timeline tool_call without deleting the created worktree", async () => {
|
||||
test("reports workspace setup failures without deleting the created worktree", async () => {
|
||||
const repoRoot = tmpCwd();
|
||||
|
||||
const { execSync } = await import("child_process");
|
||||
@@ -687,13 +568,7 @@ test("reports failures via timeline tool_call without deleting the created workt
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd: repoRoot,
|
||||
title: "Async Worktree Setup Failure Test",
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
baseBranch: "main",
|
||||
newBranchName: "async-setup-failure-test",
|
||||
worktreeSlug: "async-setup-failure-test",
|
||||
},
|
||||
worktree: { mode: "branch-off", newBranch: "async-setup-failure-test", base: "main" },
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "createAgent should not block on failing setup",
|
||||
@@ -702,23 +577,7 @@ test("reports failures via timeline tool_call without deleting the created workt
|
||||
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
|
||||
expect(existsSync(agent.cwd)).toBe(true);
|
||||
|
||||
const started = await waitForTimelineToolCall(
|
||||
collector.messages,
|
||||
agent.id,
|
||||
(item) => item.name === "paseo_worktree_setup" && item.status === "running",
|
||||
10000,
|
||||
);
|
||||
|
||||
const failed = await waitForTimelineToolCall(
|
||||
collector.messages,
|
||||
agent.id,
|
||||
(item) =>
|
||||
item.name === "paseo_worktree_setup" &&
|
||||
item.callId === started.callId &&
|
||||
item.status === "failed",
|
||||
20000,
|
||||
);
|
||||
|
||||
const failed = await waitForWorkspaceSetup(agent.workspaceId!, "failed");
|
||||
expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true);
|
||||
expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false);
|
||||
|
||||
@@ -726,7 +585,8 @@ test("reports failures via timeline tool_call without deleting the created workt
|
||||
if (failed.detail.type === "worktree_setup") {
|
||||
expect(Array.isArray(failed.detail.commands)).toBe(true);
|
||||
expect(failed.detail.commands[0]?.exitCode).toBe(7);
|
||||
expect(failed.detail.log).toContain("Exit 7");
|
||||
expect(failed.detail.log).toBe("");
|
||||
expect(failed.detail.commands[0]?.log).toContain("boom");
|
||||
}
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
@@ -756,13 +616,7 @@ test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", as
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Worktree Agent Test",
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
newBranchName: "worktree-test",
|
||||
worktreeSlug: "worktree-test",
|
||||
baseBranch: "main",
|
||||
},
|
||||
worktree: { mode: "branch-off", newBranch: "worktree-test", base: "main" },
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
@@ -776,7 +630,7 @@ test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", as
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
test("archiving a worktree shuts down its terminals but leaves the worktree on disk", async () => {
|
||||
test("archiving a worktree shuts down terminals and removes its final workspace backing", async () => {
|
||||
const repoRoot = tmpCwd();
|
||||
|
||||
const { execSync } = await import("child_process");
|
||||
@@ -823,13 +677,7 @@ test("archiving a worktree shuts down its terminals but leaves the worktree on d
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd: repoRoot,
|
||||
title: "Worktree Archive Cleanup Test",
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
baseBranch: "main",
|
||||
newBranchName: "archive-cleanup-test",
|
||||
worktreeSlug: "archive-cleanup-test",
|
||||
},
|
||||
worktree: { mode: "branch-off", newBranch: "archive-cleanup-test", base: "main" },
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "createAgent should not block on setup",
|
||||
@@ -858,14 +706,23 @@ test("archiving a worktree shuts down its terminals but leaves the worktree on d
|
||||
expect(archive.success).toBe(true);
|
||||
expect(archive.removedAgents).toContain(agent.id);
|
||||
|
||||
// Archiving tears down the workspace's terminals but never deletes the
|
||||
// worktree from disk and never runs teardown commands — on-disk removal is a
|
||||
// separate, explicit step.
|
||||
expect(existsSync(agent.cwd)).toBe(true);
|
||||
expect(existsSync(teardownMarkerPath)).toBe(false);
|
||||
// The final workspace reference owns teardown and backing-worktree removal.
|
||||
expect(existsSync(agent.cwd)).toBe(false);
|
||||
expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd);
|
||||
|
||||
const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
|
||||
expect(afterArchiveDirectories).not.toContain(agent.cwd);
|
||||
|
||||
rmSync(repoRoot, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
async function waitForWorkspaceSetup(workspaceId: string, status: "completed" | "failed") {
|
||||
await expect
|
||||
.poll(async () => (await ctx.client.fetchWorkspaceSetupStatus(workspaceId)).snapshot?.status, {
|
||||
timeout: 20000,
|
||||
})
|
||||
.toBe(status);
|
||||
const snapshot = (await ctx.client.fetchWorkspaceSetupStatus(workspaceId)).snapshot;
|
||||
if (!snapshot) throw new Error("Expected workspace setup snapshot");
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ test("creates a worktree and registers it in the source workspace project withou
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "feature-one",
|
||||
title: "Feature One",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
@@ -114,7 +114,7 @@ test("refreshes a source project that became Git while creating a worktree", asy
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "project-became-git",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
@@ -148,7 +148,7 @@ test("repairs a legacy source workspace whose project record is missing", async
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "repaired-source",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
@@ -192,7 +192,7 @@ test("uses an equivalent source workspace path when creating a worktree", async
|
||||
{
|
||||
cwd: sourceDir,
|
||||
worktreeSlug: "equivalent-source",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
@@ -221,7 +221,7 @@ test("creates a worktree workspace at the selected project subdirectory", async
|
||||
cwd: sourceDir,
|
||||
projectId: project.projectId,
|
||||
worktreeSlug: "selected-subdirectory",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
@@ -249,7 +249,7 @@ test("seeds an uncommitted exact-project config into the mapped worktree directo
|
||||
{
|
||||
cwd: sourceDir,
|
||||
worktreeSlug: "seed-nested-config",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
@@ -277,7 +277,7 @@ test("does not overwrite a committed exact-project config with source checkout e
|
||||
{
|
||||
cwd: sourceDir,
|
||||
worktreeSlug: "preserve-nested-config",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
@@ -314,7 +314,7 @@ test("removes a new worktree when its ref does not contain the selected project
|
||||
action: "checkout",
|
||||
refName: "without-subproject",
|
||||
worktreeSlug: "missing-subproject",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
@@ -345,7 +345,7 @@ test("removes a new worktree when workspace persistence fails", async () => {
|
||||
cwd: repoDir,
|
||||
projectId: "missing-project",
|
||||
worktreeSlug: "persistence-failure",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
},
|
||||
createDeps(),
|
||||
@@ -373,7 +373,7 @@ test("maps a nested cwd from an existing Paseo worktree into the next worktree",
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "source-worktree",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
@@ -384,7 +384,7 @@ test("maps a nested cwd from an existing Paseo worktree into the next worktree",
|
||||
{
|
||||
cwd: sourceCwd,
|
||||
worktreeSlug: "nested-worktree",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
@@ -408,7 +408,7 @@ test("rejects source checkout planning before creating a worktree", async () =>
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "must-not-create",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
@@ -443,7 +443,7 @@ test("registers a new worktree in the existing root project after the main check
|
||||
cwd: repoDir,
|
||||
projectId: sourceProject.projectId,
|
||||
worktreeSlug: "second-worktree",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
@@ -472,7 +472,7 @@ test("an explicit project FK remains unchanged when its worktree comes from anot
|
||||
cwd: repoDir,
|
||||
projectId: project.projectId,
|
||||
worktreeSlug: "attached-worktree",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
@@ -502,7 +502,7 @@ test.skipIf(isPlatform("win32"))(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "reuse-me",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
},
|
||||
firstDeps,
|
||||
@@ -518,7 +518,7 @@ test.skipIf(isPlatform("win32"))(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "reuse-me",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
},
|
||||
deps,
|
||||
@@ -541,7 +541,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "dazzling-yak",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
@@ -610,7 +610,7 @@ test("falls back to a numeric suffix when the desired branch name already exists
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "dazzling-yak",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
@@ -647,7 +647,7 @@ test("renames the branch even when the app supplies a random placeholder slug",
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "dazzling-yak",
|
||||
firstAgentContext: { prompt: "Investigate the failing login flow" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
@@ -695,7 +695,7 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
|
||||
},
|
||||
],
|
||||
},
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
@@ -740,7 +740,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "dazzling-yak",
|
||||
firstAgentContext: { prompt: "Name this branch" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
@@ -785,7 +785,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
|
||||
cwd: repoDir,
|
||||
action: "checkout",
|
||||
refName: "dev",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
@@ -825,7 +825,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
|
||||
cwd: repoDir,
|
||||
action: "checkout",
|
||||
githubPrNumber: 123,
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
@@ -862,7 +862,7 @@ test("does not mutate registries or broadcast when core worktree creation fails"
|
||||
{
|
||||
cwd: tempDir,
|
||||
worktreeSlug: "not-git",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
@@ -888,7 +888,7 @@ test.skipIf(isPlatform("win32"))(
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "restore-me",
|
||||
source: { kind: "checkout-branch", branchName: "restore-me" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
@@ -903,7 +903,7 @@ test.skipIf(isPlatform("win32"))(
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "restore-me",
|
||||
source: { kind: "checkout-branch", branchName: "restore-me" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -947,7 +947,7 @@ test.skipIf(isPlatform("win32"))(
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "restore-me",
|
||||
source: { kind: "checkout-branch", branchName: "restore-me" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
@@ -970,7 +970,7 @@ test.skipIf(isPlatform("win32"))(
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "restore-me",
|
||||
source: { kind: "checkout-branch", branchName: "restore-me" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toThrow("missing but already registered worktree");
|
||||
@@ -982,7 +982,7 @@ test.skipIf(isPlatform("win32"))(
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "restore-me",
|
||||
source: { kind: "checkout-branch", branchName: "restore-me" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -1010,7 +1010,7 @@ test.skipIf(isPlatform("win32"))(
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "gone-branch",
|
||||
source: { kind: "checkout-branch", branchName: "gone-branch" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "UnknownBranchError" });
|
||||
@@ -1029,7 +1029,7 @@ test.skipIf(isPlatform("win32"))(
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "busy-branch",
|
||||
source: { kind: "checkout-branch", branchName: "busy-branch" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
expect(existsSync(first.worktreePath)).toBe(true);
|
||||
@@ -1038,7 +1038,7 @@ test.skipIf(isPlatform("win32"))(
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "busy-branch-again",
|
||||
source: { kind: "checkout-branch", branchName: "busy-branch" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ async function createPaseoWorktreeWithPriority(
|
||||
{
|
||||
cwd: createdWorktree.repoRoot,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
...(input.runSetup === false ? { teardownCwds: [] } : {}),
|
||||
teardownCwds: [],
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesBaseRoot: input.worktreesRoot,
|
||||
},
|
||||
|
||||
@@ -411,7 +411,7 @@ test("create_agent_request rejects legacy git options before creating a worktree
|
||||
},
|
||||
initialPrompt: "Say done.",
|
||||
}),
|
||||
).rejects.toThrow("worktree cannot be combined with git options");
|
||||
).rejects.toThrow("createWorkspace");
|
||||
|
||||
await expectActiveAgentListEmpty();
|
||||
await expectWorktreeListEmpty(repoDir);
|
||||
@@ -447,72 +447,6 @@ function storedAgentIds(): string[] {
|
||||
.map((entry) => path.basename(entry, ".json"));
|
||||
}
|
||||
|
||||
test.each(["git", "worktreeName"] as const)(
|
||||
"legacy %s worktree creation resolves final workspace visibility without intermediate records",
|
||||
async (placement) => {
|
||||
const cwd = createGitRepo();
|
||||
const config = { ...getFullAccessConfig("codex"), cwd };
|
||||
const backgroundCaller = await ctx.client.createAgent({ config, background: true });
|
||||
const visibleCaller = await ctx.client.createAgent({ config });
|
||||
const cases = [
|
||||
{ name: "review", background: true, expected: true },
|
||||
{ name: "review-child", callerAgentId: backgroundCaller.id, expected: true },
|
||||
{
|
||||
name: "visible-child",
|
||||
callerAgentId: backgroundCaller.id,
|
||||
background: false,
|
||||
expected: false,
|
||||
},
|
||||
{ name: "hidden-child", callerAgentId: visibleCaller.id, background: true, expected: true },
|
||||
{ name: "human-default", expected: false },
|
||||
];
|
||||
const orderedCases =
|
||||
placement === "worktreeName" ? [cases[1]!, cases[0]!, ...cases.slice(2)] : cases;
|
||||
for (const entry of orderedCases) {
|
||||
const before = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } });
|
||||
const child = await ctx.client.createAgent({
|
||||
config,
|
||||
...(entry.callerAgentId ? { callerAgentId: entry.callerAgentId } : {}),
|
||||
...(entry.background !== undefined ? { background: entry.background } : {}),
|
||||
...(placement === "git"
|
||||
? {
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
newBranchName: entry.name,
|
||||
baseBranch: "main",
|
||||
},
|
||||
}
|
||||
: { worktreeName: entry.name }),
|
||||
});
|
||||
const inclusive = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } });
|
||||
const created = inclusive.entries.filter(
|
||||
(workspace) => !before.entries.some((existing) => existing.id === workspace.id),
|
||||
);
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0]).toMatchObject({
|
||||
id: child.workspaceId,
|
||||
workspaceDirectory: child.cwd,
|
||||
background: entry.expected,
|
||||
workspaceKind: "worktree",
|
||||
});
|
||||
expect(child.cwd).not.toBe(cwd);
|
||||
expect(child.labels?.["paseo.parent-agent-id"]).toBe(entry.callerAgentId);
|
||||
const defaultAgents = await ctx.client.fetchAgents();
|
||||
const allAgents = await ctx.client.fetchAgents({ filter: { includeBackground: true } });
|
||||
expect(defaultAgents.entries.some((item) => item.agent.id === child.id)).toBe(
|
||||
!entry.expected,
|
||||
);
|
||||
expect(allAgents.entries.some((item) => item.agent.id === child.id)).toBe(true);
|
||||
const defaultWorkspaces = await ctx.client.fetchWorkspaces();
|
||||
expect(
|
||||
defaultWorkspaces.entries.some((workspace) => workspace.id === child.workspaceId),
|
||||
).toBe(!entry.expected);
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
test("background workspaces keep durable agents addressable through restart and archive", async () => {
|
||||
const cwd = createGitRepo();
|
||||
const config = { ...getFullAccessConfig("codex"), cwd };
|
||||
|
||||
@@ -4694,7 +4694,6 @@ describe("session paseo worktree creation handling", () => {
|
||||
await asSessionInternals(session).createPaseoWorktree({
|
||||
cwd: "/tmp/repo",
|
||||
worktreeSlug: "new-worktree",
|
||||
runSetup: false,
|
||||
});
|
||||
|
||||
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", {
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
type SessionInboundMessage,
|
||||
type SessionOutboundMessage,
|
||||
type ScriptStatusUpdateMessage,
|
||||
type GitSetupOptions,
|
||||
type StartWorkspaceScriptRequest,
|
||||
type WorkspaceScriptListRequest,
|
||||
type WorkspaceScriptStartRequest,
|
||||
@@ -122,10 +121,6 @@ import {
|
||||
updateAgentCommand,
|
||||
} from "./agent/lifecycle-command.js";
|
||||
import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
} from "./agent/timeline-append.js";
|
||||
import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js";
|
||||
import { parsePluginClientId } from "./plugins/plugin-session-identity.js";
|
||||
import { buildAgentForkContextAttachment } from "./agent/activity-curator.js";
|
||||
@@ -249,10 +244,7 @@ import {
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||
import {
|
||||
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
|
||||
normalizeAgentCreationGitOptions,
|
||||
createPaseoWorktreeWorkflow as createWorktreeWorkflow,
|
||||
type CreatePaseoWorktreeSetupContinuationInput,
|
||||
type CreatePaseoWorktreeWorkflowResult,
|
||||
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
|
||||
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
|
||||
@@ -4293,17 +4285,19 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private validatePublicAgentWorkspaceIntent(
|
||||
request: CreateAgentRequestMessage,
|
||||
createsWorktree: boolean,
|
||||
): void {
|
||||
private validatePublicAgentWorkspaceIntent(request: CreateAgentRequestMessage): void {
|
||||
if (request.git !== undefined || request.worktreeName !== undefined) {
|
||||
throw new Error(
|
||||
"Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) to create a workspace with an agent, or create the workspace first and pass workspaceId.",
|
||||
);
|
||||
}
|
||||
if (request.internal)
|
||||
throw new Error(
|
||||
"Public internal agents were removed; create a background workspace instead.",
|
||||
);
|
||||
if (
|
||||
request.background !== undefined &&
|
||||
(request.workspaceId || (request.callerAgentId && !createsWorktree))
|
||||
(request.workspaceId || (request.callerAgentId && !request.worktree))
|
||||
) {
|
||||
throw new Error(
|
||||
"background is workspace creation intent; configure the selected workspace at creation.",
|
||||
@@ -4322,7 +4316,6 @@ export class Session {
|
||||
initialPrompt,
|
||||
clientMessageId,
|
||||
outputSchema,
|
||||
git,
|
||||
worktree,
|
||||
autoArchive,
|
||||
images,
|
||||
@@ -4340,14 +4333,11 @@ export class Session {
|
||||
let createdAgentId: string | null = null;
|
||||
try {
|
||||
const requestedCwd = resolve(config.cwd);
|
||||
const needsRequestedDirectory =
|
||||
Boolean(worktreeName || git || worktree) || (!msg.workspaceId && !msg.callerAgentId);
|
||||
const needsRequestedDirectory = Boolean(worktree) || (!msg.workspaceId && !msg.callerAgentId);
|
||||
if (needsRequestedDirectory && !(await this.filesystem.isDirectory(requestedCwd))) {
|
||||
throw new Error(`Working directory does not exist or is not a directory: ${requestedCwd}`);
|
||||
}
|
||||
// Resolve all supported worktree input forms before provisioning workspace ownership.
|
||||
const gitIntent = normalizeAgentCreationGitOptions(git, worktreeName, worktree);
|
||||
this.validatePublicAgentWorkspaceIntent(msg, gitIntent?.createWorktree === true);
|
||||
this.validatePublicAgentWorkspaceIntent(msg);
|
||||
const trimmedPrompt = initialPrompt?.trim();
|
||||
const { provisionalTitle } = resolveCreateAgentTitles({
|
||||
configTitle: config.title,
|
||||
@@ -4361,9 +4351,8 @@ export class Session {
|
||||
const workspacePromptTitle = resolveFirstAgentPromptTitle(firstAgentContext);
|
||||
const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({
|
||||
cwd: config.cwd,
|
||||
target: gitIntent,
|
||||
target: worktree,
|
||||
firstAgentContext,
|
||||
setupContinuation: worktree ? undefined : this.agentWorktreeSetupContinuation(),
|
||||
background: msg.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(msg.callerAgentId),
|
||||
});
|
||||
@@ -4396,7 +4385,6 @@ export class Session {
|
||||
agentId,
|
||||
config: resolvedIntent.config,
|
||||
workspaceId: resolvedIntent.intent.workspaceId,
|
||||
worktreeName,
|
||||
initialPrompt,
|
||||
source: msg.callerAgentId
|
||||
? { kind: "agent-message", agentId: msg.callerAgentId }
|
||||
@@ -4405,19 +4393,10 @@ export class Session {
|
||||
outputSchema,
|
||||
images,
|
||||
attachments,
|
||||
git,
|
||||
labels: resolvedIntent.intent.labels,
|
||||
env,
|
||||
provisionalTitle,
|
||||
firstAgentContext,
|
||||
buildSessionConfig: async (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
|
||||
createdWorktree
|
||||
? {
|
||||
sessionConfig,
|
||||
setupContinuation: createdWorktree.setupContinuation,
|
||||
createdWorkspaceId: createdWorktree.workspace.workspaceId,
|
||||
}
|
||||
: this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
|
||||
},
|
||||
);
|
||||
createdAgentId = snapshot.id;
|
||||
@@ -4887,58 +4866,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private agentWorktreeSetupContinuation(): CreatePaseoWorktreeSetupContinuationInput {
|
||||
return {
|
||||
kind: "agent",
|
||||
terminalManager: this.terminalManager,
|
||||
appendTimelineItem: ({ agentId, item }) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
agentManager: this.agentManager,
|
||||
agentId,
|
||||
item,
|
||||
}),
|
||||
emitLiveTimelineItem: ({ agentId, item }) =>
|
||||
emitLiveTimelineItemIfAgentKnown({
|
||||
agentManager: this.agentManager,
|
||||
agentId,
|
||||
item,
|
||||
}),
|
||||
logger: this.sessionLogger,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildAgentSessionConfig(
|
||||
config: AgentSessionConfig,
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
): Promise<{
|
||||
sessionConfig: AgentSessionConfig;
|
||||
setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"];
|
||||
createdWorkspaceId?: string;
|
||||
}> {
|
||||
return buildWorktreeAgentSessionConfig(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
sessionLogger: this.sessionLogger,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
createPaseoWorktree: (input, serviceOptions) =>
|
||||
this.createPaseoWorktreeWorkflow(input, {
|
||||
...serviceOptions,
|
||||
setupContinuation: this.agentWorktreeSetupContinuation(),
|
||||
}),
|
||||
checkoutExistingBranch: (cwd, branch) =>
|
||||
this.gitMutation.checkoutExistingBranch(cwd, branch),
|
||||
createBranchFromBase: (params) => this.gitMutation.createBranchFromBase(params),
|
||||
},
|
||||
config,
|
||||
gitOptions,
|
||||
legacyWorktreeName,
|
||||
firstAgentContext,
|
||||
);
|
||||
}
|
||||
|
||||
private isPathWithinRoot(rootPath: string, candidatePath: string): boolean {
|
||||
const resolvedRoot = resolve(rootPath);
|
||||
const resolvedCandidate = resolve(candidatePath);
|
||||
@@ -7400,7 +7327,6 @@ export class Session {
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: {
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
},
|
||||
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||
return createWorktreeWorkflow(
|
||||
|
||||
@@ -5704,7 +5704,7 @@ test("legacy refresh_agent_request restores a real deleted worktree", async () =
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "keep",
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
});
|
||||
@@ -5952,7 +5952,7 @@ test("archive_workspace_request archives a worktree-kind workspace and removes t
|
||||
baseBranch: "main",
|
||||
branchName: "worktree-kind-archive",
|
||||
},
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
|
||||
@@ -1746,7 +1746,6 @@ it("creates a PR from a restored exact base using the host metadata and a forge
|
||||
paseoHome,
|
||||
worktreeSlug: "restored-pr",
|
||||
source: { kind: "branch-off", baseBranch: baseRef, branchName: "restored-pr" },
|
||||
runSetup: false,
|
||||
});
|
||||
writeFileSync(join(created.worktreePath, "feature.txt"), "feature\n");
|
||||
git(created.worktreePath, "add", ".");
|
||||
@@ -1757,7 +1756,6 @@ it("creates a PR from a restored exact base using the host metadata and a forge
|
||||
paseoHome,
|
||||
worktreeSlug: "restored-pr",
|
||||
source: { kind: "restore", branchName: created.branchName, baseRef },
|
||||
runSetup: false,
|
||||
});
|
||||
const requests: Array<Parameters<ForgeService["createPullRequest"]>[0]> = [];
|
||||
const service: ForgeService = {
|
||||
|
||||
+2
-3
@@ -153,7 +153,7 @@ describe("workspace recovery", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "mixed-project",
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
});
|
||||
@@ -209,7 +209,7 @@ describe("workspace recovery", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "without-subproject",
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
});
|
||||
@@ -303,7 +303,6 @@ async function createBaseRecoveryFixture(baseBranch: string | null) {
|
||||
paseoHome,
|
||||
worktreeSlug: "base-recovery",
|
||||
source: { kind: "branch-off", branchName: "feature", baseBranch: "main" },
|
||||
runSetup: false,
|
||||
});
|
||||
writeFileSync(join(created.worktreePath, "feature.txt"), "feature\n");
|
||||
execFileSync("git", ["add", "."], { cwd: created.worktreePath });
|
||||
|
||||
@@ -184,7 +184,7 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
cwd: sourceRepoRoot,
|
||||
worktreeSlug: basename(previousWorktreePath),
|
||||
source,
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: deps.paseoHome,
|
||||
worktreesRoot: deps.worktreesRoot,
|
||||
});
|
||||
|
||||
@@ -550,7 +550,7 @@ describe("wire compatibility", () => {
|
||||
refName: undefined,
|
||||
action: undefined,
|
||||
githubPrNumber: undefined,
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,7 +329,7 @@ test("archiving an unregistered worktree in a bare repo clears the main checkout
|
||||
cwd: mainCheckout,
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "cached-bare-worktree" },
|
||||
worktreeSlug: "cached-bare-worktree",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: ctx.daemon.paseoHome,
|
||||
});
|
||||
expect(await activeWorkspaceIds()).toEqual(new Set());
|
||||
|
||||
@@ -110,7 +110,7 @@ async function createPaseoOwnedWorktree(
|
||||
baseBranch: "main",
|
||||
branchName: worktreeSlug,
|
||||
},
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,30 @@ const timer = setInterval(() => {
|
||||
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
test("agent creation rejects legacy worktree intent without provisioning or executing setup", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const { repoDir, tempRoot } = createGitRepoWithBranch();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await expect(
|
||||
client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: repoDir,
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
baseBranch: "main",
|
||||
newBranchName: "legacy-setup",
|
||||
worktreeSlug: "legacy-setup",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("createWorkspace");
|
||||
expect((await client.fetchWorkspaces()).entries).toEqual([]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,31 +13,13 @@ import {
|
||||
} from "fs";
|
||||
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 { spawnWorkspaceScript } from "./worktree-bootstrap.js";
|
||||
import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import {
|
||||
createWorktree as createWorktreePrimitive,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
|
||||
interface CreateAgentWorktreeTestOptions {
|
||||
cwd: string;
|
||||
branchName: string;
|
||||
baseBranch: string;
|
||||
worktreeSlug: string;
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
interface CreateAgentWorktreeTestResult {
|
||||
worktree: WorktreeConfig;
|
||||
shouldBootstrap: boolean;
|
||||
}
|
||||
|
||||
async function cleanupTerminalManager(terminalManager: TerminalManager): Promise<void> {
|
||||
const terminalsByCwd = await Promise.all(
|
||||
terminalManager.listDirectories().map((cwd) => terminalManager.getTerminals(cwd)),
|
||||
@@ -54,28 +36,10 @@ function killTerminal(terminalManager: TerminalManager, terminal: TerminalSessio
|
||||
});
|
||||
}
|
||||
|
||||
async function createBootstrapWorktreeForTest(
|
||||
options: CreateAgentWorktreeTestOptions,
|
||||
): Promise<CreateAgentWorktreeTestResult> {
|
||||
const worktree = await createWorktreePrimitive({
|
||||
cwd: options.cwd,
|
||||
worktreeSlug: options.worktreeSlug,
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
return { worktree, shouldBootstrap: true };
|
||||
}
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
describe("runAsyncWorktreeBootstrap", () => {
|
||||
describe("workspace scripts", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
let realTerminalManagers: TerminalManager[];
|
||||
|
||||
async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise<void> {
|
||||
@@ -108,7 +72,6 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
realTerminalManagers = [];
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
|
||||
repoDir = join(tempDir, "repo");
|
||||
paseoHome = join(tempDir, "paseo-home");
|
||||
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
||||
@@ -129,290 +92,6 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
await Promise.all(realTerminalManagers.map(cleanupTerminalManager));
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
it("streams running setup updates live and persists only a final setup timeline row", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktreeBootstrap = await createBootstrapWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-streaming-setup",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-streaming-setup",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
const live: AgentTimelineItem[] = [];
|
||||
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-test",
|
||||
workspaceId: "ws-agent-test",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async (item: AgentTimelineItem) => {
|
||||
live.push(item);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const liveSetupItems = live.filter(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.name === "paseo_worktree_setup" &&
|
||||
item.status === "running",
|
||||
);
|
||||
expect(liveSetupItems.length).toBeGreaterThan(0);
|
||||
|
||||
const persistedSetupItems = persisted.filter(
|
||||
(item) => item.type === "tool_call" && item.name === "paseo_worktree_setup",
|
||||
);
|
||||
expect(persistedSetupItems).toHaveLength(1);
|
||||
expect(persistedSetupItems[0]?.type).toBe("tool_call");
|
||||
if (persistedSetupItems[0]?.type === "tool_call") {
|
||||
expect(persistedSetupItems[0].status).toBe("completed");
|
||||
expect(persistedSetupItems[0].detail.type).toBe("worktree_setup");
|
||||
|
||||
if (persistedSetupItems[0].detail.type === "worktree_setup") {
|
||||
expect(persistedSetupItems[0].detail.log).toContain(
|
||||
'==> [1/2] Running: echo "line-one"; echo "line-two" 1>&2',
|
||||
);
|
||||
expect(persistedSetupItems[0].detail.log).toContain("line-one");
|
||||
expect(persistedSetupItems[0].detail.log).toContain("line-two");
|
||||
expect(persistedSetupItems[0].detail.log).toContain(
|
||||
'==> [2/2] Running: echo "line-three"',
|
||||
);
|
||||
expect(persistedSetupItems[0].detail.log).toContain("line-three");
|
||||
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/);
|
||||
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/);
|
||||
|
||||
expect(persistedSetupItems[0].detail.commands).toHaveLength(2);
|
||||
expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({
|
||||
index: 1,
|
||||
command: 'echo "line-one"; echo "line-two" 1>&2',
|
||||
log: expect.stringContaining("line-one"),
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two");
|
||||
expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({
|
||||
index: 2,
|
||||
command: 'echo "line-three"',
|
||||
log: "line-three\n",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number").toBe(
|
||||
true,
|
||||
);
|
||||
expect(typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number").toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const liveCallIds = new Set(
|
||||
liveSetupItems
|
||||
.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call",
|
||||
)
|
||||
.map((item) => item.callId),
|
||||
);
|
||||
expect(liveCallIds.size).toBe(1);
|
||||
if (persistedSetupItems[0]?.type === "tool_call") {
|
||||
expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps only the final carriage-return-updated content in command logs", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [
|
||||
`node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`,
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
["-c", "commit.gpgsign=false", "commit", "-m", "add carriage return setup"],
|
||||
{
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const worktreeBootstrap = await createBootstrapWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-carriage-return",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-carriage-return",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-carriage-return",
|
||||
workspaceId: "ws-carriage-return",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
const persistedSetupItem = persisted.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call" && item.name === "paseo_worktree_setup",
|
||||
);
|
||||
expect(persistedSetupItem?.detail.type).toBe("worktree_setup");
|
||||
if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") {
|
||||
throw new Error("Expected worktree_setup tool detail");
|
||||
}
|
||||
|
||||
expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n");
|
||||
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n");
|
||||
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n");
|
||||
expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n");
|
||||
});
|
||||
|
||||
it("shares the same worktree runtime port across setup and bootstrap terminals", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'],
|
||||
terminals: [
|
||||
{
|
||||
name: "Port Terminal",
|
||||
command: "true",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
["-c", "commit.gpgsign=false", "commit", "-m", "add port setup and terminals"],
|
||||
{
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const worktreeBootstrap = await createBootstrapWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-shared-runtime-port",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-shared-runtime-port",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const registeredEnvs: Array<{ cwd: string; env: Record<string, string> }> = [];
|
||||
const createTerminalEnvs: Record<string, string>[] = [];
|
||||
const createTerminalWorkspaceIds: Array<string | undefined> = [];
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-shared-runtime-port",
|
||||
workspaceId: "ws-shared-runtime-port",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
},
|
||||
async createTerminal(options) {
|
||||
createTerminalEnvs.push(options.env ?? {});
|
||||
createTerminalWorkspaceIds.push(options.workspaceId);
|
||||
return {
|
||||
id: "term-1",
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {},
|
||||
subscribe: () => () => {},
|
||||
onExit: () => () => {},
|
||||
onCommandFinished: () => () => {},
|
||||
onTitleChange: () => () => {},
|
||||
getSize: () => ({ rows: 1, cols: 1 }),
|
||||
getTitle: () => undefined,
|
||||
getExitInfo: () => null,
|
||||
getState: () => ({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "$" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
}),
|
||||
kill: () => {},
|
||||
killAndWait: async () => {},
|
||||
};
|
||||
},
|
||||
registerCwdEnv(options) {
|
||||
registeredEnvs.push({ cwd: options.cwd, env: options.env });
|
||||
},
|
||||
getTerminal() {
|
||||
return undefined;
|
||||
},
|
||||
killTerminal() {},
|
||||
async killTerminalAndWait() {},
|
||||
listDirectories() {
|
||||
return [];
|
||||
},
|
||||
killAll() {},
|
||||
subscribeTerminalsChanged() {
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt");
|
||||
await waitForPathExists(setupPortPath);
|
||||
|
||||
const setupPort = readFileSync(setupPortPath, "utf8").trim();
|
||||
expect(setupPort.length).toBeGreaterThan(0);
|
||||
expect(registeredEnvs).toHaveLength(1);
|
||||
expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath);
|
||||
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||
expect(createTerminalEnvs.length).toBeGreaterThan(0);
|
||||
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||
expect(createTerminalWorkspaceIds).toEqual(["ws-shared-runtime-port"]);
|
||||
|
||||
const terminalToolCall = persisted.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call" &&
|
||||
item.name === "paseo_worktree_terminals" &&
|
||||
item.status === "completed",
|
||||
);
|
||||
expect(terminalToolCall?.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("injects real peer service env into terminal-backed services", async () => {
|
||||
writeFileSync(
|
||||
|
||||
@@ -3,9 +3,11 @@ import { execFileSync } from "child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync, mkdirSync } from "fs";
|
||||
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,
|
||||
runWorktreeAutoTerminals,
|
||||
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";
|
||||
@@ -13,6 +15,7 @@ import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js
|
||||
import {
|
||||
createWorktree as createWorktreePrimitive,
|
||||
type WorktreeConfig,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
} from "../utils/worktree.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
@@ -57,13 +60,58 @@ async function createBootstrapWorktreeForTest(
|
||||
baseBranch: options.baseBranch,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
return { worktree, shouldBootstrap: true };
|
||||
}
|
||||
|
||||
describe("runAsyncWorktreeBootstrap", () => {
|
||||
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);
|
||||
});
|
||||
it("retains bounded per-command output and truncation without an aggregate copy", () => {
|
||||
const detail = buildWorktreeSetupDetail({
|
||||
worktree: { worktreePath: "/workspace", branchName: "feature" },
|
||||
results: [
|
||||
{
|
||||
command: "large-output",
|
||||
cwd: "/workspace",
|
||||
stdout: "prefix-" + "x".repeat(100000) + "-suffix",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
durationMs: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(detail.log).toBe("");
|
||||
expect(detail.truncated).toBe(true);
|
||||
expect(detail.commands[0]?.log).toContain("prefix-");
|
||||
expect(detail.commands[0]?.log).toContain("-suffix");
|
||||
expect(detail.commands[0]?.log).toContain("...<output truncated in the middle>...");
|
||||
expect(Buffer.byteLength(detail.commands[0]!.log)).toBeLessThanOrEqual(64 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWorktreeAutoTerminals", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
@@ -92,116 +140,6 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("does not fail setup when live timeline emission throws", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ['echo "ok"'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktreeBootstrap = await createBootstrapWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-live-failure",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-live-failure",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await expect(
|
||||
runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-live-failure",
|
||||
workspaceId: "ws-live-failure",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async () => {
|
||||
throw new Error("live emit failed");
|
||||
},
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const persistedSetupItems = persisted.filter(
|
||||
(item) => item.type === "tool_call" && item.name === "paseo_worktree_setup",
|
||||
);
|
||||
expect(persistedSetupItems).toHaveLength(1);
|
||||
if (persistedSetupItems[0]?.type === "tool_call") {
|
||||
expect(persistedSetupItems[0].status).toBe("completed");
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates each command output to 64kb in the middle", async () => {
|
||||
const largeOutputCommand =
|
||||
"node -e \"process.stdout.write('prefix-'); process.stdout.write('x'.repeat(70000)); process.stdout.write('-suffix')\"";
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [largeOutputCommand],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add large output setup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktreeBootstrap = await createBootstrapWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-large-output",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-large-output",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-large-output",
|
||||
workspaceId: "ws-large-output",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
terminalManager: null,
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
const persistedSetupItem = persisted.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call" && item.name === "paseo_worktree_setup",
|
||||
);
|
||||
expect(persistedSetupItem).toBeDefined();
|
||||
expect(persistedSetupItem?.detail.type).toBe("worktree_setup");
|
||||
if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") {
|
||||
throw new Error("Expected worktree_setup tool detail");
|
||||
}
|
||||
|
||||
expect(persistedSetupItem.detail.truncated).toBe(true);
|
||||
expect(persistedSetupItem.detail.log).toContain("prefix-");
|
||||
expect(persistedSetupItem.detail.log).toContain("-suffix");
|
||||
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
|
||||
expect(persistedSetupItem.detail.commands[0]?.log).toContain("prefix-");
|
||||
expect(persistedSetupItem.detail.commands[0]?.log).toContain("-suffix");
|
||||
expect(persistedSetupItem.detail.commands[0]?.log).toContain(
|
||||
"...<output truncated in the middle>...",
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for terminal output before sending bootstrap commands", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
@@ -238,11 +176,11 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
let sendAt = 0;
|
||||
let outputListener: ((chunk: { data: string }) => void) | null = null;
|
||||
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-terminal-readiness",
|
||||
await runWorktreeAutoTerminals({
|
||||
workspaceId: "ws-terminal-readiness",
|
||||
worktree: worktreeBootstrap.worktree,
|
||||
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
|
||||
workspaceCwd: worktreeBootstrap.worktree.worktreePath,
|
||||
runtimeEnv: await resolveWorktreeRuntimeEnv(worktreeBootstrap.worktree),
|
||||
terminalManager: {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
@@ -296,8 +234,6 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
appendTimelineItem: async () => true,
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
expect(readyAt).toBeGreaterThan(0);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { Logger } from "pino";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
@@ -9,9 +8,7 @@ import {
|
||||
paseoConfigParseError,
|
||||
processCarriageReturns,
|
||||
readPaseoConfig,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
runWorktreeSetupCommands,
|
||||
WorktreeSetupError,
|
||||
type WorktreeConfig,
|
||||
type WorktreeSetupCommandResult,
|
||||
type WorktreeRuntimeEnv,
|
||||
@@ -19,7 +16,7 @@ import {
|
||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import { allocateWorkspaceServicePort } from "./workspace-service-port-allocator.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
|
||||
import type { ToolCallDetail } from "./agent/agent-sdk-types.js";
|
||||
import {
|
||||
assertNoServiceEnvNameCollisions,
|
||||
buildWorkspaceServiceEnv,
|
||||
@@ -32,28 +29,6 @@ import {
|
||||
} from "./workspace-service-port-registry.js";
|
||||
import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema";
|
||||
|
||||
export interface WorktreeBootstrapTerminalResult {
|
||||
name: string | null;
|
||||
command: string;
|
||||
status: "started" | "failed";
|
||||
terminalId: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface RunAsyncWorktreeBootstrapOptions {
|
||||
agentId: string;
|
||||
// Workspace the bootstrapped terminals belong to. Stamping it lets
|
||||
// workspaceId-scoped archive tear these terminals down.
|
||||
workspaceId: string;
|
||||
worktree: WorktreeConfig;
|
||||
workspaceCwd?: string;
|
||||
shouldBootstrap?: boolean;
|
||||
terminalManager: TerminalManager | null;
|
||||
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
|
||||
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
||||
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
|
||||
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
|
||||
@@ -170,10 +145,6 @@ function renderMiddleTruncationAccumulator(accumulator: MiddleTruncationAccumula
|
||||
};
|
||||
}
|
||||
|
||||
function formatDurationMs(durationMs: number): string {
|
||||
return `${(durationMs / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function commandStatusFromResult(
|
||||
result: WorktreeSetupCommandResult,
|
||||
): "running" | "completed" | "failed" {
|
||||
@@ -183,46 +154,6 @@ function commandStatusFromResult(
|
||||
return result.exitCode === 0 ? "completed" : "failed";
|
||||
}
|
||||
|
||||
function buildWorktreeSetupLog(input: {
|
||||
results: WorktreeSetupCommandResult[];
|
||||
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||
}): { log: string; truncated: boolean } {
|
||||
const { results, outputAccumulatorsByIndex } = input;
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
log: "",
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
let anyTruncated = false;
|
||||
const total = results.length;
|
||||
for (const [index, result] of results.entries()) {
|
||||
lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`);
|
||||
const output = buildWorktreeSetupCommandLog({
|
||||
index: index + 1,
|
||||
result,
|
||||
outputAccumulatorsByIndex,
|
||||
});
|
||||
if (output.log.length > 0) {
|
||||
lines.push(output.log.replace(/\n$/, ""));
|
||||
}
|
||||
if (output.truncated) {
|
||||
anyTruncated = true;
|
||||
}
|
||||
if (result.exitCode !== null) {
|
||||
lines.push(
|
||||
`<== [${index + 1}/${total}] Exit ${result.exitCode} in ${formatDurationMs(result.durationMs)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
log: lines.join("\n"),
|
||||
truncated: anyTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorktreeSetupCommandLog(input: {
|
||||
index: number;
|
||||
result: WorktreeSetupCommandResult;
|
||||
@@ -325,124 +256,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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSetupTimelineItem(input: {
|
||||
callId: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
worktree: WorktreeConfig;
|
||||
results: WorktreeSetupCommandResult[];
|
||||
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
|
||||
errorMessage: string | null;
|
||||
}): AgentTimelineItem {
|
||||
const detail = buildWorktreeSetupDetail({
|
||||
worktree: input.worktree,
|
||||
results: input.results,
|
||||
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
|
||||
});
|
||||
|
||||
if (input.status === "running") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_setup",
|
||||
callId: input.callId,
|
||||
status: "running",
|
||||
detail,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.status === "completed") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_setup",
|
||||
callId: input.callId,
|
||||
status: "completed",
|
||||
detail,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_setup",
|
||||
callId: input.callId,
|
||||
status: "failed",
|
||||
detail,
|
||||
error: { message: input.errorMessage ?? "Worktree setup failed" },
|
||||
};
|
||||
}
|
||||
|
||||
function buildTerminalTimelineItem(input: {
|
||||
callId: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
worktree: WorktreeConfig;
|
||||
results: WorktreeBootstrapTerminalResult[];
|
||||
errorMessage: string | null;
|
||||
}): AgentTimelineItem {
|
||||
const detailInput = {
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
branchName: input.worktree.branchName,
|
||||
};
|
||||
const detailOutput = {
|
||||
worktreePath: input.worktree.worktreePath,
|
||||
terminals: input.results,
|
||||
};
|
||||
|
||||
if (input.status === "running") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_terminals",
|
||||
callId: input.callId,
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: detailInput,
|
||||
output: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.status === "completed") {
|
||||
return {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_terminals",
|
||||
callId: input.callId,
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: detailInput,
|
||||
output: detailOutput,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
name: "paseo_worktree_terminals",
|
||||
callId: input.callId,
|
||||
status: "failed",
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: detailInput,
|
||||
output: detailOutput,
|
||||
},
|
||||
error: { message: input.errorMessage ?? "Worktree terminal bootstrap failed" },
|
||||
...(anyCommandTruncated ? { truncated: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,93 +324,6 @@ function terminalHasOutput(state: ReturnType<TerminalSession["getState"]>): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runWorktreeTerminalBootstrap(
|
||||
options: RunAsyncWorktreeBootstrapOptions,
|
||||
runtimeEnv: WorktreeRuntimeEnv,
|
||||
): Promise<void> {
|
||||
const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath;
|
||||
const terminalSpecs = getWorktreeTerminalSpecs(workspaceCwd);
|
||||
if (terminalSpecs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callId = uuidv4();
|
||||
const started = await options.appendTimelineItem(
|
||||
buildTerminalTimelineItem({
|
||||
callId,
|
||||
status: "running",
|
||||
worktree: options.worktree,
|
||||
results: [],
|
||||
errorMessage: null,
|
||||
}),
|
||||
);
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.terminalManager) {
|
||||
await options.appendTimelineItem(
|
||||
buildTerminalTimelineItem({
|
||||
callId,
|
||||
status: "failed",
|
||||
worktree: options.worktree,
|
||||
results: [],
|
||||
errorMessage: "Terminal manager not available",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalManager = options.terminalManager;
|
||||
const results = await Promise.all(
|
||||
terminalSpecs.map(async (spec): Promise<WorktreeBootstrapTerminalResult> => {
|
||||
try {
|
||||
const terminal = await terminalManager.createTerminal({
|
||||
cwd: workspaceCwd,
|
||||
name: spec.name,
|
||||
env: runtimeEnv,
|
||||
workspaceId: options.workspaceId,
|
||||
});
|
||||
await waitForTerminalBootstrapReadiness(terminal);
|
||||
terminal.send({
|
||||
type: "input",
|
||||
data: `${spec.command}\r`,
|
||||
});
|
||||
return {
|
||||
name: terminal.name ?? spec.name ?? null,
|
||||
command: spec.command,
|
||||
status: "started",
|
||||
terminalId: terminal.id,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.logger?.warn(
|
||||
{ agentId: options.agentId, command: spec.command, err: error },
|
||||
"Failed to bootstrap worktree terminal",
|
||||
);
|
||||
return {
|
||||
name: spec.name ?? null,
|
||||
command: spec.command,
|
||||
status: "failed",
|
||||
terminalId: null,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await options.appendTimelineItem(
|
||||
buildTerminalTimelineItem({
|
||||
callId,
|
||||
status: "completed",
|
||||
worktree: options.worktree,
|
||||
results,
|
||||
errorMessage: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runWorktreeAutoTerminals(options: {
|
||||
workspaceId: string;
|
||||
worktree: WorktreeConfig;
|
||||
@@ -597,117 +332,27 @@ export async function runWorktreeAutoTerminals(options: {
|
||||
terminalManager: TerminalManager | null;
|
||||
logger?: Logger;
|
||||
}): Promise<void> {
|
||||
await runWorktreeTerminalBootstrap(
|
||||
{
|
||||
agentId: options.workspaceId,
|
||||
workspaceId: options.workspaceId,
|
||||
worktree: options.worktree,
|
||||
workspaceCwd: options.workspaceCwd,
|
||||
terminalManager: options.terminalManager,
|
||||
appendTimelineItem: async () => true,
|
||||
logger: options.logger,
|
||||
},
|
||||
options.runtimeEnv,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runAsyncWorktreeBootstrap(
|
||||
options: RunAsyncWorktreeBootstrapOptions,
|
||||
): Promise<void> {
|
||||
if (options.shouldBootstrap === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setupCallId = uuidv4();
|
||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||
let runtimeEnv: WorktreeRuntimeEnv | null = null;
|
||||
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
||||
const progressAccumulator = createWorktreeSetupProgressAccumulator();
|
||||
const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath;
|
||||
let liveEmitQueue = Promise.resolve();
|
||||
|
||||
const queueLiveRunningEmit = () => {
|
||||
if (!emitLiveTimelineItem) {
|
||||
return;
|
||||
}
|
||||
const runningResults = getWorktreeSetupProgressResults(progressAccumulator);
|
||||
liveEmitQueue = liveEmitQueue.then(async () => {
|
||||
if (!options.terminalManager) return;
|
||||
const terminalManager = options.terminalManager;
|
||||
await Promise.all(
|
||||
getWorktreeTerminalSpecs(options.workspaceCwd).map(async (spec) => {
|
||||
try {
|
||||
await emitLiveTimelineItem(
|
||||
buildSetupTimelineItem({
|
||||
callId: setupCallId,
|
||||
status: "running",
|
||||
worktree: options.worktree,
|
||||
results: runningResults,
|
||||
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||
errorMessage: null,
|
||||
}),
|
||||
);
|
||||
const terminal = await terminalManager.createTerminal({
|
||||
cwd: options.workspaceCwd,
|
||||
name: spec.name,
|
||||
env: options.runtimeEnv,
|
||||
workspaceId: options.workspaceId,
|
||||
});
|
||||
await waitForTerminalBootstrapReadiness(terminal);
|
||||
terminal.send({ type: "input", data: `${spec.command}\r` });
|
||||
} catch (error) {
|
||||
options.logger?.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Failed to emit live worktree setup timeline update",
|
||||
{ workspaceId: options.workspaceId, command: spec.command, err: error },
|
||||
"Failed to bootstrap worktree terminal",
|
||||
);
|
||||
}
|
||||
return;
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: options.worktree.worktreePath,
|
||||
branchName: options.worktree.branchName,
|
||||
});
|
||||
options.terminalManager?.registerCwdEnv({
|
||||
cwd: workspaceCwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
|
||||
setupResults = await runWorktreeSetupCommands({
|
||||
worktreePath: workspaceCwd,
|
||||
branchName: options.worktree.branchName,
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv,
|
||||
onEvent: (event) => {
|
||||
applyWorktreeSetupProgressEvent(progressAccumulator, event);
|
||||
queueLiveRunningEmit();
|
||||
},
|
||||
});
|
||||
await liveEmitQueue;
|
||||
|
||||
const completed = await options.appendTimelineItem(
|
||||
buildSetupTimelineItem({
|
||||
callId: setupCallId,
|
||||
status: "completed",
|
||||
worktree: options.worktree,
|
||||
results: setupResults,
|
||||
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||
errorMessage: null,
|
||||
}),
|
||||
);
|
||||
if (!completed) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WorktreeSetupError) {
|
||||
setupResults = error.results;
|
||||
}
|
||||
await liveEmitQueue;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await options.appendTimelineItem(
|
||||
buildSetupTimelineItem({
|
||||
callId: setupCallId,
|
||||
status: "failed",
|
||||
worktree: options.worktree,
|
||||
results: setupResults,
|
||||
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
|
||||
errorMessage: message,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await runWorktreeTerminalBootstrap(options, runtimeEnv);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -405,7 +405,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "legacy-rpc",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
);
|
||||
@@ -431,7 +430,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "branch-off",
|
||||
refName: "main",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
);
|
||||
@@ -452,7 +450,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
{
|
||||
cwd: repoDir,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
);
|
||||
@@ -475,7 +472,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
githubPrNumber: 123,
|
||||
refName: "feature/review-pr",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
);
|
||||
@@ -502,7 +498,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
githubPrNumber: 123,
|
||||
refName: "feature/review-pr",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
);
|
||||
@@ -520,7 +515,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "mcp-standalone",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
);
|
||||
@@ -547,7 +541,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "branch-off",
|
||||
refName: "dev",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
);
|
||||
@@ -576,7 +569,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
refName: "dev",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
);
|
||||
@@ -604,7 +596,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 123,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
);
|
||||
@@ -656,7 +647,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 1790,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
@@ -694,7 +684,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 14,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
|
||||
);
|
||||
@@ -746,7 +735,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 14,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
|
||||
);
|
||||
@@ -796,7 +784,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 14,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
|
||||
);
|
||||
@@ -841,7 +828,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 14,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
|
||||
);
|
||||
@@ -852,7 +838,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 14,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }),
|
||||
);
|
||||
@@ -910,7 +895,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 1790,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
@@ -955,7 +939,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 1790,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
@@ -997,7 +980,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 1790,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
@@ -1008,7 +990,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 1790,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
@@ -1118,7 +1099,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 1790,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
@@ -1129,7 +1109,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
githubPrNumber: 1790,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
@@ -1179,7 +1158,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
githubPrNumber: 526,
|
||||
refName: "main",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
@@ -1285,7 +1263,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
githubPrNumber: 526,
|
||||
refName: "main",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
@@ -1383,7 +1360,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
githubPrNumber: 526,
|
||||
refName: "main",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
@@ -1395,7 +1371,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
githubPrNumber: 526,
|
||||
refName: "main",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
@@ -1417,7 +1392,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
action: "checkout",
|
||||
refName: "missing-branch",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
),
|
||||
@@ -1433,7 +1407,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "agent-worktree",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
);
|
||||
@@ -1452,11 +1425,11 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
const deps = createCoreDeps();
|
||||
|
||||
const first = await createCoreWorktree(
|
||||
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false },
|
||||
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome },
|
||||
deps,
|
||||
);
|
||||
const second = await createCoreWorktree(
|
||||
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false },
|
||||
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome },
|
||||
deps,
|
||||
);
|
||||
|
||||
@@ -1475,7 +1448,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
githubPrNumber: 123,
|
||||
refName: "feature/review-pr",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
};
|
||||
|
||||
const first = await createCoreWorktree(input, deps);
|
||||
@@ -1515,7 +1487,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
worktreeSlug: "stubbed-github",
|
||||
githubPrNumber: 123,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
|
||||
@@ -28,7 +28,6 @@ export interface CreateWorktreeCoreInput {
|
||||
firstAgentContext?: FirstAgentContext;
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
runSetup?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateWorktreeCoreDeps {
|
||||
@@ -120,7 +119,6 @@ async function createWorktreeCoreWithPriority(
|
||||
cwd: repoRoot,
|
||||
worktreeSlug: normalizedSlug,
|
||||
source: intent,
|
||||
runSetup: input.runSetup ?? true,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
}),
|
||||
|
||||
@@ -15,11 +15,9 @@ import pino, { type Logger } from "pino";
|
||||
|
||||
import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js";
|
||||
import {
|
||||
buildAgentSessionConfig,
|
||||
createPaseoWorktreeWorkflow,
|
||||
handlePaseoWorktreeArchiveRequest,
|
||||
handlePaseoWorktreeListRequest,
|
||||
resolveGitCreateBaseBranch,
|
||||
runWorktreeSetupInBackground,
|
||||
handleCreatePaseoWorktreeRequest,
|
||||
handleWorkspaceSetupStatusRequest,
|
||||
@@ -57,7 +55,7 @@ interface LegacyCreateWorktreeTestOptions {
|
||||
cwd: string;
|
||||
baseBranch: string;
|
||||
worktreeSlug: string;
|
||||
runSetup?: boolean;
|
||||
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
@@ -76,7 +74,7 @@ function createLegacyWorktreeForTest(
|
||||
baseBranch: options.baseBranch,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
}
|
||||
@@ -122,7 +120,6 @@ function createWorkflowForRequestTest(options: {
|
||||
onScriptsChanged: null,
|
||||
},
|
||||
input,
|
||||
{ setupContinuation: { kind: "workspace" } },
|
||||
).then((result) => {
|
||||
options.onSetupStarted?.({
|
||||
requestCwd: input.cwd,
|
||||
@@ -428,30 +425,6 @@ describe("handlePaseoWorktreeListRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGitCreateBaseBranch", () => {
|
||||
test("resolves the default branch through the workspace git service", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const cwd = path.join(repoDir, "packages", "app");
|
||||
const workspaceGitService = {
|
||||
resolveDefaultBranch: vi.fn().mockResolvedValue("main"),
|
||||
getSnapshot: vi.fn(async () => {
|
||||
throw new Error("getSnapshot should not be used for default-branch resolution");
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(
|
||||
resolveGitCreateBaseBranch(cwd, workspaceGitService as unknown as WorkspaceGitService),
|
||||
).resolves.toBe("main");
|
||||
|
||||
expect(workspaceGitService.resolveDefaultBranch).toHaveBeenCalledWith(cwd);
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("create-agent worktree setup boundary", () => {
|
||||
test("blocked worktrees keep their workspace but skip setup and automatic terminals", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
@@ -469,7 +442,7 @@ describe("create-agent worktree setup boundary", () => {
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await createPaseoWorktreeWorkflow(
|
||||
await createPaseoWorktreeWorkflow(
|
||||
{
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
@@ -494,19 +467,9 @@ describe("create-agent worktree setup boundary", () => {
|
||||
getDaemonTcpHost: null,
|
||||
onScriptsChanged: null,
|
||||
},
|
||||
{ cwd: repoDir, worktreeSlug: "blocked-fork", runSetup: false, paseoHome },
|
||||
{
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
terminalManager: createTerminalManagerStub().manager,
|
||||
appendTimelineItem: async () => true,
|
||||
logger: createLogger(),
|
||||
},
|
||||
},
|
||||
{ cwd: repoDir, worktreeSlug: "blocked-fork", paseoHome },
|
||||
);
|
||||
|
||||
expect(result.setupContinuation?.kind).toBe("agent");
|
||||
result.setupContinuation?.startAfterAgentCreate({ agentId: "agent-fork" });
|
||||
expect(existsSync(setupMarker)).toBe(false);
|
||||
expect(emitted).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -518,79 +481,6 @@ describe("create-agent worktree setup boundary", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("agent setup continuation starts setup for the created agent timeline", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const appendedItems: Array<{ name: string; status: string }> = [];
|
||||
const liveItems: Array<{ name: string; status: string }> = [];
|
||||
const workspaceSetupEvents: SessionOutboundMessage[] = [];
|
||||
|
||||
try {
|
||||
const result = await createPaseoWorktreeWorkflow(
|
||||
{
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
autoNameWorkspaceBranchForFirstAgent: () => {},
|
||||
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: (message) => workspaceSetupEvents.push(message),
|
||||
sessionLogger: createLogger(),
|
||||
terminalManager: null,
|
||||
serviceProxy: null,
|
||||
scriptRuntimeStore: null,
|
||||
getDaemonTcpPort: null,
|
||||
getDaemonTcpHost: null,
|
||||
onScriptsChanged: null,
|
||||
},
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "agent-setup-after-create",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
{
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
terminalManager: createTerminalManagerStub().manager,
|
||||
appendTimelineItem: async ({ agentId, item }) => {
|
||||
expect(agentId).toBe("agent-after-create");
|
||||
if (item.type !== "tool_call") {
|
||||
throw new Error(`Expected tool call timeline item, got ${item.type}`);
|
||||
}
|
||||
appendedItems.push({ name: item.name, status: item.status });
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async ({ agentId, item }) => {
|
||||
expect(agentId).toBe("agent-after-create");
|
||||
if (item.type !== "tool_call") {
|
||||
throw new Error(`Expected tool call timeline item, got ${item.type}`);
|
||||
}
|
||||
liveItems.push({ name: item.name, status: item.status });
|
||||
return true;
|
||||
},
|
||||
logger: createLogger(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.setupContinuation?.kind).toBe("agent");
|
||||
expect(workspaceSetupEvents).toEqual([]);
|
||||
|
||||
result.setupContinuation?.startAfterAgentCreate({ agentId: "agent-after-create" });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(appendedItems).toContainEqual({
|
||||
name: "paseo_worktree_setup",
|
||||
status: "completed",
|
||||
});
|
||||
});
|
||||
expect(liveItems).toEqual([]);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createAgentStorageStub(): Pick<AgentStorage, "list"> {
|
||||
@@ -698,7 +588,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-subdirectory-setup",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const workspaceCwd = path.join(createdWorktree.worktreePath, "packages", "app");
|
||||
@@ -746,7 +636,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-no-setup",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
@@ -842,7 +732,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "broken-feature",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
@@ -916,7 +806,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-running-setup",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
@@ -983,7 +873,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 +891,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'\"",
|
||||
@@ -1037,7 +927,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "reused-worktree",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -1125,7 +1015,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-service-failure",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
@@ -1208,7 +1098,7 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-socket-mode",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const worktreePath = createdWorktree.worktreePath;
|
||||
@@ -1508,221 +1398,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
expect(readme).toContain("review branch");
|
||||
});
|
||||
|
||||
test("buildAgentSessionConfig checks out the GitHub PR branch for agent worktrees", async () => {
|
||||
const { tempDir, repoDir } = createGitHubPrRemoteRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const events: string[] = [];
|
||||
|
||||
const result = await buildAgentSessionConfig(
|
||||
{
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
sessionLogger: createLogger(),
|
||||
workspaceGitService: {
|
||||
resolveRepoRoot: vi.fn(async () => repoDir),
|
||||
resolveDefaultBranch: vi.fn(async () => "main"),
|
||||
} as unknown as WorkspaceGitService,
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
events,
|
||||
}),
|
||||
checkoutExistingBranch: async () => {
|
||||
throw new Error("should not checkout existing branch");
|
||||
},
|
||||
createBranchFromBase: async () => {
|
||||
throw new Error("should not create a new branch from base");
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: repoDir,
|
||||
},
|
||||
{
|
||||
createWorktree: true,
|
||||
worktreeSlug: "agent-review-pr-123",
|
||||
action: "checkout",
|
||||
githubPrNumber: 123,
|
||||
refName: "feature/review-pr",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.sessionConfig.cwd).toContain("agent-review-pr-123");
|
||||
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
|
||||
|
||||
const branch = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: result.sessionConfig.cwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(branch).toBe("feature/review-pr");
|
||||
});
|
||||
|
||||
test("buildAgentSessionConfig uses the normalized new branch name as the worktree slug fallback", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
|
||||
const result = await buildAgentSessionConfig(
|
||||
{
|
||||
paseoHome,
|
||||
sessionLogger: createLogger(),
|
||||
workspaceGitService: {
|
||||
resolveRepoRoot: vi.fn(async () => repoDir),
|
||||
resolveDefaultBranch: vi.fn(async () => "main"),
|
||||
} as unknown as WorkspaceGitService,
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
checkoutExistingBranch: async () => {
|
||||
throw new Error("should not checkout existing branch");
|
||||
},
|
||||
createBranchFromBase: async () => {
|
||||
throw new Error("should not create a branch outside the worktree service");
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: repoDir,
|
||||
},
|
||||
{
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
newBranchName: "feature-x",
|
||||
},
|
||||
);
|
||||
|
||||
expect(path.basename(result.sessionConfig.cwd)).toBe("feature-x");
|
||||
});
|
||||
|
||||
test("buildAgentSessionConfig passes prompt and attachment context into worktree creation", async () => {
|
||||
const createPaseoWorktree = vi.fn(async () => ({
|
||||
worktree: {
|
||||
branchName: "fix-attached-pr-context",
|
||||
worktreePath: "/tmp/worktrees/fix-attached-pr-context",
|
||||
},
|
||||
intent: {
|
||||
kind: "branch-off" as const,
|
||||
baseBranch: "main",
|
||||
branchName: "fix-attached-pr-context",
|
||||
},
|
||||
workspace: {
|
||||
workspaceId: "ws-fix-attached-pr-context",
|
||||
projectId: "/tmp/repo",
|
||||
cwd: "/tmp/worktrees/fix-attached-pr-context/packages/app",
|
||||
kind: "worktree" as const,
|
||||
displayName: "fix-attached-pr-context",
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
repoRoot: "/tmp/repo",
|
||||
created: true,
|
||||
}));
|
||||
const firstAgentContext = {
|
||||
prompt: "Create a worktree name from this prompt",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr" as const,
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix worktree naming",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-naming",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await buildAgentSessionConfig(
|
||||
{
|
||||
sessionLogger: createLogger(),
|
||||
workspaceGitService: {
|
||||
resolveDefaultBranch: vi.fn(async () => "main"),
|
||||
} as unknown as WorkspaceGitService,
|
||||
createPaseoWorktree,
|
||||
checkoutExistingBranch: async () => {
|
||||
throw new Error("should not checkout existing branch");
|
||||
},
|
||||
createBranchFromBase: async () => {
|
||||
throw new Error("should not create a branch outside the worktree service");
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
{
|
||||
createWorktree: true,
|
||||
action: "branch-off",
|
||||
},
|
||||
undefined,
|
||||
firstAgentContext,
|
||||
);
|
||||
|
||||
expect(createPaseoWorktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
firstAgentContext,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context/packages/app");
|
||||
});
|
||||
|
||||
test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => {
|
||||
const invalidate = vi.fn();
|
||||
const createBranchFromBase = vi.fn(async () => {});
|
||||
const checkoutExistingBranch = vi.fn(async () => ({ source: "local" as const }));
|
||||
const createPaseoWorktree = vi.fn(async () => {
|
||||
throw new Error("should not create worktree");
|
||||
});
|
||||
|
||||
await buildAgentSessionConfig(
|
||||
{
|
||||
sessionLogger: createLogger(),
|
||||
createPaseoWorktree,
|
||||
checkoutExistingBranch,
|
||||
createBranchFromBase,
|
||||
workspaceGitService: { invalidateForge: invalidate } as unknown as WorkspaceGitService,
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
{
|
||||
createNewBranch: true,
|
||||
baseBranch: "main",
|
||||
newBranchName: "feature-x",
|
||||
},
|
||||
);
|
||||
|
||||
expect(createBranchFromBase).toHaveBeenCalledWith({
|
||||
cwd: "/tmp/repo",
|
||||
baseBranch: "main",
|
||||
newBranchName: "feature-x",
|
||||
});
|
||||
expect(invalidate).toHaveBeenCalledWith("/tmp/repo");
|
||||
|
||||
invalidate.mockClear();
|
||||
|
||||
await buildAgentSessionConfig(
|
||||
{
|
||||
sessionLogger: createLogger(),
|
||||
createPaseoWorktree,
|
||||
checkoutExistingBranch,
|
||||
createBranchFromBase,
|
||||
workspaceGitService: { invalidateForge: invalidate } as unknown as WorkspaceGitService,
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
{
|
||||
baseBranch: "release",
|
||||
},
|
||||
);
|
||||
|
||||
expect(checkoutExistingBranch).toHaveBeenCalledWith("/tmp/repo", "release");
|
||||
expect(invalidate).toHaveBeenCalledWith("/tmp/repo");
|
||||
});
|
||||
|
||||
test("createPaseoWorktreeForTest forwards the default branch resolver for branch-off intents", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
@@ -1734,7 +1409,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "resolver-feature",
|
||||
action: "branch-off",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
},
|
||||
{ resolveDefaultBranch },
|
||||
@@ -1996,7 +1671,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "archive-worktree-scope",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const sharedCwd = created.worktreePath;
|
||||
@@ -2072,7 +1747,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "archive-default-scope",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const workspaceId = "ws-default-scope";
|
||||
@@ -2146,7 +1821,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "archive-default-scope-sibling",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const sharedCwd = created.worktreePath;
|
||||
@@ -2223,7 +1898,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "archive-delete-flag",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const sharedCwd = created.worktreePath;
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { Logger } from "pino";
|
||||
import { basename } from "node:path";
|
||||
|
||||
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import {
|
||||
type GitSetupOptions,
|
||||
type CreateAgentWorktreeTarget,
|
||||
type FirstAgentContext,
|
||||
type ChangeRequestCheckoutSource,
|
||||
type SessionInboundMessage,
|
||||
type SessionOutboundMessage,
|
||||
type WorkspaceSetupSnapshot,
|
||||
@@ -15,7 +10,6 @@ import {
|
||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import {
|
||||
runAsyncWorktreeBootstrap,
|
||||
runWorktreeAutoTerminals,
|
||||
applyWorktreeSetupProgressEvent,
|
||||
buildWorktreeSetupDetail,
|
||||
@@ -25,14 +19,10 @@ import {
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import {
|
||||
getWorktreeSetupCommands,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
runWorktreeSetupCommands,
|
||||
slugify,
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
type WorktreeSetupCommandResult,
|
||||
WorktreeSetupError,
|
||||
@@ -57,49 +47,7 @@ import {
|
||||
|
||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
|
||||
|
||||
export interface NormalizedGitOptions {
|
||||
baseBranch?: string;
|
||||
createNewBranch: boolean;
|
||||
newBranchName?: string;
|
||||
createWorktree: boolean;
|
||||
worktreeSlug?: string;
|
||||
requestedWorktreeSlug?: string;
|
||||
refName?: string;
|
||||
action?: "branch-off" | "checkout";
|
||||
checkoutSource?: ChangeRequestCheckoutSource;
|
||||
githubPrNumber?: number;
|
||||
}
|
||||
|
||||
type EmitSessionMessage = (message: SessionOutboundMessage) => void;
|
||||
type AgentWorktreeSetupTimelineItem = Parameters<
|
||||
typeof runAsyncWorktreeBootstrap
|
||||
>[0]["appendTimelineItem"] extends (item: infer Item) => unknown
|
||||
? Item
|
||||
: never;
|
||||
type AgentWorktreeSetupTimelineWriter = (input: {
|
||||
agentId: string;
|
||||
item: AgentWorktreeSetupTimelineItem;
|
||||
}) => Promise<boolean>;
|
||||
|
||||
interface BuildAgentSessionConfigDependencies {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
sessionLogger: Logger;
|
||||
workspaceGitService?: WorkspaceGitService;
|
||||
createPaseoWorktree: (
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: {
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
},
|
||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||
checkoutExistingBranch: (cwd: string, branch: string) => Promise<CheckoutExistingBranchResult>;
|
||||
createBranchFromBase: (params: {
|
||||
cwd: string;
|
||||
baseBranch: string;
|
||||
newBranchName: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
interface CreatePaseoWorktreeInBackgroundDependencies {
|
||||
paseoHome?: string;
|
||||
@@ -133,32 +81,12 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB
|
||||
assertWorkspaceAutomationAllowed?: (workspaceId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface AgentWorktreeSetupContinuationInput {
|
||||
kind: "agent";
|
||||
terminalManager: TerminalManager | null;
|
||||
appendTimelineItem: AgentWorktreeSetupTimelineWriter;
|
||||
emitLiveTimelineItem: AgentWorktreeSetupTimelineWriter;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export type CreatePaseoWorktreeSetupContinuationInput =
|
||||
| { kind: "workspace" }
|
||||
| AgentWorktreeSetupContinuationInput;
|
||||
|
||||
export interface AgentWorktreeSetupContinuation {
|
||||
kind: "agent";
|
||||
startAfterAgentCreate: (input: { agentId: string }) => void;
|
||||
}
|
||||
|
||||
export type CreatePaseoWorktreeWorkflowResult = CreatePaseoWorktreeResult & {
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
};
|
||||
export type CreatePaseoWorktreeWorkflowResult = CreatePaseoWorktreeResult;
|
||||
|
||||
export type CreatePaseoWorktreeWorkflowFn = (
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: {
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
},
|
||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||
|
||||
@@ -204,245 +132,12 @@ function normalizeFirstAgentContext(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function buildAgentSessionConfig(
|
||||
dependencies: BuildAgentSessionConfigDependencies,
|
||||
config: AgentSessionConfig,
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
): Promise<{
|
||||
sessionConfig: AgentSessionConfig;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
createdWorkspaceId?: string;
|
||||
}> {
|
||||
let cwd = expandTilde(config.cwd);
|
||||
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
|
||||
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
||||
let createdWorkspaceId: string | undefined;
|
||||
|
||||
if (!normalized) {
|
||||
return {
|
||||
sessionConfig: {
|
||||
...config,
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.createWorktree) {
|
||||
dependencies.sessionLogger.info(
|
||||
{ worktreeSlug: normalized.requestedWorktreeSlug },
|
||||
"Creating worktree through createWorktreeCore",
|
||||
);
|
||||
|
||||
const createdWorktree = await dependencies.createPaseoWorktree(
|
||||
{
|
||||
cwd,
|
||||
worktreeSlug: normalized.worktreeSlug,
|
||||
refName: normalized.refName,
|
||||
action: normalized.action,
|
||||
checkoutSource: normalized.checkoutSource,
|
||||
githubPrNumber: normalized.githubPrNumber,
|
||||
firstAgentContext,
|
||||
runSetup: false,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: dependencies.worktreesRoot,
|
||||
},
|
||||
{
|
||||
resolveDefaultBranch: normalized.baseBranch
|
||||
? async () => normalized.baseBranch!
|
||||
: (repoRoot) =>
|
||||
resolveGitCreateBaseBranch(
|
||||
repoRoot,
|
||||
dependencies.workspaceGitService,
|
||||
dependencies.paseoHome,
|
||||
),
|
||||
},
|
||||
);
|
||||
cwd = createdWorktree.workspace.cwd;
|
||||
setupContinuation = createdWorktree.setupContinuation;
|
||||
createdWorkspaceId = createdWorktree.workspace.workspaceId;
|
||||
} else if (normalized.createNewBranch) {
|
||||
const baseBranch =
|
||||
normalized.baseBranch ??
|
||||
(await resolveGitCreateBaseBranch(
|
||||
cwd,
|
||||
dependencies.workspaceGitService,
|
||||
dependencies.paseoHome,
|
||||
));
|
||||
await dependencies.createBranchFromBase({
|
||||
cwd,
|
||||
baseBranch,
|
||||
newBranchName: normalized.newBranchName!,
|
||||
});
|
||||
dependencies.workspaceGitService?.invalidateForge(cwd);
|
||||
} else if (normalized.baseBranch) {
|
||||
await dependencies.checkoutExistingBranch(cwd, normalized.baseBranch);
|
||||
dependencies.workspaceGitService?.invalidateForge(cwd);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionConfig: {
|
||||
...config,
|
||||
cwd,
|
||||
},
|
||||
setupContinuation,
|
||||
createdWorkspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
interface ValidateNormalizedGitOptionsInput {
|
||||
baseBranch: string | undefined;
|
||||
createNewBranch: boolean;
|
||||
normalizedBranchName: string | undefined;
|
||||
normalizedWorktreeSlug: string | undefined;
|
||||
}
|
||||
|
||||
function validateNormalizedGitOptions(input: ValidateNormalizedGitOptionsInput): void {
|
||||
if (input.baseBranch) {
|
||||
assertSafeGitRef(input.baseBranch, "base branch");
|
||||
}
|
||||
|
||||
if (input.createNewBranch) {
|
||||
if (!input.normalizedBranchName) {
|
||||
throw new Error("New branch name is required");
|
||||
}
|
||||
const validation = validateBranchSlug(input.normalizedBranchName);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid branch name: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.normalizedWorktreeSlug) {
|
||||
const validation = validateBranchSlug(input.normalizedWorktreeSlug);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid worktree name: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAgentCreationGitOptions(
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
worktree?: CreateAgentWorktreeTarget,
|
||||
): NormalizedGitOptions | null {
|
||||
if (worktree && gitOptions) {
|
||||
throw new Error("create_agent_request worktree cannot be combined with git options");
|
||||
}
|
||||
if (worktree) {
|
||||
switch (worktree.mode) {
|
||||
case "branch-off":
|
||||
gitOptions = {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
newBranchName: worktree.newBranch,
|
||||
baseBranch: worktree.base,
|
||||
action: "branch-off",
|
||||
refName: worktree.base,
|
||||
};
|
||||
break;
|
||||
case "checkout-branch":
|
||||
gitOptions = { createWorktree: true, action: "checkout", refName: worktree.branch };
|
||||
break;
|
||||
case "checkout-pr":
|
||||
gitOptions = {
|
||||
createWorktree: true,
|
||||
action: "checkout",
|
||||
githubPrNumber: worktree.prNumber,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return normalizeGitOptions(gitOptions, legacyWorktreeName);
|
||||
}
|
||||
|
||||
function normalizeGitOptions(
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
): NormalizedGitOptions | null {
|
||||
const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName
|
||||
? {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
newBranchName: legacyWorktreeName,
|
||||
worktreeSlug: legacyWorktreeName,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const merged = gitOptions ?? fallbackOptions;
|
||||
if (!merged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseBranch = merged.baseBranch?.trim() || undefined;
|
||||
const createWorktree = Boolean(merged.createWorktree);
|
||||
const createNewBranch = Boolean(merged.createNewBranch);
|
||||
const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined;
|
||||
const requestedWorktreeSlug = merged.worktreeSlug ? slugify(merged.worktreeSlug) : undefined;
|
||||
const normalizedWorktreeSlug = requestedWorktreeSlug ?? normalizedBranchName;
|
||||
const refName = merged.refName?.trim() || undefined;
|
||||
const action = merged.action;
|
||||
// COMPAT(githubPrNumber): legacy GitHub checkout input retained when
|
||||
// checkoutSource shipped in v0.2.0-beta.1. Remove after 2027-01-17 once the
|
||||
// supported client floor is >= v0.2.0.
|
||||
const checkoutSource =
|
||||
merged.checkoutSource ??
|
||||
(merged.githubPrNumber
|
||||
? ({ kind: "change_request", forge: "github", number: merged.githubPrNumber } as const)
|
||||
: undefined);
|
||||
const githubPrNumber = merged.githubPrNumber;
|
||||
|
||||
if (
|
||||
!createWorktree &&
|
||||
!createNewBranch &&
|
||||
!baseBranch &&
|
||||
!refName &&
|
||||
!action &&
|
||||
!checkoutSource &&
|
||||
!githubPrNumber
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
validateNormalizedGitOptions({
|
||||
baseBranch,
|
||||
createNewBranch,
|
||||
normalizedBranchName,
|
||||
normalizedWorktreeSlug,
|
||||
});
|
||||
|
||||
return {
|
||||
baseBranch,
|
||||
createNewBranch,
|
||||
newBranchName: normalizedBranchName,
|
||||
createWorktree,
|
||||
worktreeSlug: normalizedWorktreeSlug,
|
||||
requestedWorktreeSlug,
|
||||
refName,
|
||||
action,
|
||||
checkoutSource,
|
||||
githubPrNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertSafeGitRef(ref: string, label: string): void {
|
||||
if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) {
|
||||
throw new Error(`Invalid ${label}: ${ref}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveGitCreateBaseBranch(
|
||||
cwd: string,
|
||||
workspaceGitService?: WorkspaceGitService,
|
||||
_paseoHome?: string,
|
||||
): Promise<string> {
|
||||
if (!workspaceGitService) {
|
||||
throw new Error("WorkspaceGitService is required to resolve the repository root");
|
||||
}
|
||||
|
||||
return workspaceGitService.resolveDefaultBranch(cwd);
|
||||
}
|
||||
|
||||
export async function handlePaseoWorktreeListRequest(
|
||||
dependencies: {
|
||||
emit: EmitSessionMessage;
|
||||
@@ -648,13 +343,12 @@ export async function createPaseoWorktreeWorkflow(
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: {
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
},
|
||||
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||
const createdWorktree = await dependencies.createPaseoWorktree(
|
||||
{
|
||||
...input,
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: input.paseoHome ?? dependencies.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot,
|
||||
},
|
||||
@@ -664,7 +358,6 @@ export async function createPaseoWorktreeWorkflow(
|
||||
);
|
||||
const slug = basename(createdWorktree.worktree.worktreePath);
|
||||
const workspace = createdWorktree.workspace;
|
||||
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
|
||||
|
||||
try {
|
||||
await dependencies.assertWorkspaceAutomationAllowed?.(workspace.workspaceId);
|
||||
@@ -681,15 +374,6 @@ export async function createPaseoWorktreeWorkflow(
|
||||
type: "workspace_setup_progress",
|
||||
payload: { workspaceId: workspace.workspaceId, ...snapshot },
|
||||
});
|
||||
if (setupContinuation.kind === "agent") {
|
||||
return {
|
||||
...createdWorktree,
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
startAfterAgentCreate: () => undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
return createdWorktree;
|
||||
}
|
||||
|
||||
@@ -706,53 +390,28 @@ export async function createPaseoWorktreeWorkflow(
|
||||
"Failed to warm workspace git data after creating worktree",
|
||||
);
|
||||
});
|
||||
if (setupContinuation.kind === "workspace") {
|
||||
const runSetup = (signal: AbortSignal) =>
|
||||
runWorktreeSetupInBackground(
|
||||
dependencies,
|
||||
{
|
||||
requestCwd: input.cwd,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
workspaceId: workspace.workspaceId,
|
||||
worktree: createdWorktree.worktree,
|
||||
shouldBootstrap: createdWorktree.created,
|
||||
slug,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
workspaceCwd: workspace.cwd,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (dependencies.startWorkspaceSetup) {
|
||||
dependencies.startWorkspaceSetup(workspace.workspaceId, runSetup);
|
||||
} else {
|
||||
void runSetup(new AbortController().signal);
|
||||
}
|
||||
const runSetup = (signal: AbortSignal) =>
|
||||
runWorktreeSetupInBackground(
|
||||
dependencies,
|
||||
{
|
||||
requestCwd: input.cwd,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
workspaceId: workspace.workspaceId,
|
||||
worktree: createdWorktree.worktree,
|
||||
shouldBootstrap: createdWorktree.created,
|
||||
slug,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
workspaceCwd: workspace.cwd,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (dependencies.startWorkspaceSetup) {
|
||||
dependencies.startWorkspaceSetup(workspace.workspaceId, runSetup);
|
||||
} else {
|
||||
void runSetup(new AbortController().signal);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
if (setupContinuation.kind === "agent") {
|
||||
return {
|
||||
...createdWorktree,
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
startAfterAgentCreate: ({ agentId }) => {
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
worktree: createdWorktree.worktree,
|
||||
workspaceCwd: workspace.cwd,
|
||||
shouldBootstrap: createdWorktree.created,
|
||||
terminalManager: setupContinuation.terminalManager,
|
||||
appendTimelineItem: (item) => setupContinuation.appendTimelineItem({ agentId, item }),
|
||||
emitLiveTimelineItem: (item) =>
|
||||
setupContinuation.emitLiveTimelineItem({ agentId, item }),
|
||||
logger: setupContinuation.logger,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return createdWorktree;
|
||||
}
|
||||
|
||||
@@ -911,7 +570,6 @@ export async function runWorktreeSetupInBackground(
|
||||
setupResults = await runWorktreeSetupCommands({
|
||||
worktreePath: workspaceCwd,
|
||||
branchName: worktree.branchName,
|
||||
cleanupOnFailure: false,
|
||||
repoRootPath: options.repoRoot,
|
||||
runtimeEnv,
|
||||
signal,
|
||||
|
||||
@@ -45,10 +45,7 @@ export interface CreatePaseoWorktreeCommandDependencies<
|
||||
createPaseoWorktreeWorkflow?: CreatePaseoWorktreeWorkflow<Result>;
|
||||
}
|
||||
|
||||
export type CreatePaseoWorktreeCommandInput = Omit<
|
||||
CreatePaseoWorktreeInput,
|
||||
"paseoHome" | "runSetup"
|
||||
> & {
|
||||
export type CreatePaseoWorktreeCommandInput = Omit<CreatePaseoWorktreeInput, "paseoHome"> & {
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
};
|
||||
@@ -75,7 +72,7 @@ export async function createPaseoWorktreeCommand<Result extends CreatePaseoWorkt
|
||||
|
||||
const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
|
||||
...input,
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: input.paseoHome ?? dependencies.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot,
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ interface LegacyCreateWorktreeTestOptions {
|
||||
cwd: string;
|
||||
baseBranch: string;
|
||||
worktreeSlug: string;
|
||||
runSetup?: boolean;
|
||||
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ function createLegacyWorktreeForTest(
|
||||
baseBranch: options.baseBranch,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,7 +144,6 @@ describe("worktree shell selection", () => {
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath,
|
||||
branchName: "main",
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv: {
|
||||
PASEO_SOURCE_CHECKOUT_PATH: worktreePath,
|
||||
PASEO_ROOT_PATH: worktreePath,
|
||||
|
||||
@@ -58,7 +58,7 @@ interface LegacyCreateWorktreeTestOptions {
|
||||
cwd: string;
|
||||
baseBranch: string;
|
||||
worktreeSlug: string;
|
||||
runSetup?: boolean;
|
||||
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ function createLegacyWorktreeForTest(
|
||||
baseBranch: options.baseBranch,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
});
|
||||
@@ -239,7 +239,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "my-feature",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/x" },
|
||||
runSetup: true,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -282,7 +282,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "existing-feature",
|
||||
source: { kind: "checkout-branch", branchName: "feature/existing" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const status = await getCheckoutStatus(result.worktreePath, { paseoHome });
|
||||
@@ -304,7 +304,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "dev-worktree",
|
||||
source: { kind: "checkout-branch", branchName: "dev" },
|
||||
runSetup: true,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -346,7 +346,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "stale-default",
|
||||
source: { kind: "checkout-branch", branchName: "feature/stale-default" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const diff = await getCheckoutDiff(created.worktreePath, { mode: "base" }, { paseoHome });
|
||||
@@ -365,7 +365,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "feature-x-worktree",
|
||||
source: { kind: "checkout-branch", branchName: "feature-x" },
|
||||
runSetup: true,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -382,7 +382,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "release-worktree",
|
||||
source: { kind: "checkout-branch", branchName: "release/1.1.15" },
|
||||
runSetup: true,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -400,7 +400,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "dev-worktree",
|
||||
source: { kind: "checkout-branch", branchName: "main" },
|
||||
runSetup: true,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -412,7 +412,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => {
|
||||
it("fetches a GitHub PR branch and writes metadata without executing setup", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||
execFileSync("git", ["clone", "--bare", repoDir, remoteDir]);
|
||||
@@ -446,12 +446,12 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
headRef: "user/feature",
|
||||
baseRefName: "main",
|
||||
},
|
||||
runSetup: true,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n");
|
||||
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe("setup ran\n");
|
||||
expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false);
|
||||
const currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: result.worktreePath,
|
||||
})
|
||||
@@ -494,7 +494,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
headRef: "Feature.X",
|
||||
baseRefName: "main",
|
||||
},
|
||||
runSetup: true,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -540,7 +540,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "refs/heads/main",
|
||||
worktreeSlug: "prefer-local-feature",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const originResult = await createLegacyWorktreeForTest({
|
||||
@@ -548,7 +548,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "refs/remotes/origin/main",
|
||||
worktreeSlug: "prefer-origin-feature",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -598,7 +598,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "refs/remotes/upstream/main",
|
||||
worktreeSlug: "fork-base-feature",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -689,7 +689,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "refs/remotes/upstream/release+hotfix",
|
||||
worktreeSlug: "release-hotfix-feature",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -717,7 +717,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "prefer-local-fallback-feature",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -731,7 +731,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "does-not-exist",
|
||||
worktreeSlug: "missing-base-feature",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toThrow("Base branch not found: does-not-exist");
|
||||
@@ -755,7 +755,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "invalid-existing-branch",
|
||||
source: { kind: "checkout-branch", branchName: "bad..name" },
|
||||
runSetup: true,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -773,7 +773,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "invalid-option-like-branch",
|
||||
source: { kind: "checkout-branch", branchName: "-bad" },
|
||||
runSetup: true,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -853,6 +853,10 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath: result.worktreePath,
|
||||
branchName: result.branchName,
|
||||
});
|
||||
// Verify setup ran and env vars were available
|
||||
const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8");
|
||||
expect(setupLog).toContain(`source=${repoDir}`);
|
||||
@@ -886,6 +890,10 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath: result.worktreePath,
|
||||
branchName: result.branchName,
|
||||
});
|
||||
expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([
|
||||
'greeting="hello from string setup"\necho "$greeting" > setup.log',
|
||||
]);
|
||||
@@ -926,7 +934,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath: repoDir,
|
||||
branchName: "main",
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv: {
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoDir,
|
||||
PASEO_ROOT_PATH: repoDir,
|
||||
@@ -1002,7 +1009,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not run setup commands when runSetup=false", async () => {
|
||||
it("only provisions the worktree without executing setup", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
setup: ['echo "setup ran" > setup.log'],
|
||||
@@ -1019,7 +1026,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "no-setup-test",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -1043,7 +1050,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
const results = await runWorktreeSetupCommands({
|
||||
worktreePath: repoDir,
|
||||
branchName: "main",
|
||||
cleanupOnFailure: false,
|
||||
onEvent: (event) => {
|
||||
progressEvents.push(event);
|
||||
},
|
||||
@@ -1061,7 +1067,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "runtime-env-port-reuse",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -1083,7 +1089,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "runtime-env-port-conflict",
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -1117,35 +1123,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans up worktree if setup command fails", async () => {
|
||||
// Create paseo.json with failing setup command
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
setup: ["exit 1"],
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
const expectedWorktreePath = join(paseoHome, "worktrees", "test-repo", "fail-test");
|
||||
|
||||
await expect(
|
||||
createLegacyWorktreeForTest({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "fail-test",
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toThrow("Worktree setup command failed");
|
||||
|
||||
// Verify worktree was cleaned up
|
||||
expect(existsSync(expectedWorktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("reads worktree terminal specs from paseo.json with optional name", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
@@ -1278,7 +1255,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "seed-uncommitted",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/seed" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -1324,7 +1301,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
baseBranch: "origin/main",
|
||||
branchName: "feature/upstream-config",
|
||||
},
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -1364,7 +1341,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
baseBranch: "symlink-config",
|
||||
branchName: "feature/symlink-config",
|
||||
},
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -1385,7 +1362,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "no-config",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/no-config" },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ interface LegacyCreateWorktreeTestOptions {
|
||||
cwd: string;
|
||||
baseBranch: string;
|
||||
worktreeSlug: string;
|
||||
runSetup?: boolean;
|
||||
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function createLegacyWorktreeForTest(
|
||||
baseBranch: options.baseBranch,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
}
|
||||
@@ -132,7 +132,7 @@ describe("paseo worktree manager", () => {
|
||||
cwd: repoDir,
|
||||
paseoHome,
|
||||
worktreeSlug: "fork-review",
|
||||
runSetup: false,
|
||||
|
||||
source: {
|
||||
kind: "checkout-change-request",
|
||||
forge: "gitea",
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import {
|
||||
constants as fsConstants,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "fs";
|
||||
import { constants as fsConstants, existsSync, mkdirSync, realpathSync, statSync } from "fs";
|
||||
import { copyFile, rm, stat } from "fs/promises";
|
||||
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
@@ -217,7 +210,6 @@ export interface CreateWorktreeOptions {
|
||||
cwd: string;
|
||||
worktreeSlug: string;
|
||||
source: WorktreeSource;
|
||||
runSetup: boolean;
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
}
|
||||
@@ -643,7 +635,6 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<
|
||||
export async function runWorktreeSetupCommands(options: {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
cleanupOnFailure: boolean;
|
||||
repoRootPath?: string;
|
||||
runtimeEnv?: WorktreeRuntimeEnv;
|
||||
signal?: AbortSignal;
|
||||
@@ -683,16 +674,6 @@ export async function runWorktreeSetupCommands(options: {
|
||||
results.push(result);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
if (options.cleanupOnFailure) {
|
||||
try {
|
||||
await runGitCommand(["worktree", "remove", options.worktreePath, "--force"], {
|
||||
cwd: options.worktreePath,
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch {
|
||||
rmSync(options.worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
throw new WorktreeSetupError(
|
||||
`Worktree setup command failed: ${cmd}\n${result.stderr}`.trim(),
|
||||
results,
|
||||
@@ -1221,7 +1202,6 @@ export const createWorktree = async ({
|
||||
cwd,
|
||||
source,
|
||||
worktreeSlug,
|
||||
runSetup,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
}: CreateWorktreeOptions): Promise<CreatedWorktree> => {
|
||||
@@ -1269,14 +1249,6 @@ export const createWorktree = async ({
|
||||
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
|
||||
if (runSetup) {
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath,
|
||||
branchName: sourcePlan.branchName,
|
||||
cleanupOnFailure: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: sourcePlan.branchName,
|
||||
worktreePath,
|
||||
|
||||
Reference in New Issue
Block a user