diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 8a90ddb54..70375a365 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -14,11 +14,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { encodeImages } from "@/utils/encode-images"; import { toErrorMessage } from "@/utils/error-messages"; import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; -import type { - CreateAgentRequestOptions, - CreatePaseoWorktreeInput, - DaemonClient, -} from "@server/client/daemon-client"; +import type { CreateAgentRequestOptions, DaemonClient } from "@server/client/daemon-client"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; @@ -73,24 +69,19 @@ function buildChatDraftComposerArgs({ }; } -type WorkspaceCreationAttachments = NonNullable; - async function callWorkspaceCreation({ creationMethod, connectedClient, input, - wirePayload, }: { creationMethod: "create_worktree" | "open_project"; connectedClient: DaemonClient; input: { cwd: string }; - wirePayload: { attachments: WorkspaceCreationAttachments }; }) { if (creationMethod === "create_worktree") { return connectedClient.createPaseoWorktree({ cwd: input.cwd, worktreeSlug: createNameId(), - ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }); } return connectedClient.openProject(input.cwd); @@ -228,12 +219,10 @@ export function WorkspaceSetupDialog() { } const connectedClient = withConnectedClient(); - const wirePayload = splitComposerAttachmentsForSubmit(input.attachments); const payload = await callWorkspaceCreation({ creationMethod: pendingWorkspaceSetup.creationMethod, connectedClient, input, - wirePayload, }); if (payload.error || !payload.workspace) { diff --git a/packages/app/src/screens/new-workspace-screen.test.tsx b/packages/app/src/screens/new-workspace-screen.test.tsx index 93d9e99f3..c09b38d7a 100644 --- a/packages/app/src/screens/new-workspace-screen.test.tsx +++ b/packages/app/src/screens/new-workspace-screen.test.tsx @@ -699,6 +699,7 @@ describe("NewWorkspaceScreen picker payload", () => { await flush(); expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1); + expect(firstCreateWorktreeCall().firstAgentContext?.prompt).toBe("please review this change"); expect(mockClient.createAgent).not.toHaveBeenCalled(); expect(saveDraftInputMock).toHaveBeenCalledWith({ draftKey: "draft:server:draft-new-workspace", @@ -876,7 +877,7 @@ describe("NewWorkspaceScreen picker payload", () => { await flush(); const call = firstCreateWorktreeCall(); - const attachments = call.attachments ?? []; + const attachments = call.firstAgentContext?.attachments ?? []; expect(attachments).toHaveLength(1); expect(attachments[0]).toMatchObject({ type: "github_pr", number: 202 }); }); @@ -907,8 +908,11 @@ describe("NewWorkspaceScreen picker payload", () => { await flush(); const call = firstCreateWorktreeCall(); - expect(call.attachments).toHaveLength(1); - expect(call.attachments?.[0]).toMatchObject({ type: "github_pr", number: 202 }); + expect(call.firstAgentContext?.attachments).toHaveLength(1); + expect(call.firstAgentContext?.attachments?.[0]).toMatchObject({ + type: "github_pr", + number: 202, + }); }); it("omits PR rows from the picker when GitHub features are disabled", async () => { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 894a72c8a..ee36921b7 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -293,6 +293,7 @@ interface CreateChatAgentInput { composerState: ReturnType["composerState"]; ensureWorkspace: (input: { cwd: string; + prompt: string; attachments: AgentAttachment[]; }) => Promise>; serverId: string; @@ -310,7 +311,11 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { throw new Error("Select a model"); } const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments); - const ensuredWorkspace = await ensureWorkspace({ cwd, attachments: reviewAttachments }); + const ensuredWorkspace = await ensureWorkspace({ + cwd, + prompt: text, + attachments: reviewAttachments, + }); submitWorkspaceDraft({ serverId, draftKey, @@ -588,13 +593,22 @@ export function NewWorkspaceScreen({ }, []); const buildCreateWorktreeInput = useCallback( - (input: { cwd: string; attachments: AgentAttachment[] }) => { + (input: { cwd: string; prompt: string; attachments: AgentAttachment[] }) => { const checkoutRequest = pickerItemToCheckoutRequest(selectedItem); + const trimmedPrompt = input.prompt.trim(); + const hasFirstAgentContext = trimmedPrompt.length > 0 || input.attachments.length > 0; return { cwd: input.cwd, worktreeSlug: createNameId(), - ...(input.attachments.length > 0 ? { attachments: input.attachments } : {}), + ...(hasFirstAgentContext + ? { + firstAgentContext: { + ...(trimmedPrompt ? { prompt: trimmedPrompt } : {}), + ...(input.attachments.length > 0 ? { attachments: input.attachments } : {}), + }, + } + : {}), ...checkoutRequest, }; }, @@ -602,7 +616,7 @@ export function NewWorkspaceScreen({ ); const ensureWorkspace = useCallback( - async (input: { cwd: string; attachments: AgentAttachment[] }) => { + async (input: { cwd: string; prompt: string; attachments: AgentAttachment[] }) => { if (createdWorkspace) { return createdWorkspace; } diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 7170f1c1d..15230bbd6 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -251,7 +251,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { export interface CreatePaseoWorktreeInput extends Pick< CreatePaseoWorktreeRequest, - "cwd" | "worktreeSlug" | "nameContext" | "attachments" | "refName" | "action" | "githubPrNumber" + "cwd" | "worktreeSlug" | "firstAgentContext" | "refName" | "action" | "githubPrNumber" > {} type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; @@ -2773,9 +2773,8 @@ export class DaemonClient { type: "create_paseo_worktree_request", cwd: input.cwd, worktreeSlug: input.worktreeSlug, - ...(input.nameContext !== undefined ? { nameContext: input.nameContext } : {}), - ...(input.attachments && input.attachments.length > 0 - ? { attachments: input.attachments } + ...(input.firstAgentContext !== undefined + ? { firstAgentContext: input.firstAgentContext } : {}), ...(input.refName !== undefined ? { refName: input.refName } : {}), ...(input.action !== undefined ? { action: input.action } : {}), diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 3ccd5208a..7b3246584 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -478,42 +478,28 @@ describe("create_agent MCP tool", () => { 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 server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const tool = registeredTool(server, "create_worktree"); - const parsed = await tool.inputSchema.safeParseAsync({ - cwd: existingCwd, - action: "checkout", - refName: "head-ref", - githubPrNumber: 42, - }); - - expect(parsed.success).toBe(true); + for (const target of [ + { mode: "branch-off", newBranch: "feature-x", base: "main" }, + { mode: "checkout-branch", branch: "head-ref" }, + { mode: "checkout-pr", prNumber: 42 }, + ] as const) { + const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, target }); + 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 server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const tool = registeredTool(server, "create_worktree"); - const parsed = await tool.inputSchema.safeParseAsync({ - cwd: existingCwd, - 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", - ); + const parsed = await tool.inputSchema.safeParseAsync({}); + expect(parsed.success).toBe(false); }); it("surfaces createAgent validation failures", async () => { @@ -913,7 +899,7 @@ describe("create_agent MCP tool", () => { expect(createPaseoWorktree).toHaveBeenCalledWith( expect.objectContaining({ githubPrNumber: 123, - nameContext: "Rename this PR branch from prompt", + firstAgentContext: { prompt: "Rename this PR branch from prompt" }, }), expect.objectContaining({ setupContinuation: expect.objectContaining({ kind: "agent" }), @@ -968,8 +954,7 @@ describe("create_agent MCP tool", () => { const tool = registeredTool(server, "create_worktree"); const response = await tool.callback({ cwd: repoDir, - branchName: "tool-worktree", - baseBranch: "main", + target: { mode: "branch-off", newBranch: "tool-worktree", base: "main" }, }); 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 () => { const { agentManager, agentStorage } = createTestDeps(); 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 created = await createTool.callback({ cwd: repoDir, - branchName: "archive-tool-worktree", - baseBranch: "main", + target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" }, }); workspaceGitService.getSnapshot.mockClear(); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 79393cfbf..cfe2d066c 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -686,7 +686,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom refName, action, githubPrNumber, - nameContext: topLevelArgs.initialPrompt, + ...(topLevelArgs.initialPrompt + ? { firstAgentContext: { prompt: topLevelArgs.initialPrompt } } + : {}), runSetup: false, paseoHome: options.paseoHome, }, @@ -1761,44 +1763,50 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom "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: { - cwd: z - .string() - .optional() - .describe("Optional repository cwd. Defaults to the caller agent cwd."), - branchName: z.string().optional(), - nameContext: z.string().optional(), - baseBranch: z.string().optional(), - refName: z.string().min(1).optional(), - action: z.enum(["branch-off", "checkout"]).optional(), - githubPrNumber: z.number().int().positive().optional(), + cwd: z.string().optional().describe("Repository directory. Defaults to the agent's cwd."), + target: z + .discriminatedUnion("mode", [ + z + .object({ + mode: z.literal("branch-off"), + newBranch: z.string().min(1).describe("Name for the new branch."), + base: z + .string() + .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: { branchName: z.string(), worktreePath: z.string(), }, }, - async ({ cwd, branchName, nameContext, baseBranch, refName, action, githubPrNumber }) => { - if (!branchName && !nameContext && !refName && githubPrNumber === undefined) { - throw new Error( - "create_worktree requires branchName, nameContext, refName, or githubPrNumber", - ); - } + async ({ cwd, target }) => { const repoRoot = resolveScopedCwd(cwd, { required: true }); + const mcpInput = mcpCreateWorktreeInput(repoRoot, target, options.paseoHome); const createdWorktree = await createMcpWorktree({ - input: { - cwd: repoRoot, - worktreeSlug: branchName, - nameContext, - refName, - action, - githubPrNumber, - runSetup: false, - paseoHome: options.paseoHome, - }, + input: mcpInput.input, createPaseoWorktree: options.createPaseoWorktree, - resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, + resolveDefaultBranch: mcpInput.resolveDefaultBranch, }); const { worktree } = createdWorktree; @@ -2039,6 +2047,38 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom 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 } { + 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 { input: CreatePaseoWorktreeInput; createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined; diff --git a/packages/server/src/server/agent/prompt-attachments.test.ts b/packages/server/src/server/agent/prompt-attachments.test.ts index 95e1f16e0..3c59ea40f 100644 --- a/packages/server/src/server/agent/prompt-attachments.test.ts +++ b/packages/server/src/server/agent/prompt-attachments.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { findGitHubPrAttachment, renderPromptAttachmentAsText } from "./prompt-attachments.js"; +import { buildAgentBranchNameSeed, renderPromptAttachmentAsText } from "./prompt-attachments.js"; describe("prompt attachments", () => { it("renders github_pr attachments as readable text", () => { @@ -85,30 +85,31 @@ describe("prompt attachments", () => { ).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( - findGitHubPrAttachment([ - { - type: "github_issue", - mimeType: "application/github-issue", - number: 55, - title: "Issue", - url: "https://github.com/getpaseo/paseo/issues/55", - }, - { - type: "github_pr", - mimeType: "application/github-pr", - number: 123, - title: "PR", - url: "https://github.com/getpaseo/paseo/pull/123", - }, - ]), - ).toEqual({ - type: "github_pr", - mimeType: "application/github-pr", - number: 123, - title: "PR", - url: "https://github.com/getpaseo/paseo/pull/123", - }); + buildAgentBranchNameSeed({ + prompt: "Investigate flaky test", + attachments: [ + { + 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", + }, + ], + }), + ).toBe( + "Investigate flaky test\n\nGitHub PR #123: Fix worktree naming\nhttps://github.com/getpaseo/paseo/pull/123\nBase: main\nHead: fix/worktree-naming", + ); }); }); diff --git a/packages/server/src/server/agent/prompt-attachments.ts b/packages/server/src/server/agent/prompt-attachments.ts index 8fe696d95..bcdcea70d 100644 --- a/packages/server/src/server/agent/prompt-attachments.ts +++ b/packages/server/src/server/agent/prompt-attachments.ts @@ -58,16 +58,22 @@ function padLineNumber(lineNumber: number | null): string { return (lineNumber?.toString() ?? "-").padStart(2); } -export function findGitHubPrAttachment( - attachments: readonly AgentAttachment[] | undefined, -): Extract | null { - if (!attachments) { - return null; +export function buildAgentBranchNameSeed( + firstAgentContext: { prompt?: string; attachments?: readonly AgentAttachment[] } | undefined, +): string | undefined { + if (!firstAgentContext) { + return undefined; } - return ( - attachments.find( - (attachment): attachment is Extract => - attachment.type === "github_pr", - ) ?? null - ); + const parts: string[] = []; + const prompt = firstAgentContext.prompt?.trim(); + if (prompt) { + parts.push(prompt); + } + 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; } diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index f5b8bdaa4..4f442ed81 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -129,7 +129,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex const first = await attemptFirstAgentBranchAutoName({ cwd: created.worktree.worktreePath, - nameContext: "Build the agent context name", + firstAgentContext: { prompt: "Build the agent context name" }, generateBranchName: deps.generateBranchName, }); 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({ cwd: created.worktree.worktreePath, - nameContext: "Try another name", + firstAgentContext: { prompt: "Try another name" }, generateBranchName: () => "second-agent-name", }); 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"); }); +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 () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -196,7 +270,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename await expect( attemptFirstAgentBranchAutoName({ cwd: created.worktree.worktreePath, - nameContext: "Rename checkout branch", + firstAgentContext: { prompt: "Rename checkout branch" }, generateBranchName: () => "must-not-rename", }), ).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( attemptFirstAgentBranchAutoName({ cwd: created.worktree.worktreePath, - nameContext: "Rename PR checkout", + firstAgentContext: { prompt: "Rename PR checkout" }, generateBranchName: () => "must-not-rename", }), ).resolves.toEqual({ attempted: false, renamed: false, branchName: null }); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 7ed166d97..51ae6a628 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -1,5 +1,3 @@ -import { basename } from "node:path"; - import type { WorkspaceGitService } from "./workspace-git-service.js"; import { type PersistedWorkspaceRecord, @@ -23,6 +21,8 @@ import { writePaseoWorktreeFirstAgentBranchAutoNameMetadata, } from "../utils/worktree-metadata.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 {} @@ -58,7 +58,9 @@ export async function createPaseoWorktree( deps: CreatePaseoWorktreeDeps, ): Promise { const createdWorktree = await createWorktreeCore(input, deps); - maybeMarkFirstAgentBranchAutoNameEligible({ input, createdWorktree }); + if (!buildAgentBranchNameSeed(input.firstAgentContext)) { + maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); + } const worktree = await maybeAutoNameCreatedWorktree({ input, createdWorktree, @@ -84,12 +86,12 @@ export async function createPaseoWorktree( export async function attemptFirstAgentBranchAutoName(options: { cwd: string; - nameContext?: string; + firstAgentContext: FirstAgentContext | undefined; generateBranchName: (seed: string | undefined) => string; renameCurrentBranch?: typeof renameCurrentBranch; }): Promise { - const nameContext = options.nameContext?.trim(); - if (!nameContext) { + const seed = buildAgentBranchNameSeed(options.firstAgentContext); + if (!seed) { return { attempted: false, renamed: false, branchName: null }; } @@ -109,7 +111,7 @@ export async function attemptFirstAgentBranchAutoName(options: { markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); - const branchName = options.generateBranchName(nameContext); + const branchName = options.generateBranchName(seed); const validation = validateBranchSlug(branchName); if (!validation.valid || branchName === metadata.firstAgentBranchAutoName.placeholderBranchName) { return { attempted: true, renamed: false, branchName: null }; @@ -125,15 +127,10 @@ export async function attemptFirstAgentBranchAutoName(options: { } function maybeMarkFirstAgentBranchAutoNameEligible(options: { - input: CreatePaseoWorktreeInput; createdWorktree: Awaited>; }): void { - const { input, createdWorktree } = options; - if ( - !createdWorktree.created || - input.worktreeSlug || - createdWorktree.intent.kind !== "branch-off" - ) { + const { createdWorktree } = options; + if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") { return; } @@ -148,27 +145,12 @@ async function maybeAutoNameCreatedWorktree(options: { deps: Pick; }): Promise { const { input, createdWorktree, deps } = options; - const nameContext = input.nameContext?.trim(); - if ( - !nameContext || - input.worktreeSlug || - !createdWorktree.created || - createdWorktree.intent.kind !== "branch-off" - ) { + const seed = buildAgentBranchNameSeed(input.firstAgentContext); + if (!seed || !createdWorktree.created || createdWorktree.intent.kind !== "branch-off") { return createdWorktree.worktree; } - const generatedPlaceholderName = basename(createdWorktree.worktree.worktreePath); - if ( - !generatedPlaceholderName || - createdWorktree.worktree.branchName !== generatedPlaceholderName - ) { - return createdWorktree.worktree; - } - - markPaseoWorktreeFirstAgentBranchAutoNameAttempted(createdWorktree.worktree.worktreePath); - - const branchName = deps.generateBranchName(nameContext); + const branchName = deps.generateBranchName(seed); const validation = validateBranchSlug(branchName); if (!validation.valid || branchName === createdWorktree.worktree.branchName) { return createdWorktree.worktree; diff --git a/packages/server/src/server/resolve-worktree-creation-intent.test.ts b/packages/server/src/server/resolve-worktree-creation-intent.test.ts index d72fa6c32..f903d48b3 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.test.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.test.ts @@ -1,9 +1,7 @@ import { describe, expect, test } from "vitest"; import type { GitHubService } from "../services/github-service.js"; -import type { AgentAttachment } from "./messages.js"; import { - ConflictingGitHubPullRequestIntentError, MissingCheckoutTargetError, resolveWorktreeCreationIntent, } 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", () => { const repoRoot = "/tmp/repo"; @@ -155,80 +137,4 @@ describe("resolveWorktreeCreationIntent", () => { resolveWorktreeCreationIntent({ action: "checkout" }, repoRoot, deps), ).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([]); - }); }); diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index aa962b57d..1c0973e9e 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -1,6 +1,4 @@ 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"; export type WorktreeCreationIntent = WorktreeSource; @@ -10,7 +8,6 @@ export interface ResolveWorktreeCreationIntentInput { refName?: string; action?: "branch-off" | "checkout"; githubPrNumber?: number; - attachments?: AgentAttachment[]; } 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( input: ResolveWorktreeCreationIntentInput, repoRoot: string, deps: ResolveWorktreeCreationIntentDeps, ): Promise { - const githubPrAttachment = findGitHubPrAttachment(input.attachments); - assertGitHubPrIntentAgreesWithAttachment({ - githubPrNumber: input.githubPrNumber, - githubPrAttachment, - }); - if (input.action === "branch-off") { return { kind: "branch-off", @@ -66,7 +43,6 @@ export async function resolveWorktreeCreationIntent( return resolveGitHubPrCheckoutIntent({ refName: input.refName, githubPrNumber: input.githubPrNumber, - githubPrAttachment, repoRoot, deps, }); @@ -87,7 +63,6 @@ export async function resolveWorktreeCreationIntent( return resolveGitHubPrCheckoutIntent({ refName: input.refName, githubPrNumber: input.githubPrNumber, - githubPrAttachment, repoRoot, deps, }); @@ -101,15 +76,6 @@ export async function resolveWorktreeCreationIntent( }; } - if (githubPrAttachment) { - return resolveGitHubPrCheckoutIntent({ - githubPrNumber: githubPrAttachment.number, - githubPrAttachment, - repoRoot, - deps, - }); - } - return { kind: "branch-off", baseBranch: await resolveDefaultBranch(repoRoot, deps), @@ -120,7 +86,6 @@ export async function resolveWorktreeCreationIntent( async function resolveGitHubPrCheckoutIntent(params: { refName?: string; githubPrNumber: number; - githubPrAttachment: Extract | null; repoRoot: string; deps: ResolveWorktreeCreationIntentDeps; }): Promise> { @@ -128,14 +93,13 @@ async function resolveGitHubPrCheckoutIntent(params: { const headRef = await resolveGitHubPrHeadRef({ refName: params.refName, githubPrNumber: params.githubPrNumber, - githubPrAttachment: params.githubPrAttachment, checkoutTarget, repoRoot: params.repoRoot, deps: params.deps, }); const baseRefName = checkoutTarget?.baseRefName?.trim() || - (await resolveGitHubPrBaseRefName(params.githubPrAttachment, params.repoRoot, params.deps)); + (await resolveDefaultBranch(params.repoRoot, params.deps)); const localBranchName = buildGitHubPrLocalBranchName({ headRef, checkoutTarget }); const pushRemoteUrl = checkoutTarget ? checkoutTarget.headRepositorySshUrl || checkoutTarget.headRepositoryUrl || undefined @@ -176,26 +140,9 @@ async function resolveDefaultBranch( return baseBranch; } -function assertGitHubPrIntentAgreesWithAttachment(params: { - githubPrNumber?: number; - githubPrAttachment: Extract | 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: { refName?: string; githubPrNumber: number; - githubPrAttachment: Extract | null; checkoutTarget?: GitHubPullRequestCheckoutTarget | null; repoRoot: string; deps: ResolveWorktreeCreationIntentDeps; @@ -208,12 +155,6 @@ async function resolveGitHubPrHeadRef(params: { if (checkoutTargetHeadRef) { return checkoutTargetHeadRef; } - if (params.githubPrAttachment) { - const attachmentHeadRef = params.githubPrAttachment.headRefName?.trim(); - if (attachmentHeadRef) { - return attachmentHeadRef; - } - } return params.deps.github.getPullRequestHeadRef({ cwd: params.repoRoot, number: params.githubPrNumber, @@ -234,11 +175,3 @@ function normalizeGitHubOwnerForBranch(owner: string | null): string | null { const normalized = owner?.trim().toLowerCase() ?? ""; return /^[a-z0-9-]+$/.test(normalized) ? normalized : null; } - -async function resolveGitHubPrBaseRefName( - attachment: Extract | null, - repoRoot: string, - deps: ResolveWorktreeCreationIntentDeps, -): Promise { - return attachment?.baseRefName?.trim() || (await resolveDefaultBranch(repoRoot, deps)); -} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 77fede13e..b87d2125a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -13,6 +13,7 @@ import { serializeAgentStreamEvent, type AgentSnapshotPayload, type AgentAttachment, + type FirstAgentContext, type SessionInboundMessage, type SessionOutboundMessage, type FileExplorerRequest, @@ -200,7 +201,6 @@ import { createWorktreeCoreDeps } from "./worktree-core.js"; import { assertSafeGitRef as assertWorktreeSafeGitRef, buildAgentSessionConfig as buildWorktreeAgentSessionConfig, - buildAgentWorktreeNameContext, createPaseoWorktreeWorkflow as createWorktreeWorkflow, type CreatePaseoWorktreeSetupContinuationInput, type CreatePaseoWorktreeWorkflowResult, @@ -2984,16 +2984,15 @@ export class Session { ...(provisionalTitle ? { title: provisionalTitle } : {}), }; - const agentNameContext = buildAgentWorktreeNameContext({ - initialPrompt: trimmedPrompt, - attachments, - }); + const firstAgentContext: FirstAgentContext = { + ...(trimmedPrompt ? { prompt: trimmedPrompt } : {}), + ...(attachments && attachments.length > 0 ? { attachments } : {}), + }; const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig( resolvedConfig, git, worktreeName, - attachments, - agentNameContext, + firstAgentContext, ); let resolvedWorkspace = msg.workspaceId ? await this.workspaceRegistry.get(msg.workspaceId) @@ -3004,7 +3003,7 @@ export class Session { } resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({ workspace: resolvedWorkspace, - nameContext: agentNameContext, + firstAgentContext, }); const snapshot = await this.agentManager.createAgent( { @@ -3352,8 +3351,7 @@ export class Session { config: AgentSessionConfig, gitOptions?: GitSetupOptions, legacyWorktreeName?: string, - attachments?: AgentAttachment[], - nameContext?: string, + firstAgentContext?: FirstAgentContext, ): Promise<{ sessionConfig: AgentSessionConfig; setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"]; @@ -3391,19 +3389,18 @@ export class Session { config, gitOptions, legacyWorktreeName, - attachments, - nameContext, + firstAgentContext, ); } private async maybeAutoNameWorkspaceBranchForFirstAgent(input: { workspace: PersistedWorkspaceRecord; - nameContext?: string; + firstAgentContext: FirstAgentContext; }): Promise { const coreDeps = createWorktreeCoreDeps(this.github); const result = await attemptFirstAgentBranchAutoName({ cwd: input.workspace.cwd, - nameContext: input.nameContext, + firstAgentContext: input.firstAgentContext, generateBranchName: coreDeps.generateBranchName, }); if (!result.renamed || !result.branchName) { diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index 8d2950f54..67da55575 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -13,11 +13,12 @@ import { type ResolveWorktreeCreationIntentInput, type WorktreeCreationIntent, } from "./resolve-worktree-creation-intent.js"; +import type { FirstAgentContext } from "../shared/messages.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput { cwd: string; - nameContext?: string; + firstAgentContext?: FirstAgentContext; paseoHome?: string; runSetup?: boolean; } diff --git a/packages/server/src/server/worktree-errors.ts b/packages/server/src/server/worktree-errors.ts index 499cd57a8..4d2cf4279 100644 --- a/packages/server/src/server/worktree-errors.ts +++ b/packages/server/src/server/worktree-errors.ts @@ -1,13 +1,9 @@ -import { - ConflictingGitHubPullRequestIntentError, - MissingCheckoutTargetError, -} from "./resolve-worktree-creation-intent.js"; +import { MissingCheckoutTargetError } from "./resolve-worktree-creation-intent.js"; import { BranchAlreadyCheckedOutError, UnknownBranchError } from "../utils/worktree.js"; export type WorktreeWireErrorCode = | "branch_already_checked_out" | "missing_checkout_target" - | "conflicting_github_pull_request_intent" | "unknown_branch" | "unknown"; @@ -33,9 +29,6 @@ export function toWorktreeWireError(error: unknown): WorktreeWireError { if (error instanceof MissingCheckoutTargetError) { 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) { return { code: "unknown_branch", message: error.message }; } diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index ccb3b3dae..325b2ee9d 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -16,7 +16,6 @@ import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messa import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js"; import { buildAgentSessionConfig, - buildAgentWorktreeNameContext, createPaseoWorktreeWorkflow, handlePaseoWorktreeArchiveRequest, 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(); cleanupPaths.push(tempDir); @@ -1180,17 +1179,9 @@ describe("handleCreatePaseoWorktreeRequest", () => { requestId: "req-pr-worktree", cwd: repoDir, worktreeSlug: "review-pr-123", - attachments: [ - { - 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", - }, - ], + action: "checkout", + githubPrNumber: 123, + refName: "feature/review-pr", }, ); @@ -1228,6 +1219,10 @@ describe("handleCreatePaseoWorktreeRequest", () => { { 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, @@ -1246,19 +1241,10 @@ describe("handleCreatePaseoWorktreeRequest", () => { { createWorktree: true, 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"); @@ -1332,11 +1318,11 @@ describe("handleCreatePaseoWorktreeRequest", () => { repoRoot: "/tmp/repo", created: true, })); - const nameContext = buildAgentWorktreeNameContext({ - initialPrompt: "Create a worktree name from this prompt", + const firstAgentContext = { + prompt: "Create a worktree name from this prompt", attachments: [ { - type: "github_pr", + type: "github_pr" as const, mimeType: "application/github-pr", number: 123, title: "Fix worktree naming", @@ -1345,7 +1331,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { headRefName: "fix/worktree-naming", }, ], - }); + }; const result = await buildAgentSessionConfig( { @@ -1370,24 +1356,12 @@ describe("handleCreatePaseoWorktreeRequest", () => { action: "branch-off", }, undefined, - [ - { - 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, + firstAgentContext, ); expect(createPaseoWorktree).toHaveBeenCalledWith( expect.objectContaining({ - nameContext: - "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", + firstAgentContext, }), expect.anything(), ); @@ -1641,7 +1615,6 @@ describe("handleCreatePaseoWorktreeRequest", () => { type: "create_paseo_worktree_request", cwd: repoDir, action: "checkout", - attachments: [], requestId: "req-missing-target", }, ); @@ -1681,7 +1654,6 @@ describe("handleCreatePaseoWorktreeRequest", () => { cwd: repoDir, action: "checkout", refName: "missing-branch", - attachments: [], requestId: "req-unknown-branch", }, ); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 4e4d9b613..719a8e6ad 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -3,8 +3,8 @@ import { basename } from "node:path"; import type { AgentSessionConfig } from "./agent/agent-sdk-types.js"; import { - type AgentAttachment, type GitSetupOptions, + type FirstAgentContext, type SessionInboundMessage, type SessionOutboundMessage, type WorkspaceSetupSnapshot, @@ -24,7 +24,6 @@ import type { ScriptRouteStore } from "./script-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { GitHubService } from "../services/github-service.js"; import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; -import { renderPromptAttachmentAsText } from "./agent/prompt-attachments.js"; import { expandTilde } from "../utils/path.js"; import { getWorktreeSetupCommands, @@ -169,8 +168,7 @@ export async function buildAgentSessionConfig( config: AgentSessionConfig, gitOptions?: GitSetupOptions, legacyWorktreeName?: string, - attachments?: AgentAttachment[], - nameContext?: string, + firstAgentContext?: FirstAgentContext, ): Promise<{ sessionConfig: AgentSessionConfig; setupContinuation?: AgentWorktreeSetupContinuation; @@ -201,8 +199,7 @@ export async function buildAgentSessionConfig( refName: normalized.refName, action: normalized.action, githubPrNumber: normalized.githubPrNumber, - nameContext, - attachments, + firstAgentContext, runSetup: false, 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 { baseBranch: string | undefined; createNewBranch: boolean; @@ -513,11 +492,10 @@ export async function handleCreatePaseoWorktreeRequest( const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({ cwd: request.cwd, worktreeSlug: request.worktreeSlug, - nameContext: request.nameContext, + firstAgentContext: request.firstAgentContext, refName: request.refName, action: request.action, githubPrNumber: request.githubPrNumber, - attachments: request.attachments, runSetup: false, paseoHome: dependencies.paseoHome, }); diff --git a/packages/server/src/shared/messages.attachments.test.ts b/packages/server/src/shared/messages.attachments.test.ts index 32fa8c4eb..114e2a72d 100644 --- a/packages/server/src/shared/messages.attachments.test.ts +++ b/packages/server/src/shared/messages.attachments.test.ts @@ -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({ type: "create_paseo_worktree_request", requestId: "req-3", cwd: "/tmp/repo", - attachments: [ - { - type: "github_pr", - mimeType: "application/github-pr", - number: 99, - title: "Fork-safe PR checkout", - url: "https://github.com/getpaseo/paseo/pull/99", - }, - { - type: "future_attachment", - mimeType: "application/future", - foo: "bar", - }, - ], + firstAgentContext: { + prompt: "Investigate flaky test", + attachments: [ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 99, + title: "Fork-safe PR checkout", + url: "https://github.com/getpaseo/paseo/pull/99", + }, + { + type: "future_attachment", + mimeType: "application/future", + foo: "bar", + }, + ], + }, }); - expect(parsed.attachments).toEqual([ + expect(parsed.firstAgentContext?.attachments).toEqual([ { type: "github_pr", mimeType: "application/github-pr", @@ -256,21 +259,20 @@ describe("shared messages attachments", () => { 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({ type: "create_paseo_worktree_request", requestId: "req-4", cwd: "/tmp/repo", - attachments: [], }); expect(parsed).toEqual({ type: "create_paseo_worktree_request", requestId: "req-4", cwd: "/tmp/repo", - attachments: [], }); }); @@ -279,7 +281,6 @@ describe("shared messages attachments", () => { type: "create_paseo_worktree_request", requestId: "req-5", cwd: "/tmp/repo", - attachments: [], action: "checkout", refName: "feature/ref-picker", githubPrNumber: 42, @@ -290,7 +291,6 @@ describe("shared messages attachments", () => { type: "create_paseo_worktree_request", requestId: "req-5", cwd: "/tmp/repo", - attachments: [], action: "checkout", refName: "feature/ref-picker", githubPrNumber: 42, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 2b0977183..a28d09d52 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1363,12 +1363,16 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({ requestId: z.string(), }); +export const FirstAgentContextSchema = z.object({ + prompt: z.string().optional(), + attachments: AgentAttachmentsSchema, +}); + export const CreatePaseoWorktreeRequestSchema = z.object({ type: z.literal("create_paseo_worktree_request"), cwd: z.string(), worktreeSlug: z.string().optional(), - nameContext: z.string().optional(), - attachments: AgentAttachmentsSchema, + firstAgentContext: FirstAgentContextSchema.optional(), refName: z.string().min(1).optional(), action: z.enum(["branch-off", "checkout"]).optional(), githubPrNumber: z.number().int().positive().optional(), @@ -3484,6 +3488,7 @@ export type DictationStreamFinishMessage = z.infer; export type CreateAgentRequestMessage = z.infer; export type AgentAttachment = z.infer; +export type FirstAgentContext = z.infer; export type ReviewAttachment = z.infer; export type ListProviderModelsRequestMessage = z.infer< typeof ListProviderModelsRequestMessageSchema