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:
@@ -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<CreatePaseoWorktreeInput["attachments"]>;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -293,6 +293,7 @@ interface CreateChatAgentInput {
|
||||
composerState: ReturnType<typeof useAgentInputDraft>["composerState"];
|
||||
ensureWorkspace: (input: {
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
attachments: AgentAttachment[];
|
||||
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||
serverId: string;
|
||||
@@ -310,7 +311,11 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
input: CreatePaseoWorktreeInput;
|
||||
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,16 +58,22 @@ function padLineNumber(lineNumber: number | null): string {
|
||||
return (lineNumber?.toString() ?? "-").padStart(2);
|
||||
}
|
||||
|
||||
export function findGitHubPrAttachment(
|
||||
attachments: readonly AgentAttachment[] | undefined,
|
||||
): Extract<AgentAttachment, { type: "github_pr" }> | 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<AgentAttachment, { type: "github_pr" }> =>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<CreatePaseoWorktreeResult> {
|
||||
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<AttemptFirstAgentBranchAutoNameResult> {
|
||||
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<ReturnType<typeof createWorktreeCore>>;
|
||||
}): 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<CreatePaseoWorktreeDeps, "generateBranchName">;
|
||||
}): Promise<WorktreeConfig> {
|
||||
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;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<WorktreeCreationIntent> {
|
||||
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<AgentAttachment, { type: "github_pr" }> | null;
|
||||
repoRoot: string;
|
||||
deps: ResolveWorktreeCreationIntentDeps;
|
||||
}): Promise<Extract<WorktreeCreationIntent, { kind: "checkout-github-pr" }>> {
|
||||
@@ -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<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: {
|
||||
refName?: string;
|
||||
githubPrNumber: number;
|
||||
githubPrAttachment: Extract<AgentAttachment, { type: "github_pr" }> | 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<AgentAttachment, { type: "github_pr" }> | null,
|
||||
repoRoot: string,
|
||||
deps: ResolveWorktreeCreationIntentDeps,
|
||||
): Promise<string> {
|
||||
return attachment?.baseRefName?.trim() || (await resolveDefaultBranch(repoRoot, deps));
|
||||
}
|
||||
|
||||
@@ -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<PersistedWorkspaceRecord> {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof DictationStreamFinishM
|
||||
export type DictationStreamCancelMessage = z.infer<typeof DictationStreamCancelMessageSchema>;
|
||||
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
||||
export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>;
|
||||
export type FirstAgentContext = z.infer<typeof FirstAgentContextSchema>;
|
||||
export type ReviewAttachment = z.infer<typeof ReviewAttachmentSchema>;
|
||||
export type ListProviderModelsRequestMessage = z.infer<
|
||||
typeof ListProviderModelsRequestMessageSchema
|
||||
|
||||
Reference in New Issue
Block a user