Let agents rename workspaces (#1876)

* feat(agent): let agents rename workspaces

* fix(agent): scope workspace rename tool

* fix(agent): allow explicit workspace renames
This commit is contained in:
Mohamed Boudra
2026-07-03 15:41:01 +02:00
committed by GitHub
parent 8d3a43e05d
commit 6ce4e3f507
4 changed files with 270 additions and 2 deletions

View File

@@ -73,7 +73,7 @@ Anyone who builds software:
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
- Frequent releases (multiple per week)
- Community contributions across packaging, providers, and bug fixes

View File

@@ -23,7 +23,11 @@ import {
AgentPermissionRequestPayloadSchema,
AgentSnapshotPayloadSchema,
} from "@getpaseo/protocol/messages";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js";
import {
createPersistedWorkspaceRecord,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
} from "../workspace-registry.js";
import type {
CreateScheduleInput,
StoredSchedule,
@@ -3070,6 +3074,191 @@ describe("update_agent MCP tool", () => {
});
});
describe("rename_workspace MCP tool", () => {
const logger = createTestLogger();
it("renames the caller workspace when workspaceId is omitted", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const workspace = createPersistedWorkspaceRecord({
workspaceId: "wks_parent",
projectId: "proj_parent",
cwd: REPO_CWD,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-07-03T09:00:00.000Z",
updatedAt: "2026-07-03T09:00:00.000Z",
});
const workspaces = new Map([[workspace.workspaceId, workspace]]);
const upsertedWorkspaces: PersistedWorkspaceRecord[] = [];
const emittedWorkspaceIds: string[][] = [];
spies.agentManager.getAgent.mockReturnValue(
createManagedAgent({
id: "parent-agent",
cwd: REPO_CWD,
workspaceId: workspace.workspaceId,
}),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
upsert: async (record) => {
upsertedWorkspaces.push(record);
workspaces.set(record.workspaceId, record);
},
},
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
emittedWorkspaceIds.push(Array.from(workspaceIds));
},
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "rename_workspace");
const response = await invokeToolWithParsedInput(tool, {
title: " Payments flow ",
});
expect(upsertedWorkspaces).toEqual([
{
...workspace,
title: "Payments flow",
updatedAt: expect.any(String),
},
]);
expect(response.structuredContent).toEqual({
success: true,
workspaceId: "wks_parent",
title: "Payments flow",
});
expect(emittedWorkspaceIds).toEqual([["wks_parent"]]);
});
it("renames an explicit workspace outside the caller workspace", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const parentWorkspace = createPersistedWorkspaceRecord({
workspaceId: "wks_parent",
projectId: "proj_parent",
cwd: REPO_CWD,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-07-03T09:00:00.000Z",
updatedAt: "2026-07-03T09:00:00.000Z",
});
const otherWorkspace = createPersistedWorkspaceRecord({
workspaceId: "wks_other",
projectId: "proj_other",
cwd: TARGET_CWD,
kind: "local_checkout",
displayName: "other",
createdAt: "2026-07-03T09:00:00.000Z",
updatedAt: "2026-07-03T09:00:00.000Z",
});
const workspaces = new Map([
[parentWorkspace.workspaceId, parentWorkspace],
[otherWorkspace.workspaceId, otherWorkspace],
]);
const upsertedWorkspaces: PersistedWorkspaceRecord[] = [];
const emittedWorkspaceIds: string[][] = [];
spies.agentManager.getAgent.mockReturnValue(
createManagedAgent({
id: "parent-agent",
cwd: REPO_CWD,
workspaceId: parentWorkspace.workspaceId,
}),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
upsert: async (record) => {
upsertedWorkspaces.push(record);
workspaces.set(record.workspaceId, record);
},
},
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
emittedWorkspaceIds.push(Array.from(workspaceIds));
},
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "rename_workspace");
const response = await invokeToolWithParsedInput(tool, {
workspaceId: "wks_other",
title: "Payments flow",
});
expect(upsertedWorkspaces).toEqual([
{
...otherWorkspace,
title: "Payments flow",
updatedAt: expect.any(String),
},
]);
expect(response.structuredContent).toEqual({
success: true,
workspaceId: "wks_other",
title: "Payments flow",
});
expect(emittedWorkspaceIds).toEqual([["wks_other"]]);
});
it("rejects archived workspaces", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const workspace = createPersistedWorkspaceRecord({
workspaceId: "wks_archived",
projectId: "proj_parent",
cwd: REPO_CWD,
kind: "local_checkout",
displayName: "main",
archivedAt: "2026-07-03T10:00:00.000Z",
createdAt: "2026-07-03T09:00:00.000Z",
updatedAt: "2026-07-03T10:00:00.000Z",
});
const workspaces = new Map([[workspace.workspaceId, workspace]]);
const upsertedWorkspaces: PersistedWorkspaceRecord[] = [];
const emittedWorkspaceIds: string[][] = [];
spies.agentManager.getAgent.mockReturnValue(
createManagedAgent({
id: "parent-agent",
cwd: REPO_CWD,
workspaceId: workspace.workspaceId,
}),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
upsert: async (record) => {
upsertedWorkspaces.push(record);
workspaces.set(record.workspaceId, record);
},
},
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
emittedWorkspaceIds.push(Array.from(workspaceIds));
},
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "rename_workspace");
await expect(
invokeToolWithParsedInput(tool, {
title: "Payments flow",
}),
).rejects.toThrow("Workspace wks_archived is archived");
expect(upsertedWorkspaces).toEqual([]);
expect(emittedWorkspaceIds).toEqual([]);
});
});
describe("create_schedule MCP tool", () => {
const logger = createTestLogger();

View File

@@ -65,6 +65,7 @@ import {
} from "../lifecycle-command.js";
import type { GitHubService } from "../../../services/github-service.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { WorkspaceRegistry } from "../../workspace-registry.js";
import { WorktreeRequestError } from "../../worktree-errors.js";
import {
archiveCommand,
@@ -99,6 +100,7 @@ export interface PaseoToolHostDependencies {
listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"];
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "upsert">;
markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"];
clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"];
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
@@ -554,6 +556,22 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
return options.ensureWorkspaceForCreate(resolvedCwd);
}
function resolveWorkspaceIdForRename(requestedWorkspaceId?: string): string {
const explicitWorkspaceId = requestedWorkspaceId?.trim();
if (explicitWorkspaceId) {
return explicitWorkspaceId;
}
if (callerAgentId) {
const callerAgent = resolveCallerAgent();
if (!callerAgent?.workspaceId) {
throw new Error(`Caller agent ${callerAgentId} has no current workspace`);
}
return callerAgent.workspaceId;
}
throw new Error("workspaceId is required outside an agent-scoped session");
}
const buildCallerAgentScheduleConfigExtras = (
callerAgent: NonNullable<ReturnType<typeof resolveCallerAgent>>,
): Record<string, unknown> => {
@@ -1781,6 +1799,66 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
},
);
registerTool(
"rename_workspace",
{
title: "Rename workspace",
description:
"Rename a workspace by setting its user-visible title. Omit workspaceId to rename your current workspace.",
inputSchema: {
workspaceId: z
.string()
.trim()
.min(1)
.optional()
.describe("Workspace id to rename. Omit to rename your current workspace."),
title: z
.string()
.trim()
.min(1, "title is required")
.describe("New user-visible workspace title."),
},
outputSchema: {
success: z.boolean(),
workspaceId: z.string(),
title: z.string(),
},
},
async ({ workspaceId: requestedWorkspaceId, title }) => {
if (!options.workspaceRegistry) {
throw new Error("Workspace registry is required to rename workspaces");
}
if (!options.emitWorkspaceUpdatesForWorkspaceIds) {
throw new Error("Workspace update emitter is required to rename workspaces");
}
const workspaceId = resolveWorkspaceIdForRename(requestedWorkspaceId);
const existing = await options.workspaceRegistry.get(workspaceId);
if (!existing) {
throw new Error(`Workspace ${workspaceId} not found`);
}
if (existing.archivedAt) {
throw new Error(`Workspace ${workspaceId} is archived`);
}
await options.workspaceRegistry.upsert({
...existing,
title,
updatedAt: new Date().toISOString(),
});
await options.emitWorkspaceUpdatesForWorkspaceIds([workspaceId]);
return {
content: [],
structuredContent: ensureValidJson({
success: true,
workspaceId,
title,
}),
};
},
);
registerTool(
"list_terminals",
{

View File

@@ -976,6 +976,7 @@ export async function createPaseoDaemon(
listActiveWorkspaces: listActiveWorkspacesExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
workspaceRegistry,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal,