Support split worktree agent launches
Expose the workspace id from worktree creation and let agent creation attach to an existing workspace, so callers can split worktree setup from agent launch without reusing the caller workspace. Keep worktreeSlug as the worktree path slug and add branchName for branch-off targets.
This commit is contained in:
@@ -95,6 +95,7 @@ export interface CreateAgentFromMcpInput {
|
||||
} | null;
|
||||
worktree?: {
|
||||
worktreeName?: string;
|
||||
branchName?: string;
|
||||
baseBranch?: string;
|
||||
refName?: string;
|
||||
action?: "branch-off" | "checkout";
|
||||
@@ -419,6 +420,7 @@ async function resolveMcpCwd(params: {
|
||||
input: {
|
||||
cwd: params.cwd,
|
||||
worktreeSlug: worktree.worktreeName,
|
||||
branchName: worktree.branchName,
|
||||
refName: worktree.refName,
|
||||
action: worktree.action,
|
||||
githubPrNumber: worktree.githubPrNumber,
|
||||
|
||||
@@ -636,7 +636,7 @@ describe("create_agent MCP tool", () => {
|
||||
const detachedWorktreeWorkspace = (
|
||||
cwd: string,
|
||||
target:
|
||||
| { kind: "branch-off"; worktreeSlug?: string; baseBranch?: string }
|
||||
| { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string }
|
||||
| { kind: "checkout-branch"; branch: string }
|
||||
| { kind: "checkout-pr"; githubPrNumber: number },
|
||||
) => ({
|
||||
@@ -651,6 +651,10 @@ describe("create_agent MCP tool", () => {
|
||||
relationship: { kind: "detached" as const },
|
||||
workspace: { kind: "current" as const, ...(cwd ? { cwd } : {}) },
|
||||
});
|
||||
const detachedExistingWorkspace = (workspaceId: string, cwd?: string) => ({
|
||||
relationship: { kind: "detached" as const },
|
||||
workspace: { kind: "existing" as const, workspaceId, ...(cwd ? { cwd } : {}) },
|
||||
});
|
||||
const ensureWorkspaceForCreate = async () => "workspace-created";
|
||||
|
||||
it("requires a concise title no longer than 60 characters", async () => {
|
||||
@@ -800,6 +804,46 @@ describe("create_agent MCP tool", () => {
|
||||
).rejects.toThrow("Caller agent parent-agent has no current workspace");
|
||||
});
|
||||
|
||||
it("attaches create_agent to an existing workspace id", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "existing-workspace-agent",
|
||||
cwd: existingCwd,
|
||||
workspaceId: "wks_existing",
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Existing workspace" },
|
||||
} as ManagedAgent);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
listActiveWorkspaces: async () => [
|
||||
{ workspaceId: "wks_existing", cwd: existingCwd, kind: "worktree" },
|
||||
],
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
|
||||
const response = await tool.handler({
|
||||
...detachedExistingWorkspace("wks_existing"),
|
||||
title: "Existing workspace",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: true,
|
||||
});
|
||||
|
||||
expect(response.structuredContent.workspaceId).toBe("wks_existing");
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: existingCwd,
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "wks_existing" },
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts provider features and passes them through createAgent", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
@@ -1194,6 +1238,7 @@ describe("create_agent MCP tool", () => {
|
||||
...detachedWorktreeWorkspace(repoDir, {
|
||||
kind: "branch-off",
|
||||
worktreeSlug: "agent-worktree",
|
||||
branchName: "feature/agent-worktree",
|
||||
baseBranch: "main",
|
||||
}),
|
||||
title: "Worktree agent",
|
||||
@@ -1207,6 +1252,14 @@ describe("create_agent MCP tool", () => {
|
||||
expect(broadcasts[0]).toBe(createdWorkspaceIds[0]);
|
||||
expect(setupContinuations).toEqual(["agent"]);
|
||||
expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]);
|
||||
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
|
||||
const branchName = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd: agentCwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(branchName).toBe("feature/agent-worktree");
|
||||
// The agent is stamped with the freshly created worktree's workspaceId so
|
||||
// workspaceId-scoped archive can find and tear it down later.
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
@@ -1528,11 +1581,17 @@ describe("create_agent MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_worktree");
|
||||
const response = await tool.handler({
|
||||
cwd: repoDir,
|
||||
target: { kind: "branch-off", worktreeSlug: "tool-worktree", baseBranch: "main" },
|
||||
target: {
|
||||
kind: "branch-off",
|
||||
worktreeSlug: "tool-worktree",
|
||||
branchName: "feature/tool-worktree",
|
||||
baseBranch: "main",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.structuredContent.branchName).toBe("tool-worktree");
|
||||
expect(response.structuredContent.branchName).toBe("feature/tool-worktree");
|
||||
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
|
||||
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
|
||||
@@ -764,7 +764,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Optional worktree branch/slug. Omit to let Paseo generate one."),
|
||||
.describe("Optional worktree slug/path label. Omit to let Paseo generate one."),
|
||||
branchName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Optional git branch name. Defaults to the worktree slug."),
|
||||
baseBranch: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -796,6 +801,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
})
|
||||
.strict()
|
||||
.describe("Use the caller's current workspace."),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("existing"),
|
||||
workspaceId: z.string().min(1).describe("Existing workspace id to attach the agent to."),
|
||||
cwd: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional runtime cwd. Defaults to the existing workspace cwd."),
|
||||
})
|
||||
.strict()
|
||||
.describe("Attach the agent to an existing workspace."),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("create"),
|
||||
@@ -993,6 +1009,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
type: AgentProviderEnum,
|
||||
status: AgentStatusEnum,
|
||||
cwd: z.string(),
|
||||
workspaceId: z.string().optional(),
|
||||
currentModeId: z.string().nullable(),
|
||||
availableModes: z.array(ProviderModeSchema),
|
||||
lastMessage: z.string().nullable().optional(),
|
||||
@@ -1065,6 +1082,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
type: snapshot.provider,
|
||||
status: result.status,
|
||||
cwd: liveSnapshot.cwd,
|
||||
...(liveSnapshot.workspaceId ? { workspaceId: liveSnapshot.workspaceId } : {}),
|
||||
currentModeId: liveSnapshot.currentModeId,
|
||||
availableModes: liveSnapshot.availableModes,
|
||||
lastMessage: result.lastMessage,
|
||||
@@ -1096,6 +1114,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
type: snapshot.provider,
|
||||
status: currentSnapshot.lifecycle,
|
||||
cwd: currentSnapshot.cwd,
|
||||
...(currentSnapshot.workspaceId ? { workspaceId: currentSnapshot.workspaceId } : {}),
|
||||
currentModeId: currentSnapshot.currentModeId,
|
||||
availableModes: currentSnapshot.availableModes,
|
||||
lastMessage: null,
|
||||
@@ -1173,6 +1192,30 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
};
|
||||
}
|
||||
|
||||
if (workspace.kind === "existing") {
|
||||
if (!options.listActiveWorkspaces) {
|
||||
throw new Error("Workspace lookup is not configured");
|
||||
}
|
||||
const existingWorkspace = (await options.listActiveWorkspaces()).find(
|
||||
(candidate) => candidate.workspaceId === workspace.workspaceId,
|
||||
);
|
||||
if (!existingWorkspace) {
|
||||
throw new Error(`Workspace ${workspace.workspaceId} not found`);
|
||||
}
|
||||
const cwd = workspace.cwd
|
||||
? resolveScopedCwd(workspace.cwd, { required: true })
|
||||
: existingWorkspace.cwd;
|
||||
const lockedCwd = callerContext?.lockedCwd?.trim();
|
||||
if (lockedCwd && !isSameOrDescendantPath(expandUserPath(lockedCwd), cwd)) {
|
||||
throw new Error(`Workspace ${workspace.workspaceId} is outside the allowed cwd`);
|
||||
}
|
||||
return {
|
||||
cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
worktree: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (workspace.source.kind === "directory") {
|
||||
const cwd = resolveScopedCwd(workspace.source.path, { required: true });
|
||||
if (!options.ensureWorkspaceForCreate) {
|
||||
@@ -1201,6 +1244,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
return {
|
||||
action: "branch-off",
|
||||
worktreeName: target.worktreeSlug,
|
||||
branchName: target.branchName,
|
||||
baseBranch: target.baseBranch,
|
||||
};
|
||||
case "checkout-branch":
|
||||
@@ -2292,6 +2336,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
outputSchema: {
|
||||
branchName: z.string(),
|
||||
worktreePath: z.string(),
|
||||
workspaceId: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ cwd, target }) => {
|
||||
@@ -2307,7 +2352,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
if (!commandResult.ok) {
|
||||
throw new WorktreeRequestError(commandResult.error);
|
||||
}
|
||||
const { worktree } = commandResult.createdWorktree;
|
||||
const { worktree, workspace } = commandResult.createdWorktree;
|
||||
await options.workspaceGitService?.listWorktrees?.(repoRoot, {
|
||||
force: true,
|
||||
reason: "mcp:create-worktree",
|
||||
@@ -2318,6 +2363,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
structuredContent: ensureValidJson({
|
||||
branchName: worktree.branchName,
|
||||
worktreePath: worktree.worktreePath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
}),
|
||||
};
|
||||
},
|
||||
@@ -2530,7 +2576,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
}
|
||||
|
||||
type McpCreateWorktreeTarget =
|
||||
| { kind: "branch-off"; worktreeSlug?: string; baseBranch?: string }
|
||||
| { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string }
|
||||
| { kind: "checkout-branch"; branch: string }
|
||||
| { kind: "checkout-pr"; githubPrNumber: number };
|
||||
|
||||
@@ -2604,6 +2650,7 @@ function createMcpWorktreeCommandInput(
|
||||
return {
|
||||
...base,
|
||||
worktreeSlug: target.worktreeSlug,
|
||||
branchName: target.branchName,
|
||||
action: "branch-off",
|
||||
...(target.baseBranch ? { refName: target.baseBranch } : {}),
|
||||
};
|
||||
|
||||
@@ -6,18 +6,21 @@ export type WorktreeCreationIntent = WorktreeSource;
|
||||
export type ResolveWorktreeCreationIntentInput =
|
||||
| {
|
||||
worktreeSlug: string;
|
||||
branchName?: string;
|
||||
refName?: string;
|
||||
action?: "branch-off";
|
||||
githubPrNumber?: undefined;
|
||||
}
|
||||
| {
|
||||
worktreeSlug?: string;
|
||||
branchName?: string;
|
||||
refName?: string;
|
||||
action: "checkout";
|
||||
githubPrNumber?: number;
|
||||
}
|
||||
| {
|
||||
worktreeSlug?: string;
|
||||
branchName?: string;
|
||||
refName?: string;
|
||||
action?: undefined;
|
||||
githubPrNumber: number;
|
||||
@@ -46,7 +49,7 @@ export async function resolveWorktreeCreationIntent(
|
||||
return {
|
||||
kind: "branch-off",
|
||||
baseBranch: input.refName?.trim() || (await resolveDefaultBranch(repoRoot, deps)),
|
||||
branchName: input.worktreeSlug,
|
||||
branchName: input.branchName ?? input.worktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,14 +87,14 @@ export async function resolveWorktreeCreationIntent(
|
||||
return {
|
||||
kind: "branch-off",
|
||||
baseBranch: input.refName.trim(),
|
||||
branchName: input.worktreeSlug,
|
||||
branchName: input.branchName ?? input.worktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "branch-off",
|
||||
baseBranch: await resolveDefaultBranch(repoRoot, deps),
|
||||
branchName: input.worktreeSlug,
|
||||
branchName: input.branchName ?? input.worktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
export interface CreateWorktreeCoreInput {
|
||||
cwd: string;
|
||||
worktreeSlug?: string;
|
||||
branchName?: string;
|
||||
refName?: string;
|
||||
action?: "branch-off" | "checkout";
|
||||
githubPrNumber?: number;
|
||||
@@ -49,6 +50,9 @@ export async function createWorktreeCore(
|
||||
const requestedWorktreeSlug = input.worktreeSlug
|
||||
? normalizeWorktreeSlug(input.worktreeSlug)
|
||||
: undefined;
|
||||
const requestedBranchName = input.branchName
|
||||
? validateWorktreeSlug(input.branchName.trim())
|
||||
: undefined;
|
||||
|
||||
let intentInput: ResolveWorktreeCreationIntentInput;
|
||||
if (input.action === "checkout") {
|
||||
@@ -69,6 +73,7 @@ export async function createWorktreeCore(
|
||||
intentInput = {
|
||||
action: "branch-off",
|
||||
refName: input.refName,
|
||||
branchName: requestedBranchName,
|
||||
worktreeSlug,
|
||||
};
|
||||
}
|
||||
@@ -81,7 +86,7 @@ export async function createWorktreeCore(
|
||||
|
||||
switch (intent.kind) {
|
||||
case "branch-off": {
|
||||
normalizedSlug = intent.branchName;
|
||||
normalizedSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.branchName);
|
||||
break;
|
||||
}
|
||||
case "checkout-branch": {
|
||||
|
||||
Reference in New Issue
Block a user