fix(server): rename worktree branches from prompt + attachments

Replace top-level `attachments` and `nameContext` on `create_paseo_worktree_request` with `firstAgentContext: { prompt?, attachments? }`, the single signal driving auto-rename. Drop the `worktreeSlug || …` short-circuits so a random placeholder slug no longer blocks renaming, drop the attachment-as-PR fallback in favor of explicit `githubPrNumber`, and reshape the MCP `create_worktree` tool around a `target` discriminated union (`branch-off` / `checkout-branch` / `checkout-pr`) with concise field descriptions so callers stop misreading `prompt` as a name field.
This commit is contained in:
Mohamed Boudra
2026-05-02 12:12:44 +07:00
parent 8e9134d255
commit 2f177dde7a
19 changed files with 312 additions and 534 deletions
@@ -14,11 +14,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import type { import type { CreateAgentRequestOptions, DaemonClient } from "@server/client/daemon-client";
CreateAgentRequestOptions,
CreatePaseoWorktreeInput,
DaemonClient,
} from "@server/client/daemon-client";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
@@ -73,24 +69,19 @@ function buildChatDraftComposerArgs({
}; };
} }
type WorkspaceCreationAttachments = NonNullable<CreatePaseoWorktreeInput["attachments"]>;
async function callWorkspaceCreation({ async function callWorkspaceCreation({
creationMethod, creationMethod,
connectedClient, connectedClient,
input, input,
wirePayload,
}: { }: {
creationMethod: "create_worktree" | "open_project"; creationMethod: "create_worktree" | "open_project";
connectedClient: DaemonClient; connectedClient: DaemonClient;
input: { cwd: string }; input: { cwd: string };
wirePayload: { attachments: WorkspaceCreationAttachments };
}) { }) {
if (creationMethod === "create_worktree") { if (creationMethod === "create_worktree") {
return connectedClient.createPaseoWorktree({ return connectedClient.createPaseoWorktree({
cwd: input.cwd, cwd: input.cwd,
worktreeSlug: createNameId(), worktreeSlug: createNameId(),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
}); });
} }
return connectedClient.openProject(input.cwd); return connectedClient.openProject(input.cwd);
@@ -228,12 +219,10 @@ export function WorkspaceSetupDialog() {
} }
const connectedClient = withConnectedClient(); const connectedClient = withConnectedClient();
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
const payload = await callWorkspaceCreation({ const payload = await callWorkspaceCreation({
creationMethod: pendingWorkspaceSetup.creationMethod, creationMethod: pendingWorkspaceSetup.creationMethod,
connectedClient, connectedClient,
input, input,
wirePayload,
}); });
if (payload.error || !payload.workspace) { if (payload.error || !payload.workspace) {
@@ -699,6 +699,7 @@ describe("NewWorkspaceScreen picker payload", () => {
await flush(); await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1); expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
expect(firstCreateWorktreeCall().firstAgentContext?.prompt).toBe("please review this change");
expect(mockClient.createAgent).not.toHaveBeenCalled(); expect(mockClient.createAgent).not.toHaveBeenCalled();
expect(saveDraftInputMock).toHaveBeenCalledWith({ expect(saveDraftInputMock).toHaveBeenCalledWith({
draftKey: "draft:server:draft-new-workspace", draftKey: "draft:server:draft-new-workspace",
@@ -876,7 +877,7 @@ describe("NewWorkspaceScreen picker payload", () => {
await flush(); await flush();
const call = firstCreateWorktreeCall(); const call = firstCreateWorktreeCall();
const attachments = call.attachments ?? []; const attachments = call.firstAgentContext?.attachments ?? [];
expect(attachments).toHaveLength(1); expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({ type: "github_pr", number: 202 }); expect(attachments[0]).toMatchObject({ type: "github_pr", number: 202 });
}); });
@@ -907,8 +908,11 @@ describe("NewWorkspaceScreen picker payload", () => {
await flush(); await flush();
const call = firstCreateWorktreeCall(); const call = firstCreateWorktreeCall();
expect(call.attachments).toHaveLength(1); expect(call.firstAgentContext?.attachments).toHaveLength(1);
expect(call.attachments?.[0]).toMatchObject({ type: "github_pr", number: 202 }); expect(call.firstAgentContext?.attachments?.[0]).toMatchObject({
type: "github_pr",
number: 202,
});
}); });
it("omits PR rows from the picker when GitHub features are disabled", async () => { it("omits PR rows from the picker when GitHub features are disabled", async () => {
@@ -293,6 +293,7 @@ interface CreateChatAgentInput {
composerState: ReturnType<typeof useAgentInputDraft>["composerState"]; composerState: ReturnType<typeof useAgentInputDraft>["composerState"];
ensureWorkspace: (input: { ensureWorkspace: (input: {
cwd: string; cwd: string;
prompt: string;
attachments: AgentAttachment[]; attachments: AgentAttachment[];
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>; }) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
serverId: string; serverId: string;
@@ -310,7 +311,11 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
throw new Error("Select a model"); throw new Error("Select a model");
} }
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments); const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
const ensuredWorkspace = await ensureWorkspace({ cwd, attachments: reviewAttachments }); const ensuredWorkspace = await ensureWorkspace({
cwd,
prompt: text,
attachments: reviewAttachments,
});
submitWorkspaceDraft({ submitWorkspaceDraft({
serverId, serverId,
draftKey, draftKey,
@@ -588,13 +593,22 @@ export function NewWorkspaceScreen({
}, []); }, []);
const buildCreateWorktreeInput = useCallback( const buildCreateWorktreeInput = useCallback(
(input: { cwd: string; attachments: AgentAttachment[] }) => { (input: { cwd: string; prompt: string; attachments: AgentAttachment[] }) => {
const checkoutRequest = pickerItemToCheckoutRequest(selectedItem); const checkoutRequest = pickerItemToCheckoutRequest(selectedItem);
const trimmedPrompt = input.prompt.trim();
const hasFirstAgentContext = trimmedPrompt.length > 0 || input.attachments.length > 0;
return { return {
cwd: input.cwd, cwd: input.cwd,
worktreeSlug: createNameId(), worktreeSlug: createNameId(),
...(input.attachments.length > 0 ? { attachments: input.attachments } : {}), ...(hasFirstAgentContext
? {
firstAgentContext: {
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
...(input.attachments.length > 0 ? { attachments: input.attachments } : {}),
},
}
: {}),
...checkoutRequest, ...checkoutRequest,
}; };
}, },
@@ -602,7 +616,7 @@ export function NewWorkspaceScreen({
); );
const ensureWorkspace = useCallback( const ensureWorkspace = useCallback(
async (input: { cwd: string; attachments: AgentAttachment[] }) => { async (input: { cwd: string; prompt: string; attachments: AgentAttachment[] }) => {
if (createdWorkspace) { if (createdWorkspace) {
return createdWorkspace; return createdWorkspace;
} }
+3 -4
View File
@@ -251,7 +251,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
export interface CreatePaseoWorktreeInput extends Pick< export interface CreatePaseoWorktreeInput extends Pick<
CreatePaseoWorktreeRequest, CreatePaseoWorktreeRequest,
"cwd" | "worktreeSlug" | "nameContext" | "attachments" | "refName" | "action" | "githubPrNumber" "cwd" | "worktreeSlug" | "firstAgentContext" | "refName" | "action" | "githubPrNumber"
> {} > {}
type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
@@ -2773,9 +2773,8 @@ export class DaemonClient {
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
cwd: input.cwd, cwd: input.cwd,
worktreeSlug: input.worktreeSlug, worktreeSlug: input.worktreeSlug,
...(input.nameContext !== undefined ? { nameContext: input.nameContext } : {}), ...(input.firstAgentContext !== undefined
...(input.attachments && input.attachments.length > 0 ? { firstAgentContext: input.firstAgentContext }
? { attachments: input.attachments }
: {}), : {}),
...(input.refName !== undefined ? { refName: input.refName } : {}), ...(input.refName !== undefined ? { refName: input.refName } : {}),
...(input.action !== undefined ? { action: input.action } : {}), ...(input.action !== undefined ? { action: input.action } : {}),
@@ -478,42 +478,28 @@ describe("create_agent MCP tool", () => {
expect(parsed.success).toBe(true); expect(parsed.success).toBe(true);
}); });
it("accepts optional worktree intent fields in create_worktree input validation", async () => { it("accepts each create_worktree target mode", async () => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_worktree"); const tool = registeredTool(server, "create_worktree");
const parsed = await tool.inputSchema.safeParseAsync({ for (const target of [
cwd: existingCwd, { mode: "branch-off", newBranch: "feature-x", base: "main" },
action: "checkout", { mode: "checkout-branch", branch: "head-ref" },
refName: "head-ref", { mode: "checkout-pr", prNumber: 42 },
githubPrNumber: 42, ] as const) {
}); const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, target });
expect(parsed.success).toBe(true);
expect(parsed.success).toBe(true); }
}); });
it("accepts optional name context in create_worktree input validation", async () => { it("rejects create_worktree without a target", async () => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_worktree"); const tool = registeredTool(server, "create_worktree");
const parsed = await tool.inputSchema.safeParseAsync({ const parsed = await tool.inputSchema.safeParseAsync({});
cwd: existingCwd, expect(parsed.success).toBe(false);
nameContext: "Fix workspace creation naming",
});
expect(parsed.success).toBe(true);
});
it("rejects create_worktree without a branch name or checkout intent", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_worktree");
await expect(tool.callback({})).rejects.toThrow(
"create_worktree requires branchName, nameContext, refName, or githubPrNumber",
);
}); });
it("surfaces createAgent validation failures", async () => { it("surfaces createAgent validation failures", async () => {
@@ -913,7 +899,7 @@ describe("create_agent MCP tool", () => {
expect(createPaseoWorktree).toHaveBeenCalledWith( expect(createPaseoWorktree).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
githubPrNumber: 123, githubPrNumber: 123,
nameContext: "Rename this PR branch from prompt", firstAgentContext: { prompt: "Rename this PR branch from prompt" },
}), }),
expect.objectContaining({ expect.objectContaining({
setupContinuation: expect.objectContaining({ kind: "agent" }), setupContinuation: expect.objectContaining({ kind: "agent" }),
@@ -968,8 +954,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_worktree"); const tool = registeredTool(server, "create_worktree");
const response = await tool.callback({ const response = await tool.callback({
cwd: repoDir, cwd: repoDir,
branchName: "tool-worktree", target: { mode: "branch-off", newBranch: "tool-worktree", base: "main" },
baseBranch: "main",
}); });
expect(response.structuredContent.branchName).toBe("tool-worktree"); expect(response.structuredContent.branchName).toBe("tool-worktree");
@@ -983,106 +968,6 @@ describe("create_agent MCP tool", () => {
} }
}); });
it("auto-names a standalone branch-off worktree from create_worktree name context", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
logger,
});
const tool = registeredTool(server, "create_worktree");
const response = await tool.callback({
cwd: repoDir,
nameContext: "Fix workspace creation naming",
baseBranch: "main",
});
expect(response.structuredContent.branchName).toBe("fix-workspace-creation-naming");
expect(
execSync("git branch --show-current", {
cwd: response.structuredContent.worktreePath as string,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("fix-workspace-creation-naming");
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not rename checkout-created worktrees from create_worktree name context", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-checkout-worktree-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
logger,
});
const tool = registeredTool(server, "create_worktree");
const response = await tool.callback({
cwd: repoDir,
action: "checkout",
refName: "existing-feature",
nameContext: "Should Not Rename Checkout",
});
expect(response.structuredContent.branchName).toBe("existing-feature");
expect(
execSync("git branch --show-current", {
cwd: response.structuredContent.worktreePath as string,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("existing-feature");
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => { it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")); const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
@@ -1128,8 +1013,7 @@ describe("create_agent MCP tool", () => {
const archiveTool = registeredTool(server, "archive_worktree"); const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.callback({ const created = await createTool.callback({
cwd: repoDir, cwd: repoDir,
branchName: "archive-tool-worktree", target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" },
baseBranch: "main",
}); });
workspaceGitService.getSnapshot.mockClear(); workspaceGitService.getSnapshot.mockClear();
+69 -29
View File
@@ -686,7 +686,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
refName, refName,
action, action,
githubPrNumber, githubPrNumber,
nameContext: topLevelArgs.initialPrompt, ...(topLevelArgs.initialPrompt
? { firstAgentContext: { prompt: topLevelArgs.initialPrompt } }
: {}),
runSetup: false, runSetup: false,
paseoHome: options.paseoHome, paseoHome: options.paseoHome,
}, },
@@ -1761,44 +1763,50 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"create_worktree", "create_worktree",
{ {
title: "Create worktree", title: "Create worktree",
description: "Create a Paseo-managed git worktree.", description:
"Create a Paseo-managed git worktree. Branch off a new branch, check out an existing branch, or check out a GitHub PR.",
inputSchema: { inputSchema: {
cwd: z cwd: z.string().optional().describe("Repository directory. Defaults to the agent's cwd."),
.string() target: z
.optional() .discriminatedUnion("mode", [
.describe("Optional repository cwd. Defaults to the caller agent cwd."), z
branchName: z.string().optional(), .object({
nameContext: z.string().optional(), mode: z.literal("branch-off"),
baseBranch: z.string().optional(), newBranch: z.string().min(1).describe("Name for the new branch."),
refName: z.string().min(1).optional(), base: z
action: z.enum(["branch-off", "checkout"]).optional(), .string()
githubPrNumber: z.number().int().positive().optional(), .min(1)
.optional()
.describe("Base ref. Defaults to the repo's default branch."),
})
.describe("Create a new branch off a base."),
z
.object({
mode: z.literal("checkout-branch"),
branch: z.string().min(1).describe("Existing branch to check out."),
})
.describe("Check out an existing branch."),
z
.object({
mode: z.literal("checkout-pr"),
prNumber: z.number().int().positive().describe("Pull request number."),
})
.describe("Check out a GitHub pull request."),
])
.describe("What the worktree should contain."),
}, },
outputSchema: { outputSchema: {
branchName: z.string(), branchName: z.string(),
worktreePath: z.string(), worktreePath: z.string(),
}, },
}, },
async ({ cwd, branchName, nameContext, baseBranch, refName, action, githubPrNumber }) => { async ({ cwd, target }) => {
if (!branchName && !nameContext && !refName && githubPrNumber === undefined) {
throw new Error(
"create_worktree requires branchName, nameContext, refName, or githubPrNumber",
);
}
const repoRoot = resolveScopedCwd(cwd, { required: true }); const repoRoot = resolveScopedCwd(cwd, { required: true });
const mcpInput = mcpCreateWorktreeInput(repoRoot, target, options.paseoHome);
const createdWorktree = await createMcpWorktree({ const createdWorktree = await createMcpWorktree({
input: { input: mcpInput.input,
cwd: repoRoot,
worktreeSlug: branchName,
nameContext,
refName,
action,
githubPrNumber,
runSetup: false,
paseoHome: options.paseoHome,
},
createPaseoWorktree: options.createPaseoWorktree, createPaseoWorktree: options.createPaseoWorktree,
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, resolveDefaultBranch: mcpInput.resolveDefaultBranch,
}); });
const { worktree } = createdWorktree; const { worktree } = createdWorktree;
@@ -2039,6 +2047,38 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
return server; return server;
} }
type McpCreateWorktreeTarget =
| { mode: "branch-off"; newBranch: string; base?: string }
| { mode: "checkout-branch"; branch: string }
| { mode: "checkout-pr"; prNumber: number };
function mcpCreateWorktreeInput(
repoRoot: string,
target: McpCreateWorktreeTarget,
paseoHome: string | undefined,
): { input: CreatePaseoWorktreeInput; resolveDefaultBranch?: (root: string) => Promise<string> } {
const base = { cwd: repoRoot, runSetup: false, paseoHome } as const;
switch (target.mode) {
case "branch-off":
return {
input: {
...base,
worktreeSlug: target.newBranch,
action: "branch-off",
...(target.base ? { refName: target.base } : {}),
},
};
case "checkout-branch":
return {
input: { ...base, action: "checkout", refName: target.branch },
};
case "checkout-pr":
return {
input: { ...base, action: "checkout", githubPrNumber: target.prNumber },
};
}
}
interface CreateMcpWorktreeOptions { interface CreateMcpWorktreeOptions {
input: CreatePaseoWorktreeInput; input: CreatePaseoWorktreeInput;
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined; createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { findGitHubPrAttachment, renderPromptAttachmentAsText } from "./prompt-attachments.js"; import { buildAgentBranchNameSeed, renderPromptAttachmentAsText } from "./prompt-attachments.js";
describe("prompt attachments", () => { describe("prompt attachments", () => {
it("renders github_pr attachments as readable text", () => { it("renders github_pr attachments as readable text", () => {
@@ -85,30 +85,31 @@ describe("prompt attachments", () => {
).toContain("GitHub Issue #55: Issue"); ).toContain("GitHub Issue #55: Issue");
}); });
it("finds the first github_pr attachment", () => { it("returns undefined when firstAgentContext is empty", () => {
expect(buildAgentBranchNameSeed(undefined)).toBeUndefined();
expect(buildAgentBranchNameSeed({})).toBeUndefined();
expect(buildAgentBranchNameSeed({ prompt: " " })).toBeUndefined();
expect(buildAgentBranchNameSeed({ attachments: [] })).toBeUndefined();
});
it("joins prompt and rendered attachments into a single seed", () => {
expect( expect(
findGitHubPrAttachment([ buildAgentBranchNameSeed({
{ prompt: "Investigate flaky test",
type: "github_issue", attachments: [
mimeType: "application/github-issue", {
number: 55, type: "github_pr",
title: "Issue", mimeType: "application/github-pr",
url: "https://github.com/getpaseo/paseo/issues/55", number: 123,
}, title: "Fix worktree naming",
{ url: "https://github.com/getpaseo/paseo/pull/123",
type: "github_pr", baseRefName: "main",
mimeType: "application/github-pr", headRefName: "fix/worktree-naming",
number: 123, },
title: "PR", ],
url: "https://github.com/getpaseo/paseo/pull/123", }),
}, ).toBe(
]), "Investigate flaky test\n\nGitHub PR #123: Fix worktree naming\nhttps://github.com/getpaseo/paseo/pull/123\nBase: main\nHead: fix/worktree-naming",
).toEqual({ );
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "PR",
url: "https://github.com/getpaseo/paseo/pull/123",
});
}); });
}); });
@@ -58,16 +58,22 @@ function padLineNumber(lineNumber: number | null): string {
return (lineNumber?.toString() ?? "-").padStart(2); return (lineNumber?.toString() ?? "-").padStart(2);
} }
export function findGitHubPrAttachment( export function buildAgentBranchNameSeed(
attachments: readonly AgentAttachment[] | undefined, firstAgentContext: { prompt?: string; attachments?: readonly AgentAttachment[] } | undefined,
): Extract<AgentAttachment, { type: "github_pr" }> | null { ): string | undefined {
if (!attachments) { if (!firstAgentContext) {
return null; return undefined;
} }
return ( const parts: string[] = [];
attachments.find( const prompt = firstAgentContext.prompt?.trim();
(attachment): attachment is Extract<AgentAttachment, { type: "github_pr" }> => if (prompt) {
attachment.type === "github_pr", parts.push(prompt);
) ?? null }
); for (const attachment of firstAgentContext.attachments ?? []) {
const rendered = renderPromptAttachmentAsText(attachment).trim();
if (rendered) {
parts.push(rendered);
}
}
return parts.length > 0 ? parts.join("\n\n") : undefined;
} }
@@ -129,7 +129,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
const first = await attemptFirstAgentBranchAutoName({ const first = await attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
nameContext: "Build the agent context name", firstAgentContext: { prompt: "Build the agent context name" },
generateBranchName: deps.generateBranchName, generateBranchName: deps.generateBranchName,
}); });
const branchAfterFirst = execSync("git branch --show-current", { const branchAfterFirst = execSync("git branch --show-current", {
@@ -155,7 +155,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
const second = await attemptFirstAgentBranchAutoName({ const second = await attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
nameContext: "Try another name", firstAgentContext: { prompt: "Try another name" },
generateBranchName: () => "second-agent-name", generateBranchName: () => "second-agent-name",
}); });
const branchAfterSecond = execSync("git branch --show-current", { const branchAfterSecond = execSync("git branch --show-current", {
@@ -169,6 +169,80 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
expect(branchAfterSecond).toBe("renamed-from-agent-context"); expect(branchAfterSecond).toBe("renamed-from-agent-context");
}); });
test("renames the branch even when the app supplies a random placeholder slug", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createDeps({
generateBranchName: (seed) =>
seed === "Investigate the failing login flow" ? "renamed-from-prompt" : (seed ?? "fallback"),
});
const created = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "dazzling-yak",
firstAgentContext: { prompt: "Investigate the failing login flow" },
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
const branchAfter = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(created.worktree.branchName).toBe("renamed-from-prompt");
expect(branchAfter).toBe("renamed-from-prompt");
expect(created.workspace.displayName).toBe("renamed-from-prompt");
});
test("renames the branch from a github_pr attachment when no prompt is supplied", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createDeps({
generateBranchName: (seed) =>
seed?.includes("Investigate flaky checkout test")
? "renamed-from-pr-attachment"
: (seed ?? "fallback"),
});
const created = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "dazzling-yak",
firstAgentContext: {
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 42,
title: "Investigate flaky checkout test",
url: "https://github.com/acme/repo/pull/42",
},
],
},
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
const branchAfter = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(created.worktree.branchName).toBe("renamed-from-pr-attachment");
expect(branchAfter).toBe("renamed-from-pr-attachment");
expect(created.workspace.displayName).toBe("renamed-from-pr-attachment");
});
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => { test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
const { repoDir, tempDir } = createGitRepo(); const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);
@@ -196,7 +270,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
await expect( await expect(
attemptFirstAgentBranchAutoName({ attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
nameContext: "Rename checkout branch", firstAgentContext: { prompt: "Rename checkout branch" },
generateBranchName: () => "must-not-rename", generateBranchName: () => "must-not-rename",
}), }),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); ).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
@@ -232,7 +306,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
await expect( await expect(
attemptFirstAgentBranchAutoName({ attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath, cwd: created.worktree.worktreePath,
nameContext: "Rename PR checkout", firstAgentContext: { prompt: "Rename PR checkout" },
generateBranchName: () => "must-not-rename", generateBranchName: () => "must-not-rename",
}), }),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); ).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
@@ -1,5 +1,3 @@
import { basename } from "node:path";
import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js";
import { import {
type PersistedWorkspaceRecord, type PersistedWorkspaceRecord,
@@ -23,6 +21,8 @@ import {
writePaseoWorktreeFirstAgentBranchAutoNameMetadata, writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
} from "../utils/worktree-metadata.js"; } from "../utils/worktree-metadata.js";
import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.js"; import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.js";
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
import type { FirstAgentContext } from "../shared/messages.js";
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {} export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {}
@@ -58,7 +58,9 @@ export async function createPaseoWorktree(
deps: CreatePaseoWorktreeDeps, deps: CreatePaseoWorktreeDeps,
): Promise<CreatePaseoWorktreeResult> { ): Promise<CreatePaseoWorktreeResult> {
const createdWorktree = await createWorktreeCore(input, deps); const createdWorktree = await createWorktreeCore(input, deps);
maybeMarkFirstAgentBranchAutoNameEligible({ input, createdWorktree }); if (!buildAgentBranchNameSeed(input.firstAgentContext)) {
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
}
const worktree = await maybeAutoNameCreatedWorktree({ const worktree = await maybeAutoNameCreatedWorktree({
input, input,
createdWorktree, createdWorktree,
@@ -84,12 +86,12 @@ export async function createPaseoWorktree(
export async function attemptFirstAgentBranchAutoName(options: { export async function attemptFirstAgentBranchAutoName(options: {
cwd: string; cwd: string;
nameContext?: string; firstAgentContext: FirstAgentContext | undefined;
generateBranchName: (seed: string | undefined) => string; generateBranchName: (seed: string | undefined) => string;
renameCurrentBranch?: typeof renameCurrentBranch; renameCurrentBranch?: typeof renameCurrentBranch;
}): Promise<AttemptFirstAgentBranchAutoNameResult> { }): Promise<AttemptFirstAgentBranchAutoNameResult> {
const nameContext = options.nameContext?.trim(); const seed = buildAgentBranchNameSeed(options.firstAgentContext);
if (!nameContext) { if (!seed) {
return { attempted: false, renamed: false, branchName: null }; return { attempted: false, renamed: false, branchName: null };
} }
@@ -109,7 +111,7 @@ export async function attemptFirstAgentBranchAutoName(options: {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
const branchName = options.generateBranchName(nameContext); const branchName = options.generateBranchName(seed);
const validation = validateBranchSlug(branchName); const validation = validateBranchSlug(branchName);
if (!validation.valid || branchName === metadata.firstAgentBranchAutoName.placeholderBranchName) { if (!validation.valid || branchName === metadata.firstAgentBranchAutoName.placeholderBranchName) {
return { attempted: true, renamed: false, branchName: null }; return { attempted: true, renamed: false, branchName: null };
@@ -125,15 +127,10 @@ export async function attemptFirstAgentBranchAutoName(options: {
} }
function maybeMarkFirstAgentBranchAutoNameEligible(options: { function maybeMarkFirstAgentBranchAutoNameEligible(options: {
input: CreatePaseoWorktreeInput;
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>; createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
}): void { }): void {
const { input, createdWorktree } = options; const { createdWorktree } = options;
if ( if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") {
!createdWorktree.created ||
input.worktreeSlug ||
createdWorktree.intent.kind !== "branch-off"
) {
return; return;
} }
@@ -148,27 +145,12 @@ async function maybeAutoNameCreatedWorktree(options: {
deps: Pick<CreatePaseoWorktreeDeps, "generateBranchName">; deps: Pick<CreatePaseoWorktreeDeps, "generateBranchName">;
}): Promise<WorktreeConfig> { }): Promise<WorktreeConfig> {
const { input, createdWorktree, deps } = options; const { input, createdWorktree, deps } = options;
const nameContext = input.nameContext?.trim(); const seed = buildAgentBranchNameSeed(input.firstAgentContext);
if ( if (!seed || !createdWorktree.created || createdWorktree.intent.kind !== "branch-off") {
!nameContext ||
input.worktreeSlug ||
!createdWorktree.created ||
createdWorktree.intent.kind !== "branch-off"
) {
return createdWorktree.worktree; return createdWorktree.worktree;
} }
const generatedPlaceholderName = basename(createdWorktree.worktree.worktreePath); const branchName = deps.generateBranchName(seed);
if (
!generatedPlaceholderName ||
createdWorktree.worktree.branchName !== generatedPlaceholderName
) {
return createdWorktree.worktree;
}
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(createdWorktree.worktree.worktreePath);
const branchName = deps.generateBranchName(nameContext);
const validation = validateBranchSlug(branchName); const validation = validateBranchSlug(branchName);
if (!validation.valid || branchName === createdWorktree.worktree.branchName) { if (!validation.valid || branchName === createdWorktree.worktree.branchName) {
return createdWorktree.worktree; return createdWorktree.worktree;
@@ -1,9 +1,7 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { GitHubService } from "../services/github-service.js"; import type { GitHubService } from "../services/github-service.js";
import type { AgentAttachment } from "./messages.js";
import { import {
ConflictingGitHubPullRequestIntentError,
MissingCheckoutTargetError, MissingCheckoutTargetError,
resolveWorktreeCreationIntent, resolveWorktreeCreationIntent,
} from "./resolve-worktree-creation-intent.js"; } from "./resolve-worktree-creation-intent.js";
@@ -57,22 +55,6 @@ function createResolverHarness(): ResolverHarness {
}; };
} }
function createGitHubPrAttachment(params: {
number: number;
headRefName?: string;
baseRefName?: string;
}): AgentAttachment {
return {
type: "github_pr",
mimeType: "application/github-pr",
number: params.number,
title: `PR ${params.number}`,
url: `https://github.com/acme/repo/pull/${params.number}`,
...(params.headRefName ? { headRefName: params.headRefName } : {}),
...(params.baseRefName ? { baseRefName: params.baseRefName } : {}),
};
}
describe("resolveWorktreeCreationIntent", () => { describe("resolveWorktreeCreationIntent", () => {
const repoRoot = "/tmp/repo"; const repoRoot = "/tmp/repo";
@@ -155,80 +137,4 @@ describe("resolveWorktreeCreationIntent", () => {
resolveWorktreeCreationIntent({ action: "checkout" }, repoRoot, deps), resolveWorktreeCreationIntent({ action: "checkout" }, repoRoot, deps),
).rejects.toBeInstanceOf(MissingCheckoutTargetError); ).rejects.toBeInstanceOf(MissingCheckoutTargetError);
}); });
test("preserves the legacy GitHub PR attachment checkout path", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent(
{ attachments: [createGitHubPrAttachment({ number: 5, headRefName: "pr-five" })] },
repoRoot,
deps,
),
).resolves.toEqual({
kind: "checkout-github-pr",
githubPrNumber: 5,
headRef: "pr-five",
baseRefName: "main",
});
expect(deps.headRefLookups).toEqual([]);
});
test("accepts matching explicit and attachment GitHub PR numbers", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent(
{
action: "checkout",
githubPrNumber: 5,
attachments: [createGitHubPrAttachment({ number: 5, headRefName: "pr-five" })],
},
repoRoot,
deps,
),
).resolves.toEqual({
kind: "checkout-github-pr",
githubPrNumber: 5,
headRef: "pr-five",
baseRefName: "main",
});
expect(deps.headRefLookups).toEqual([]);
});
test("rejects conflicting explicit and attachment GitHub PR numbers", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent(
{
action: "checkout",
githubPrNumber: 5,
attachments: [createGitHubPrAttachment({ number: 7, headRefName: "pr-seven" })],
},
repoRoot,
deps,
),
).rejects.toBeInstanceOf(ConflictingGitHubPullRequestIntentError);
});
test("ignores a GitHub PR attachment for explicit branch-off", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent(
{
action: "branch-off",
attachments: [createGitHubPrAttachment({ number: 5, headRefName: "pr-five" })],
},
repoRoot,
deps,
),
).resolves.toEqual({
kind: "branch-off",
baseBranch: "main",
newBranchName: "generated-worktree",
});
expect(deps.headRefLookups).toEqual([]);
});
}); });
@@ -1,6 +1,4 @@
import type { GitHubPullRequestCheckoutTarget, GitHubService } from "../services/github-service.js"; import type { GitHubPullRequestCheckoutTarget, GitHubService } from "../services/github-service.js";
import type { AgentAttachment } from "./messages.js";
import { findGitHubPrAttachment } from "./agent/prompt-attachments.js";
import type { WorktreeSource } from "../utils/worktree.js"; import type { WorktreeSource } from "../utils/worktree.js";
export type WorktreeCreationIntent = WorktreeSource; export type WorktreeCreationIntent = WorktreeSource;
@@ -10,7 +8,6 @@ export interface ResolveWorktreeCreationIntentInput {
refName?: string; refName?: string;
action?: "branch-off" | "checkout"; action?: "branch-off" | "checkout";
githubPrNumber?: number; githubPrNumber?: number;
attachments?: AgentAttachment[];
} }
export interface ResolveWorktreeCreationIntentDeps { export interface ResolveWorktreeCreationIntentDeps {
@@ -28,31 +25,11 @@ export class MissingCheckoutTargetError extends Error {
} }
} }
export class ConflictingGitHubPullRequestIntentError extends Error {
readonly explicitGitHubPrNumber: number;
readonly attachmentGitHubPrNumber: number;
constructor(params: { explicitGitHubPrNumber: number; attachmentGitHubPrNumber: number }) {
super(
`Conflicting GitHub PR intent: explicit PR #${params.explicitGitHubPrNumber} does not match attachment PR #${params.attachmentGitHubPrNumber}`,
);
this.name = "ConflictingGitHubPullRequestIntentError";
this.explicitGitHubPrNumber = params.explicitGitHubPrNumber;
this.attachmentGitHubPrNumber = params.attachmentGitHubPrNumber;
}
}
export async function resolveWorktreeCreationIntent( export async function resolveWorktreeCreationIntent(
input: ResolveWorktreeCreationIntentInput, input: ResolveWorktreeCreationIntentInput,
repoRoot: string, repoRoot: string,
deps: ResolveWorktreeCreationIntentDeps, deps: ResolveWorktreeCreationIntentDeps,
): Promise<WorktreeCreationIntent> { ): Promise<WorktreeCreationIntent> {
const githubPrAttachment = findGitHubPrAttachment(input.attachments);
assertGitHubPrIntentAgreesWithAttachment({
githubPrNumber: input.githubPrNumber,
githubPrAttachment,
});
if (input.action === "branch-off") { if (input.action === "branch-off") {
return { return {
kind: "branch-off", kind: "branch-off",
@@ -66,7 +43,6 @@ export async function resolveWorktreeCreationIntent(
return resolveGitHubPrCheckoutIntent({ return resolveGitHubPrCheckoutIntent({
refName: input.refName, refName: input.refName,
githubPrNumber: input.githubPrNumber, githubPrNumber: input.githubPrNumber,
githubPrAttachment,
repoRoot, repoRoot,
deps, deps,
}); });
@@ -87,7 +63,6 @@ export async function resolveWorktreeCreationIntent(
return resolveGitHubPrCheckoutIntent({ return resolveGitHubPrCheckoutIntent({
refName: input.refName, refName: input.refName,
githubPrNumber: input.githubPrNumber, githubPrNumber: input.githubPrNumber,
githubPrAttachment,
repoRoot, repoRoot,
deps, deps,
}); });
@@ -101,15 +76,6 @@ export async function resolveWorktreeCreationIntent(
}; };
} }
if (githubPrAttachment) {
return resolveGitHubPrCheckoutIntent({
githubPrNumber: githubPrAttachment.number,
githubPrAttachment,
repoRoot,
deps,
});
}
return { return {
kind: "branch-off", kind: "branch-off",
baseBranch: await resolveDefaultBranch(repoRoot, deps), baseBranch: await resolveDefaultBranch(repoRoot, deps),
@@ -120,7 +86,6 @@ export async function resolveWorktreeCreationIntent(
async function resolveGitHubPrCheckoutIntent(params: { async function resolveGitHubPrCheckoutIntent(params: {
refName?: string; refName?: string;
githubPrNumber: number; githubPrNumber: number;
githubPrAttachment: Extract<AgentAttachment, { type: "github_pr" }> | null;
repoRoot: string; repoRoot: string;
deps: ResolveWorktreeCreationIntentDeps; deps: ResolveWorktreeCreationIntentDeps;
}): Promise<Extract<WorktreeCreationIntent, { kind: "checkout-github-pr" }>> { }): Promise<Extract<WorktreeCreationIntent, { kind: "checkout-github-pr" }>> {
@@ -128,14 +93,13 @@ async function resolveGitHubPrCheckoutIntent(params: {
const headRef = await resolveGitHubPrHeadRef({ const headRef = await resolveGitHubPrHeadRef({
refName: params.refName, refName: params.refName,
githubPrNumber: params.githubPrNumber, githubPrNumber: params.githubPrNumber,
githubPrAttachment: params.githubPrAttachment,
checkoutTarget, checkoutTarget,
repoRoot: params.repoRoot, repoRoot: params.repoRoot,
deps: params.deps, deps: params.deps,
}); });
const baseRefName = const baseRefName =
checkoutTarget?.baseRefName?.trim() || checkoutTarget?.baseRefName?.trim() ||
(await resolveGitHubPrBaseRefName(params.githubPrAttachment, params.repoRoot, params.deps)); (await resolveDefaultBranch(params.repoRoot, params.deps));
const localBranchName = buildGitHubPrLocalBranchName({ headRef, checkoutTarget }); const localBranchName = buildGitHubPrLocalBranchName({ headRef, checkoutTarget });
const pushRemoteUrl = checkoutTarget const pushRemoteUrl = checkoutTarget
? checkoutTarget.headRepositorySshUrl || checkoutTarget.headRepositoryUrl || undefined ? checkoutTarget.headRepositorySshUrl || checkoutTarget.headRepositoryUrl || undefined
@@ -176,26 +140,9 @@ async function resolveDefaultBranch(
return baseBranch; return baseBranch;
} }
function assertGitHubPrIntentAgreesWithAttachment(params: {
githubPrNumber?: number;
githubPrAttachment: Extract<AgentAttachment, { type: "github_pr" }> | null;
}): void {
if (
params.githubPrNumber !== undefined &&
params.githubPrAttachment &&
params.githubPrNumber !== params.githubPrAttachment.number
) {
throw new ConflictingGitHubPullRequestIntentError({
explicitGitHubPrNumber: params.githubPrNumber,
attachmentGitHubPrNumber: params.githubPrAttachment.number,
});
}
}
async function resolveGitHubPrHeadRef(params: { async function resolveGitHubPrHeadRef(params: {
refName?: string; refName?: string;
githubPrNumber: number; githubPrNumber: number;
githubPrAttachment: Extract<AgentAttachment, { type: "github_pr" }> | null;
checkoutTarget?: GitHubPullRequestCheckoutTarget | null; checkoutTarget?: GitHubPullRequestCheckoutTarget | null;
repoRoot: string; repoRoot: string;
deps: ResolveWorktreeCreationIntentDeps; deps: ResolveWorktreeCreationIntentDeps;
@@ -208,12 +155,6 @@ async function resolveGitHubPrHeadRef(params: {
if (checkoutTargetHeadRef) { if (checkoutTargetHeadRef) {
return checkoutTargetHeadRef; return checkoutTargetHeadRef;
} }
if (params.githubPrAttachment) {
const attachmentHeadRef = params.githubPrAttachment.headRefName?.trim();
if (attachmentHeadRef) {
return attachmentHeadRef;
}
}
return params.deps.github.getPullRequestHeadRef({ return params.deps.github.getPullRequestHeadRef({
cwd: params.repoRoot, cwd: params.repoRoot,
number: params.githubPrNumber, number: params.githubPrNumber,
@@ -234,11 +175,3 @@ function normalizeGitHubOwnerForBranch(owner: string | null): string | null {
const normalized = owner?.trim().toLowerCase() ?? ""; const normalized = owner?.trim().toLowerCase() ?? "";
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null; return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
} }
async function resolveGitHubPrBaseRefName(
attachment: Extract<AgentAttachment, { type: "github_pr" }> | null,
repoRoot: string,
deps: ResolveWorktreeCreationIntentDeps,
): Promise<string> {
return attachment?.baseRefName?.trim() || (await resolveDefaultBranch(repoRoot, deps));
}
+11 -14
View File
@@ -13,6 +13,7 @@ import {
serializeAgentStreamEvent, serializeAgentStreamEvent,
type AgentSnapshotPayload, type AgentSnapshotPayload,
type AgentAttachment, type AgentAttachment,
type FirstAgentContext,
type SessionInboundMessage, type SessionInboundMessage,
type SessionOutboundMessage, type SessionOutboundMessage,
type FileExplorerRequest, type FileExplorerRequest,
@@ -200,7 +201,6 @@ import { createWorktreeCoreDeps } from "./worktree-core.js";
import { import {
assertSafeGitRef as assertWorktreeSafeGitRef, assertSafeGitRef as assertWorktreeSafeGitRef,
buildAgentSessionConfig as buildWorktreeAgentSessionConfig, buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
buildAgentWorktreeNameContext,
createPaseoWorktreeWorkflow as createWorktreeWorkflow, createPaseoWorktreeWorkflow as createWorktreeWorkflow,
type CreatePaseoWorktreeSetupContinuationInput, type CreatePaseoWorktreeSetupContinuationInput,
type CreatePaseoWorktreeWorkflowResult, type CreatePaseoWorktreeWorkflowResult,
@@ -2984,16 +2984,15 @@ export class Session {
...(provisionalTitle ? { title: provisionalTitle } : {}), ...(provisionalTitle ? { title: provisionalTitle } : {}),
}; };
const agentNameContext = buildAgentWorktreeNameContext({ const firstAgentContext: FirstAgentContext = {
initialPrompt: trimmedPrompt, ...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
attachments, ...(attachments && attachments.length > 0 ? { attachments } : {}),
}); };
const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig( const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig(
resolvedConfig, resolvedConfig,
git, git,
worktreeName, worktreeName,
attachments, firstAgentContext,
agentNameContext,
); );
let resolvedWorkspace = msg.workspaceId let resolvedWorkspace = msg.workspaceId
? await this.workspaceRegistry.get(msg.workspaceId) ? await this.workspaceRegistry.get(msg.workspaceId)
@@ -3004,7 +3003,7 @@ export class Session {
} }
resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({ resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({
workspace: resolvedWorkspace, workspace: resolvedWorkspace,
nameContext: agentNameContext, firstAgentContext,
}); });
const snapshot = await this.agentManager.createAgent( const snapshot = await this.agentManager.createAgent(
{ {
@@ -3352,8 +3351,7 @@ export class Session {
config: AgentSessionConfig, config: AgentSessionConfig,
gitOptions?: GitSetupOptions, gitOptions?: GitSetupOptions,
legacyWorktreeName?: string, legacyWorktreeName?: string,
attachments?: AgentAttachment[], firstAgentContext?: FirstAgentContext,
nameContext?: string,
): Promise<{ ): Promise<{
sessionConfig: AgentSessionConfig; sessionConfig: AgentSessionConfig;
setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"]; setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"];
@@ -3391,19 +3389,18 @@ export class Session {
config, config,
gitOptions, gitOptions,
legacyWorktreeName, legacyWorktreeName,
attachments, firstAgentContext,
nameContext,
); );
} }
private async maybeAutoNameWorkspaceBranchForFirstAgent(input: { private async maybeAutoNameWorkspaceBranchForFirstAgent(input: {
workspace: PersistedWorkspaceRecord; workspace: PersistedWorkspaceRecord;
nameContext?: string; firstAgentContext: FirstAgentContext;
}): Promise<PersistedWorkspaceRecord> { }): Promise<PersistedWorkspaceRecord> {
const coreDeps = createWorktreeCoreDeps(this.github); const coreDeps = createWorktreeCoreDeps(this.github);
const result = await attemptFirstAgentBranchAutoName({ const result = await attemptFirstAgentBranchAutoName({
cwd: input.workspace.cwd, cwd: input.workspace.cwd,
nameContext: input.nameContext, firstAgentContext: input.firstAgentContext,
generateBranchName: coreDeps.generateBranchName, generateBranchName: coreDeps.generateBranchName,
}); });
if (!result.renamed || !result.branchName) { if (!result.renamed || !result.branchName) {
+2 -1
View File
@@ -13,11 +13,12 @@ import {
type ResolveWorktreeCreationIntentInput, type ResolveWorktreeCreationIntentInput,
type WorktreeCreationIntent, type WorktreeCreationIntent,
} from "./resolve-worktree-creation-intent.js"; } from "./resolve-worktree-creation-intent.js";
import type { FirstAgentContext } from "../shared/messages.js";
import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js";
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput { export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
cwd: string; cwd: string;
nameContext?: string; firstAgentContext?: FirstAgentContext;
paseoHome?: string; paseoHome?: string;
runSetup?: boolean; runSetup?: boolean;
} }
@@ -1,13 +1,9 @@
import { import { MissingCheckoutTargetError } from "./resolve-worktree-creation-intent.js";
ConflictingGitHubPullRequestIntentError,
MissingCheckoutTargetError,
} from "./resolve-worktree-creation-intent.js";
import { BranchAlreadyCheckedOutError, UnknownBranchError } from "../utils/worktree.js"; import { BranchAlreadyCheckedOutError, UnknownBranchError } from "../utils/worktree.js";
export type WorktreeWireErrorCode = export type WorktreeWireErrorCode =
| "branch_already_checked_out" | "branch_already_checked_out"
| "missing_checkout_target" | "missing_checkout_target"
| "conflicting_github_pull_request_intent"
| "unknown_branch" | "unknown_branch"
| "unknown"; | "unknown";
@@ -33,9 +29,6 @@ export function toWorktreeWireError(error: unknown): WorktreeWireError {
if (error instanceof MissingCheckoutTargetError) { if (error instanceof MissingCheckoutTargetError) {
return { code: "missing_checkout_target", message: error.message }; return { code: "missing_checkout_target", message: error.message };
} }
if (error instanceof ConflictingGitHubPullRequestIntentError) {
return { code: "conflicting_github_pull_request_intent", message: error.message };
}
if (error instanceof UnknownBranchError) { if (error instanceof UnknownBranchError) {
return { code: "unknown_branch", message: error.message }; return { code: "unknown_branch", message: error.message };
} }
@@ -16,7 +16,6 @@ import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messa
import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js"; import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js";
import { import {
buildAgentSessionConfig, buildAgentSessionConfig,
buildAgentWorktreeNameContext,
createPaseoWorktreeWorkflow, createPaseoWorktreeWorkflow,
handlePaseoWorktreeArchiveRequest, handlePaseoWorktreeArchiveRequest,
handlePaseoWorktreeListRequest, handlePaseoWorktreeListRequest,
@@ -1158,7 +1157,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
} }
}); });
test("checks out the GitHub PR branch when a github_pr attachment is present", async () => { test("checks out the GitHub PR branch when githubPrNumber is supplied", async () => {
const { tempDir, repoDir } = createGitHubPrRemoteRepo(); const { tempDir, repoDir } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);
@@ -1180,17 +1179,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
requestId: "req-pr-worktree", requestId: "req-pr-worktree",
cwd: repoDir, cwd: repoDir,
worktreeSlug: "review-pr-123", worktreeSlug: "review-pr-123",
attachments: [ action: "checkout",
{ githubPrNumber: 123,
type: "github_pr", refName: "feature/review-pr",
mimeType: "application/github-pr",
number: 123,
title: "Review branch",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
headRefName: "feature/review-pr",
},
],
}, },
); );
@@ -1228,6 +1219,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
{ {
paseoHome: path.join(tempDir, ".paseo"), paseoHome: path.join(tempDir, ".paseo"),
sessionLogger: createLogger(), sessionLogger: createLogger(),
workspaceGitService: {
resolveRepoRoot: vi.fn(async () => repoDir),
resolveDefaultBranch: vi.fn(async () => "main"),
} as unknown as WorkspaceGitService,
createPaseoWorktree: createPaseoWorktreeForTest({ createPaseoWorktree: createPaseoWorktreeForTest({
paseoHome: path.join(tempDir, ".paseo"), paseoHome: path.join(tempDir, ".paseo"),
events, events,
@@ -1246,19 +1241,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
{ {
createWorktree: true, createWorktree: true,
worktreeSlug: "agent-review-pr-123", worktreeSlug: "agent-review-pr-123",
action: "checkout",
githubPrNumber: 123,
refName: "feature/review-pr",
}, },
undefined,
[
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Review branch",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
headRefName: "feature/review-pr",
},
],
); );
expect(result.sessionConfig.cwd).toContain("agent-review-pr-123"); expect(result.sessionConfig.cwd).toContain("agent-review-pr-123");
@@ -1332,11 +1318,11 @@ describe("handleCreatePaseoWorktreeRequest", () => {
repoRoot: "/tmp/repo", repoRoot: "/tmp/repo",
created: true, created: true,
})); }));
const nameContext = buildAgentWorktreeNameContext({ const firstAgentContext = {
initialPrompt: "Create a worktree name from this prompt", prompt: "Create a worktree name from this prompt",
attachments: [ attachments: [
{ {
type: "github_pr", type: "github_pr" as const,
mimeType: "application/github-pr", mimeType: "application/github-pr",
number: 123, number: 123,
title: "Fix worktree naming", title: "Fix worktree naming",
@@ -1345,7 +1331,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
headRefName: "fix/worktree-naming", headRefName: "fix/worktree-naming",
}, },
], ],
}); };
const result = await buildAgentSessionConfig( const result = await buildAgentSessionConfig(
{ {
@@ -1370,24 +1356,12 @@ describe("handleCreatePaseoWorktreeRequest", () => {
action: "branch-off", action: "branch-off",
}, },
undefined, undefined,
[ firstAgentContext,
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Fix worktree naming",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
headRefName: "fix/worktree-naming",
},
],
nameContext,
); );
expect(createPaseoWorktree).toHaveBeenCalledWith( expect(createPaseoWorktree).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
nameContext: firstAgentContext,
"Create a worktree name from this prompt\n\nGitHub PR #123: Fix worktree naming\nhttps://github.com/getpaseo/paseo/pull/123\nBase: main\nHead: fix/worktree-naming",
}), }),
expect.anything(), expect.anything(),
); );
@@ -1641,7 +1615,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
cwd: repoDir, cwd: repoDir,
action: "checkout", action: "checkout",
attachments: [],
requestId: "req-missing-target", requestId: "req-missing-target",
}, },
); );
@@ -1681,7 +1654,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
cwd: repoDir, cwd: repoDir,
action: "checkout", action: "checkout",
refName: "missing-branch", refName: "missing-branch",
attachments: [],
requestId: "req-unknown-branch", requestId: "req-unknown-branch",
}, },
); );
+4 -26
View File
@@ -3,8 +3,8 @@ import { basename } from "node:path";
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js"; import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
import { import {
type AgentAttachment,
type GitSetupOptions, type GitSetupOptions,
type FirstAgentContext,
type SessionInboundMessage, type SessionInboundMessage,
type SessionOutboundMessage, type SessionOutboundMessage,
type WorkspaceSetupSnapshot, type WorkspaceSetupSnapshot,
@@ -24,7 +24,6 @@ import type { ScriptRouteStore } from "./script-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { GitHubService } from "../services/github-service.js"; import type { GitHubService } from "../services/github-service.js";
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
import { renderPromptAttachmentAsText } from "./agent/prompt-attachments.js";
import { expandTilde } from "../utils/path.js"; import { expandTilde } from "../utils/path.js";
import { import {
getWorktreeSetupCommands, getWorktreeSetupCommands,
@@ -169,8 +168,7 @@ export async function buildAgentSessionConfig(
config: AgentSessionConfig, config: AgentSessionConfig,
gitOptions?: GitSetupOptions, gitOptions?: GitSetupOptions,
legacyWorktreeName?: string, legacyWorktreeName?: string,
attachments?: AgentAttachment[], firstAgentContext?: FirstAgentContext,
nameContext?: string,
): Promise<{ ): Promise<{
sessionConfig: AgentSessionConfig; sessionConfig: AgentSessionConfig;
setupContinuation?: AgentWorktreeSetupContinuation; setupContinuation?: AgentWorktreeSetupContinuation;
@@ -201,8 +199,7 @@ export async function buildAgentSessionConfig(
refName: normalized.refName, refName: normalized.refName,
action: normalized.action, action: normalized.action,
githubPrNumber: normalized.githubPrNumber, githubPrNumber: normalized.githubPrNumber,
nameContext, firstAgentContext,
attachments,
runSetup: false, runSetup: false,
paseoHome: dependencies.paseoHome, paseoHome: dependencies.paseoHome,
}, },
@@ -247,24 +244,6 @@ export async function buildAgentSessionConfig(
}; };
} }
export function buildAgentWorktreeNameContext(input: {
initialPrompt?: string | null;
attachments?: readonly AgentAttachment[];
}): string | undefined {
const parts: string[] = [];
const prompt = input.initialPrompt?.trim();
if (prompt) {
parts.push(prompt);
}
for (const attachment of input.attachments ?? []) {
const rendered = renderPromptAttachmentAsText(attachment).trim();
if (rendered) {
parts.push(rendered);
}
}
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
interface ValidateNormalizedGitOptionsInput { interface ValidateNormalizedGitOptionsInput {
baseBranch: string | undefined; baseBranch: string | undefined;
createNewBranch: boolean; createNewBranch: boolean;
@@ -513,11 +492,10 @@ export async function handleCreatePaseoWorktreeRequest(
const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({ const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
cwd: request.cwd, cwd: request.cwd,
worktreeSlug: request.worktreeSlug, worktreeSlug: request.worktreeSlug,
nameContext: request.nameContext, firstAgentContext: request.firstAgentContext,
refName: request.refName, refName: request.refName,
action: request.action, action: request.action,
githubPrNumber: request.githubPrNumber, githubPrNumber: request.githubPrNumber,
attachments: request.attachments,
runSetup: false, runSetup: false,
paseoHome: dependencies.paseoHome, paseoHome: dependencies.paseoHome,
}); });
@@ -226,28 +226,31 @@ describe("shared messages attachments", () => {
]); ]);
}); });
it("keeps known attachments and drops unknown worktree-create attachments", () => { it("keeps known firstAgentContext attachments and drops unknown ones", () => {
const parsed = CreatePaseoWorktreeRequestSchema.parse({ const parsed = CreatePaseoWorktreeRequestSchema.parse({
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
requestId: "req-3", requestId: "req-3",
cwd: "/tmp/repo", cwd: "/tmp/repo",
attachments: [ firstAgentContext: {
{ prompt: "Investigate flaky test",
type: "github_pr", attachments: [
mimeType: "application/github-pr", {
number: 99, type: "github_pr",
title: "Fork-safe PR checkout", mimeType: "application/github-pr",
url: "https://github.com/getpaseo/paseo/pull/99", number: 99,
}, title: "Fork-safe PR checkout",
{ url: "https://github.com/getpaseo/paseo/pull/99",
type: "future_attachment", },
mimeType: "application/future", {
foo: "bar", type: "future_attachment",
}, mimeType: "application/future",
], foo: "bar",
},
],
},
}); });
expect(parsed.attachments).toEqual([ expect(parsed.firstAgentContext?.attachments).toEqual([
{ {
type: "github_pr", type: "github_pr",
mimeType: "application/github-pr", mimeType: "application/github-pr",
@@ -256,21 +259,20 @@ describe("shared messages attachments", () => {
url: "https://github.com/getpaseo/paseo/pull/99", url: "https://github.com/getpaseo/paseo/pull/99",
}, },
]); ]);
expect(parsed.firstAgentContext?.prompt).toBe("Investigate flaky test");
}); });
it("parses old-shape create-worktree payloads without the new intent fields", () => { it("parses worktree-create payloads without a firstAgentContext", () => {
const parsed = CreatePaseoWorktreeRequestSchema.parse({ const parsed = CreatePaseoWorktreeRequestSchema.parse({
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
requestId: "req-4", requestId: "req-4",
cwd: "/tmp/repo", cwd: "/tmp/repo",
attachments: [],
}); });
expect(parsed).toEqual({ expect(parsed).toEqual({
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
requestId: "req-4", requestId: "req-4",
cwd: "/tmp/repo", cwd: "/tmp/repo",
attachments: [],
}); });
}); });
@@ -279,7 +281,6 @@ describe("shared messages attachments", () => {
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
requestId: "req-5", requestId: "req-5",
cwd: "/tmp/repo", cwd: "/tmp/repo",
attachments: [],
action: "checkout", action: "checkout",
refName: "feature/ref-picker", refName: "feature/ref-picker",
githubPrNumber: 42, githubPrNumber: 42,
@@ -290,7 +291,6 @@ describe("shared messages attachments", () => {
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
requestId: "req-5", requestId: "req-5",
cwd: "/tmp/repo", cwd: "/tmp/repo",
attachments: [],
action: "checkout", action: "checkout",
refName: "feature/ref-picker", refName: "feature/ref-picker",
githubPrNumber: 42, githubPrNumber: 42,
+7 -2
View File
@@ -1363,12 +1363,16 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const FirstAgentContextSchema = z.object({
prompt: z.string().optional(),
attachments: AgentAttachmentsSchema,
});
export const CreatePaseoWorktreeRequestSchema = z.object({ export const CreatePaseoWorktreeRequestSchema = z.object({
type: z.literal("create_paseo_worktree_request"), type: z.literal("create_paseo_worktree_request"),
cwd: z.string(), cwd: z.string(),
worktreeSlug: z.string().optional(), worktreeSlug: z.string().optional(),
nameContext: z.string().optional(), firstAgentContext: FirstAgentContextSchema.optional(),
attachments: AgentAttachmentsSchema,
refName: z.string().min(1).optional(), refName: z.string().min(1).optional(),
action: z.enum(["branch-off", "checkout"]).optional(), action: z.enum(["branch-off", "checkout"]).optional(),
githubPrNumber: z.number().int().positive().optional(), githubPrNumber: z.number().int().positive().optional(),
@@ -3484,6 +3488,7 @@ export type DictationStreamFinishMessage = z.infer<typeof DictationStreamFinishM
export type DictationStreamCancelMessage = z.infer<typeof DictationStreamCancelMessageSchema>; export type DictationStreamCancelMessage = z.infer<typeof DictationStreamCancelMessageSchema>;
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>; export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>; export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>;
export type FirstAgentContext = z.infer<typeof FirstAgentContextSchema>;
export type ReviewAttachment = z.infer<typeof ReviewAttachmentSchema>; export type ReviewAttachment = z.infer<typeof ReviewAttachmentSchema>;
export type ListProviderModelsRequestMessage = z.infer< export type ListProviderModelsRequestMessage = z.infer<
typeof ListProviderModelsRequestMessageSchema typeof ListProviderModelsRequestMessageSchema