mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -73,7 +73,7 @@ Anyone who builds software:
|
|||||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
- 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
|
- 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
|
- 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
|
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||||
- Frequent releases (multiple per week)
|
- Frequent releases (multiple per week)
|
||||||
- Community contributions across packaging, providers, and bug fixes
|
- Community contributions across packaging, providers, and bug fixes
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ import {
|
|||||||
AgentPermissionRequestPayloadSchema,
|
AgentPermissionRequestPayloadSchema,
|
||||||
AgentSnapshotPayloadSchema,
|
AgentSnapshotPayloadSchema,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js";
|
import {
|
||||||
|
createPersistedWorkspaceRecord,
|
||||||
|
type PersistedProjectRecord,
|
||||||
|
type PersistedWorkspaceRecord,
|
||||||
|
} from "../workspace-registry.js";
|
||||||
import type {
|
import type {
|
||||||
CreateScheduleInput,
|
CreateScheduleInput,
|
||||||
StoredSchedule,
|
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", () => {
|
describe("create_schedule MCP tool", () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
} from "../lifecycle-command.js";
|
} from "../lifecycle-command.js";
|
||||||
import type { GitHubService } from "../../../services/github-service.js";
|
import type { GitHubService } from "../../../services/github-service.js";
|
||||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||||
|
import type { WorkspaceRegistry } from "../../workspace-registry.js";
|
||||||
import { WorktreeRequestError } from "../../worktree-errors.js";
|
import { WorktreeRequestError } from "../../worktree-errors.js";
|
||||||
import {
|
import {
|
||||||
archiveCommand,
|
archiveCommand,
|
||||||
@@ -99,6 +100,7 @@ export interface PaseoToolHostDependencies {
|
|||||||
listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"];
|
listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"];
|
||||||
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
|
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
|
||||||
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
|
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
|
||||||
|
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "upsert">;
|
||||||
markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"];
|
markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"];
|
||||||
clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"];
|
clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"];
|
||||||
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
||||||
@@ -554,6 +556,22 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
|||||||
return options.ensureWorkspaceForCreate(resolvedCwd);
|
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 = (
|
const buildCallerAgentScheduleConfigExtras = (
|
||||||
callerAgent: NonNullable<ReturnType<typeof resolveCallerAgent>>,
|
callerAgent: NonNullable<ReturnType<typeof resolveCallerAgent>>,
|
||||||
): Record<string, unknown> => {
|
): 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(
|
registerTool(
|
||||||
"list_terminals",
|
"list_terminals",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -976,6 +976,7 @@ export async function createPaseoDaemon(
|
|||||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||||
|
workspaceRegistry,
|
||||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||||
ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal,
|
ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal,
|
||||||
|
|||||||
Reference in New Issue
Block a user