diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 4a6aad495..0346c648c 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -47,7 +47,10 @@ interface CreateAgentCommandDependencies { providerSnapshotManager: ProviderSnapshotManager; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; // Mints a fresh directory workspace for a cwd and returns its id. - ensureWorkspaceForCreate?: (cwd: string) => Promise; + ensureWorkspaceForCreate?: ( + cwd: string, + firstAgentContext?: FirstAgentContext, + ) => Promise; } export interface CreateAgentFromSessionInput { @@ -253,7 +256,7 @@ async function resolveMcpCreateAgent( ? createdWorkspaceId : (input.workspaceId ?? parentAgent?.workspaceId ?? - (await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd))); + (await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt))); const { modeId: resolvedMode, featureValues: resolvedFeatures } = await dependencies.providerSnapshotManager.resolveCreateConfig({ @@ -300,11 +303,12 @@ async function resolveMcpCreateAgent( async function ensureWorkspaceForMcpCreate( dependencies: CreateAgentCommandDependencies, cwd: string, + initialPrompt: string, ): Promise { if (!dependencies.ensureWorkspaceForCreate) { return undefined; } - return dependencies.ensureWorkspaceForCreate(cwd); + return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt }); } async function sendInitialPrompt( @@ -424,7 +428,7 @@ async function resolveMcpCwd(params: { refName: worktree.refName, action: worktree.action, githubPrNumber: worktree.githubPrNumber, - ...(params.initialPrompt ? { firstAgentContext: { prompt: params.initialPrompt } } : {}), + firstAgentContext: { prompt: params.initialPrompt }, runSetup: false, paseoHome: dependencies.paseoHome, worktreesRoot: dependencies.worktreesRoot, diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 707269d84..ba518ac1d 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -39,13 +39,20 @@ import { createPaseoWorktree as createPaseoWorktreeService, type CreatePaseoWorktreeInput, } from "../paseo-worktree-service.js"; -import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js"; +import { + createPaseoWorktreeWorkflow, + type CreatePaseoWorktreeWorkflowFn, +} from "../worktree-session.js"; import { WorkspaceGitServiceImpl } from "../workspace-git-service.js"; +import { WorkspaceAutoName } from "../workspace-auto-name.js"; +import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js"; +import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js"; import type { GitHubService } from "../../services/github-service.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js"; import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js"; +import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js"; const REPO_CWD = resolvePath("/tmp/repo"); const TARGET_CWD = resolvePath("/tmp/target"); @@ -147,6 +154,34 @@ function agentsOf(response: { return z.array(z.record(z.string(), z.unknown())).parse(response.structuredContent.agents); } +async function waitForWorkspaceTitle( + workspaceRecords: Map, + workspaceId: string, + title: string, +): Promise { + await vi.waitFor(() => expect(workspaceRecords.get(workspaceId)?.title).toBe(title), { + timeout: 5_000, + }); +} + +async function waitForWorkspaceBranch( + workspaceRecords: Map, + workspaceId: string, + branch: string, +): Promise { + await vi.waitFor(() => expect(workspaceRecords.get(workspaceId)?.branch).toBe(branch), { + timeout: 5_000, + }); +} + +async function waitForUnexpectedWorkspaceNamingSideEffects(): Promise { + await new Promise((resolve) => setTimeout(resolve, 25)); +} + +async function removeTempDir(path: string): Promise { + await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +} + type AgentManagerSpies = ReturnType; type AgentStorageSpies = ReturnType; @@ -616,40 +651,88 @@ function createPaseoWorktreeForMcpTest(options: { paseoHome: string; broadcasts: string[]; createdWorkspaceIds?: string[]; + workspaceRecords?: Map; + generateWorkspaceName?: () => Promise; setupContinuations?: Array<"workspace" | "agent" | undefined>; startedAgentSetupIds?: string[]; }): CreatePaseoWorktreeWorkflowFn { const projects = new Map(); - const workspaces = new Map(); + const workspaces = options.workspaceRecords ?? new Map(); const github = createGitHubServiceStub(); const workspaceGitService = new WorkspaceGitServiceImpl({ logger: createTestLogger(), paseoHome: options.paseoHome, deps: { github }, }); + const workspaceRegistry = { + get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null, + list: async () => Array.from(workspaces.values()), + upsert: async (record: PersistedWorkspaceRecord) => { + workspaces.set(record.workspaceId, record); + }, + }; + const workspaceAutoName = new WorkspaceAutoName({ + agentManager: buildAgentManagerSpies() as unknown as AgentManager, + workspaceRegistry, + workspaceGitService, + providerSnapshotManager: createOpenCodeManager().manager, + readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }), + gitMutation: createGitMutationService({ + workspaceGitService, + github, + logger: createTestLogger(), + }), + emitWorkspaceUpdateForCwd: async (cwd) => { + const workspace = Array.from(workspaces.values()).find((record) => record.cwd === cwd); + options.broadcasts.push(z.string().parse(workspace?.workspaceId)); + }, + emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { + options.broadcasts.push(workspaceId); + }, + logger: createTestLogger(), + generateWorkspaceName: options.generateWorkspaceName ?? (async () => null), + }); return async (input, serviceOptions) => { options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind); - const result = await createPaseoWorktreeService(input, { - github, - ...(serviceOptions?.resolveDefaultBranch - ? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch } - : {}), - projectRegistry: { - get: async (projectId) => projects.get(projectId) ?? null, - upsert: async (record) => { - projects.set(record.projectId, record); + const result = await createPaseoWorktreeWorkflow( + { + paseoHome: options.paseoHome, + createPaseoWorktree: (workflowInput, workflowOptions) => + createPaseoWorktreeService(workflowInput, { + github, + ...(workflowOptions?.resolveDefaultBranch + ? { resolveDefaultBranch: workflowOptions.resolveDefaultBranch } + : {}), + projectRegistry: { + get: async (projectId) => projects.get(projectId) ?? null, + upsert: async (record) => { + projects.set(record.projectId, record); + }, + }, + workspaceRegistry, + workspaceGitService, + }), + warmWorkspaceGitData: async () => {}, + autoNameWorkspaceBranchForFirstAgent: (autoNameInput) => + workspaceAutoName.scheduleForWorktree(autoNameInput), + emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { + options.broadcasts.push(workspaceId); }, + cacheWorkspaceSetupSnapshot: () => {}, + emit: () => {}, + sessionLogger: createTestLogger(), + terminalManager: null, + archiveWorkspaceRecord: async () => {}, + serviceProxy: null, + scriptRuntimeStore: null, + getDaemonTcpPort: null, + getDaemonTcpHost: null, + onScriptsChanged: null, }, - workspaceRegistry: { - get: async (workspaceId) => workspaces.get(workspaceId) ?? null, - list: async () => Array.from(workspaces.values()), - upsert: async (record) => { - workspaces.set(record.workspaceId, record); - }, - }, - workspaceGitService, - }); + input, + serviceOptions, + ); options.broadcasts.push(result.workspace.workspaceId); options.createdWorkspaceIds?.push(result.workspace.workspaceId); if (serviceOptions?.setupContinuation?.kind === "agent") { @@ -1629,7 +1712,7 @@ describe("create_agent MCP tool", () => { { workspaceId: createdWorkspaceIds[0] }, ); } finally { - await rm(tempDir, { recursive: true, force: true }); + await removeTempDir(tempDir); } }); @@ -1703,20 +1786,406 @@ describe("create_agent MCP tool", () => { .trim(); expect(initialBranch).not.toBe(""); expect(initialBranch).not.toBe("main"); - await new Promise((resolve) => setTimeout(resolve, 0)); + await waitForUnexpectedWorkspaceNamingSideEffects(); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(broadcasts).toHaveLength(1); } finally { - await rm(tempDir, { recursive: true, force: true }); + await removeTempDir(tempDir); } }); - it("does not auto-rename a create_agent checkout worktree from the initial prompt", async () => { + it("auto-titles and renames an agent-created branch-off worktree from the initial prompt", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-auto-title-")); + const repoDir = join(tempDir, "repo"); + const paseoHome = join(tempDir, ".paseo"); + const broadcasts: string[] = []; + const createdWorkspaceIds: string[] = []; + const workspaceRecords = new Map(); + + try { + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { + cwd: repoDir, + stdio: "pipe", + }); + await writeFile(join(repoDir, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); + + spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ + id: "agent-auto-titled-worktree", + cwd: config.cwd, + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "Agent title" }, + })); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + paseoHome, + createPaseoWorktree: createPaseoWorktreeForMcpTest({ + paseoHome, + broadcasts, + createdWorkspaceIds, + workspaceRecords, + generateWorkspaceName: async () => ({ + title: "Workspace Auto Title Flow", + branch: "workspace-auto-title-flow", + }), + }), + logger, + }); + const tool = registeredTool(server, "create_agent"); + await tool.handler({ + ...detachedWorktreeWorkspace(repoDir, { + kind: "branch-off", + branchName: "feat/placeholder-auto-title", + baseBranch: "main", + }), + title: "Agent title", + provider: "codex/gpt-5.4", + initialPrompt: "Build a workspace auto title flow", + background: true, + }); + const workspaceId = z.string().parse(createdWorkspaceIds[0]); + await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Workspace Auto Title Flow"); + + const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); + const workspace = workspaceRecords.get(workspaceId); + const branchName = execFileSync("git", ["branch", "--show-current"], { + cwd: agentCwd, + stdio: "pipe", + }) + .toString() + .trim(); + const metadata = readPaseoWorktreeMetadata(agentCwd); + + expect(metadata).toMatchObject({ + version: 2, + firstAgentBranchAutoName: { + status: "attempted", + placeholderBranchName: "feat/placeholder-auto-title", + }, + }); + expect(branchName).toBe("workspace-auto-title-flow"); + expect(workspace).toMatchObject({ + title: "Workspace Auto Title Flow", + branch: "workspace-auto-title-flow", + }); + } finally { + await removeTempDir(tempDir); + } + }); + + it("keeps a manual workspace title when agent-created worktree naming finishes later", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-manual-title-")); + const repoDir = join(tempDir, "repo"); + const paseoHome = join(tempDir, ".paseo"); + const broadcasts: string[] = []; + const createdWorkspaceIds: string[] = []; + const workspaceRecords = new Map(); + + try { + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { + cwd: repoDir, + stdio: "pipe", + }); + await writeFile(join(repoDir, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); + + spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ + id: "agent-manual-title-worktree", + cwd: config.cwd, + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "Agent title" }, + })); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + paseoHome, + createPaseoWorktree: createPaseoWorktreeForMcpTest({ + paseoHome, + broadcasts, + createdWorkspaceIds, + workspaceRecords, + generateWorkspaceName: async () => ({ + title: "Generated Manual Race Title", + branch: "generated-manual-race-title", + }), + }), + workspaceRegistry: { + get: async (workspaceId) => workspaceRecords.get(workspaceId) ?? null, + upsert: async (record) => { + workspaceRecords.set(record.workspaceId, record); + }, + }, + emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => { + broadcasts.push(...workspaceIds); + }, + logger, + }); + const createAgentTool = registeredTool(server, "create_agent"); + await createAgentTool.handler({ + ...detachedWorktreeWorkspace(repoDir, { + kind: "branch-off", + branchName: "feat/manual-title-placeholder", + baseBranch: "main", + }), + title: "Agent title", + provider: "codex/gpt-5.4", + initialPrompt: "Keep the manually renamed workspace title", + background: true, + }); + const renameWorkspaceTool = registeredTool(server, "rename_workspace"); + await renameWorkspaceTool.handler({ + workspaceId: createdWorkspaceIds[0], + title: "Manual Workspace Title", + }); + const workspaceId = z.string().parse(createdWorkspaceIds[0]); + await waitForWorkspaceBranch(workspaceRecords, workspaceId, "generated-manual-race-title"); + + const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); + const workspace = workspaceRecords.get(workspaceId); + const branchName = execFileSync("git", ["branch", "--show-current"], { + cwd: agentCwd, + stdio: "pipe", + }) + .toString() + .trim(); + + expect(branchName).toBe("generated-manual-race-title"); + expect(workspace).toMatchObject({ + title: "Manual Workspace Title", + branch: "generated-manual-race-title", + }); + } finally { + await removeTempDir(tempDir); + } + }); + + it("uses create_agent title for the agent while still auto-titling the worktree workspace", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-title-workspace-title-")); + const repoDir = join(tempDir, "repo"); + const paseoHome = join(tempDir, ".paseo"); + const broadcasts: string[] = []; + const createdWorkspaceIds: string[] = []; + const workspaceRecords = new Map(); + + try { + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { + cwd: repoDir, + stdio: "pipe", + }); + await writeFile(join(repoDir, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); + + spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ + id: "agent-explicit-title-worktree", + cwd: config.cwd, + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "Explicit Agent Title" }, + })); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + paseoHome, + createPaseoWorktree: createPaseoWorktreeForMcpTest({ + paseoHome, + broadcasts, + createdWorkspaceIds, + workspaceRecords, + generateWorkspaceName: async () => ({ + title: "Generated Workspace Title", + branch: "generated-workspace-title", + }), + }), + logger, + }); + const tool = registeredTool(server, "create_agent"); + await tool.handler({ + ...detachedWorktreeWorkspace(repoDir, { + kind: "branch-off", + branchName: "feat/agent-title-placeholder", + baseBranch: "main", + }), + title: "Explicit Agent Title", + provider: "codex/gpt-5.4", + initialPrompt: "Generate the workspace title anyway", + background: true, + }); + const workspaceId = z.string().parse(createdWorkspaceIds[0]); + await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Generated Workspace Title"); + + const workspace = workspaceRecords.get(workspaceId); + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Explicit Agent Title", + }), + undefined, + { workspaceId }, + ); + expect(workspace).toMatchObject({ + title: "Generated Workspace Title", + branch: "generated-workspace-title", + }); + } finally { + await removeTempDir(tempDir); + } + }); + + it("auto-titles an agent-created directory workspace from the initial prompt", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-directory-auto-title-")); + const workspaceDir = join(tempDir, "workspace"); + const workspaceRecords = new Map(); + const broadcasts: string[] = []; + const workspaceGitService = new WorkspaceGitServiceImpl({ + logger: createTestLogger(), + paseoHome: join(tempDir, ".paseo"), + deps: { github: createGitHubServiceStub() }, + }); + const workspaceAutoName = new WorkspaceAutoName({ + agentManager, + workspaceRegistry: { + get: async (workspaceId) => workspaceRecords.get(workspaceId) ?? null, + upsert: async (record) => { + workspaceRecords.set(record.workspaceId, record); + }, + }, + workspaceGitService, + providerSnapshotManager: createOpenCodeManager().manager, + readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }), + gitMutation: createGitMutationService({ + workspaceGitService, + github: createGitHubServiceStub(), + logger: createTestLogger(), + }), + emitWorkspaceUpdateForCwd: async () => {}, + emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { + broadcasts.push(workspaceId); + }, + logger: createTestLogger(), + generateWorkspaceName: async () => ({ + title: "Directory Workspace Title", + branch: "directory-workspace-title", + }), + }); + + try { + await mkdir(workspaceDir, { recursive: true }); + spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ + id: "agent-directory-auto-title", + cwd: config.cwd, + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "Directory agent" }, + })); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + ensureWorkspaceForCreate: async (cwd, firstAgentContext) => { + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "workspace-directory-auto-title", + projectId: "project-directory-auto-title", + cwd, + kind: "directory", + displayName: "workspace", + title: firstAgentContext?.prompt ?? null, + createdAt: "2026-07-03T00:00:00.000Z", + updatedAt: "2026-07-03T00:00:00.000Z", + }); + workspaceRecords.set(workspace.workspaceId, workspace); + if (firstAgentContext) { + workspaceAutoName.scheduleForDirectory({ + workspaceId: workspace.workspaceId, + cwd: workspace.cwd, + firstAgentContext, + }); + } + return workspace.workspaceId; + }, + logger, + }); + const tool = registeredTool(server, "create_agent"); + await tool.handler({ + ...detachedDirectoryWorkspace(workspaceDir), + title: "Directory agent", + provider: "codex/gpt-5.4", + initialPrompt: "Name a directory workspace from the prompt", + background: true, + }); + await waitForWorkspaceTitle( + workspaceRecords, + "workspace-directory-auto-title", + "Directory Workspace Title", + ); + + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: workspaceDir, + title: "Directory agent", + }), + undefined, + { workspaceId: "workspace-directory-auto-title" }, + ); + expect(workspaceRecords.get("workspace-directory-auto-title")).toMatchObject({ + title: "Directory Workspace Title", + branch: null, + }); + expect(broadcasts).toEqual(["workspace-directory-auto-title"]); + } finally { + await removeTempDir(tempDir); + } + }); + + it("auto-titles without renaming a create_agent checkout worktree from the initial prompt", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-checkout-name-context-")); const repoDir = join(tempDir, "repo"); const paseoHome = join(tempDir, ".paseo"); const broadcasts: string[] = []; + const createdWorkspaceIds: string[] = []; + const workspaceRecords = new Map(); + let generateCalls = 0; const workspaceGitService = { getSnapshot: vi.fn(async () => { throw new Error("agent metadata branch rename should not run"); @@ -1761,7 +2230,19 @@ describe("create_agent MCP tool", () => { agentStorage, providerSnapshotManager: createOpenCodeManager().manager, paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }), + createPaseoWorktree: createPaseoWorktreeForMcpTest({ + paseoHome, + broadcasts, + createdWorkspaceIds, + workspaceRecords, + generateWorkspaceName: async () => { + generateCalls += 1; + return { + title: "Generated Checkout Workspace Title", + branch: "generated-checkout-workspace-title", + }; + }, + }), workspaceGitService: workspaceGitService as unknown as Pick< WorkspaceGitService, "getSnapshot" | "listWorktrees" @@ -1781,16 +2262,30 @@ describe("create_agent MCP tool", () => { }); const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); + const workspaceId = z.string().parse(createdWorkspaceIds[0]); + await waitForWorkspaceTitle( + workspaceRecords, + workspaceId, + "Generated Checkout Workspace Title", + ); expect( execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" }) .toString() .trim(), ).toBe("existing-feature"); - await new Promise((resolve) => setTimeout(resolve, 0)); + expect(workspaceRecords.get(workspaceId)).toMatchObject({ + title: "Generated Checkout Workspace Title", + branch: "existing-feature", + }); + expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({ + version: 1, + baseRefName: "existing-feature", + }); + expect(generateCalls).toBe(1); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - expect(broadcasts).toHaveLength(1); + expect(broadcasts).toEqual([workspaceId, workspaceId]); } finally { - await rm(tempDir, { recursive: true, force: true }); + await removeTempDir(tempDir); } }); @@ -1888,7 +2383,7 @@ describe("create_agent MCP tool", () => { undefined, { workspaceId: "ws-pr-123" }, ); - await new Promise((resolve) => setTimeout(resolve, 0)); + await waitForUnexpectedWorkspaceNamingSideEffects(); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); }); @@ -1960,7 +2455,7 @@ describe("create_agent MCP tool", () => { expect(broadcasts).toHaveLength(1); expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/); } finally { - await rm(tempDir, { recursive: true, force: true }); + await removeTempDir(tempDir); } }); @@ -2056,7 +2551,7 @@ describe("create_agent MCP tool", () => { "ws-archive-tool-worktree", ]); } finally { - await rm(tempDir, { recursive: true, force: true }); + await removeTempDir(tempDir); } }); @@ -2143,7 +2638,7 @@ describe("create_agent MCP tool", () => { expect(archivedWorkspaceIds).toContain("ws-mcp-B"); await expect(access(worktreePath)).rejects.toThrow(); } finally { - await rm(tempDir, { recursive: true, force: true }); + await removeTempDir(tempDir); } }); @@ -2221,7 +2716,7 @@ describe("create_agent MCP tool", () => { access(z.string().parse(created.structuredContent.worktreePath)), ).rejects.toThrow(); } finally { - await rm(tempDir, { recursive: true, force: true }); + await removeTempDir(tempDir); } }); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index f10c7d1b3..a7bf449cc 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -29,6 +29,7 @@ import { import { WaitForAgentTracker } from "../wait-for-agent-tracker.js"; import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js"; +import type { FirstAgentContext } from "../../messages.js"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js"; @@ -105,7 +106,10 @@ export interface PaseoToolHostDependencies { clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"]; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; // Mints a fresh directory workspace for a cwd and returns its id. - ensureWorkspaceForCreate?: (cwd: string) => Promise; + ensureWorkspaceForCreate?: ( + cwd: string, + firstAgentContext?: FirstAgentContext, + ) => Promise; browserToolsBroker?: BrowserToolsBroker | null; paseoHome?: string; worktreesRoot?: string; @@ -1181,7 +1185,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase async function resolveCreateAgentToolArgs(args: unknown): Promise { if (callerAgentId) { const parsed = agentToAgentCreateAgentArgsSchema.parse(args); - const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace); + const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, { + prompt: parsed.initialPrompt, + }); return { kind: "agent-scoped", parsedArgs: parsed, @@ -1195,7 +1201,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase if (parsedArgs.relationship.kind === "subagent") { throw new Error("relationship subagent requires an agent-scoped tool session"); } - const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsedArgs.workspace); + const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsedArgs.workspace, { + prompt: parsedArgs.initialPrompt, + }); return { kind: "top-level", parsedArgs, @@ -1309,6 +1317,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase async function resolveCreateAgentWorkspace( workspace: AgentToAgentCreateAgentArgs["workspace"] | TopLevelCreateAgentArgs["workspace"], + firstAgentContext: FirstAgentContext | undefined, ): Promise<{ cwd: string | undefined; workspaceId: string | undefined; @@ -1360,7 +1369,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } return { cwd, - workspaceId: await options.ensureWorkspaceForCreate(cwd), + workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext), worktree: undefined, }; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 6d158716b..d57d6628e 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -139,7 +139,7 @@ import type { PushNotificationSender } from "./push/notifications.js"; import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; -import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import type { FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages"; import type { AgentProviderRuntimeSettingsMap, ProviderOverride, @@ -162,6 +162,10 @@ import { type DaemonAuthConfig, } from "./auth.js"; import { createWebUiMiddleware } from "./web-ui.js"; +import { WorkspaceAutoName } from "./workspace-auto-name.js"; +import { createGitMutationService } from "./session/git-mutation/git-mutation-service.js"; +import { workspaceIdsOnCheckout } from "./workspace-directory.js"; +import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js"; const MAX_MCP_DEBUG_BATCH_ITEMS = 10; const REDACTED_LOG_VALUE = "[redacted]"; @@ -854,11 +858,21 @@ export async function createPaseoDaemon( const findWorkspaceIdForCwdExternal = async (cwd: string): Promise => { return resolveWorkspaceIdForPath(cwd, await workspaceRegistry.list()); }; - const ensureWorkspaceForCreateExternal = async (cwd: string): Promise => { + const ensureWorkspaceForCreateExternal = async ( + cwd: string, + firstAgentContext?: FirstAgentContext, + ): Promise => { const workspace = await createLocalCheckoutWorkspace( - { cwd }, + { cwd, title: resolveFirstAgentPromptTitle(firstAgentContext) }, { projectRegistry, workspaceRegistry, workspaceGitService }, ); + if (firstAgentContext) { + workspaceAutoName.scheduleForDirectory({ + workspaceId: workspace.workspaceId, + cwd: workspace.cwd, + firstAgentContext, + }); + } return workspace.workspaceId; }; const listActiveWorkspacesExternal = async (): Promise => { @@ -891,9 +905,30 @@ export async function createPaseoDaemon( ), ); }; + const emitWorkspaceUpdateForCwdExternal = async (cwd: string) => { + const workspaceIds = workspaceIdsOnCheckout(await workspaceRegistry.list(), cwd); + await emitWorkspaceUpdatesExternal(workspaceIds); + }; const emitExternalSessionMessage = (message: SessionOutboundMessage) => { wsServer?.broadcast(wrapSessionMessage(message)); }; + const workspaceAutoName = new WorkspaceAutoName({ + agentManager, + workspaceRegistry, + workspaceGitService, + providerSnapshotManager, + readDaemonConfig: () => ({ metadataGeneration: daemonConfigStore.get().metadataGeneration }), + gitMutation: createGitMutationService({ + workspaceGitService, + github, + logger, + }), + emitWorkspaceUpdateForCwd: emitWorkspaceUpdateForCwdExternal, + emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { + await emitWorkspaceUpdatesExternal([workspaceId]); + }, + logger, + }); setupAutoArchiveOnMerge({ paseoHome: config.paseoHome, @@ -941,6 +976,8 @@ export async function createPaseoDaemon( .map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [], ); }, + autoNameWorkspaceBranchForFirstAgent: (autoNameInput) => + workspaceAutoName.scheduleForWorktree(autoNameInput), emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { await emitWorkspaceUpdatesExternal([workspaceId]); }, @@ -1208,6 +1245,7 @@ export async function createPaseoDaemon( daemonConfigStore, mcpBaseUrl, { allowedOrigins, hostnames: configuredHostnames }, + workspaceAutoName, config.auth, speechService, terminalManager, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4e4f4c1bc..939b017db 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -192,16 +192,12 @@ import { } from "./workspace-directory.js"; import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js"; import { - attemptFirstAgentBranchAutoName, createLocalCheckoutWorkspace, createPaseoWorktree, type CreatePaseoWorktreeInput, type CreatePaseoWorktreeResult, } from "./paseo-worktree-service.js"; -import { - generateBranchNameFromFirstAgentContext, - type GeneratedWorkspaceName, -} from "./worktree-branch-name-generator.js"; +import { WorkspaceAutoName } from "./workspace-auto-name.js"; import { buildAgentSessionConfig as buildWorktreeAgentSessionConfig, createPaseoWorktreeWorkflow as createWorktreeWorkflow, @@ -427,10 +423,8 @@ export interface SessionOptions { // Injected so tests can substitute the git branch rename without module mocks; // defaults to the real checkout-git implementation. renameCurrentBranch?: typeof renameCurrentBranchDefault; - // Injected so tests can substitute workspace title/branch generation without - // calling the LLM; defaults to the real first-agent-context generator. - generateWorkspaceName?: typeof generateBranchNameFromFirstAgentContext; workspaceGitService: WorkspaceGitService; + workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; mcpBaseUrl?: string | null; stt: Resolvable; @@ -549,8 +543,8 @@ export class Session { private readonly filesystem: SessionFileSystem; private readonly github: GitHubService; private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; - private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext; private readonly workspaceGitService: WorkspaceGitService; + private readonly workspaceAutoName: WorkspaceAutoName; private readonly gitMutation: GitMutationService; private readonly workspaceProvisioning: WorkspaceProvisioningService; private readonly daemonConfigStore: DaemonConfigStore; @@ -617,8 +611,8 @@ export class Session { checkoutDiffManager, github, renameCurrentBranch, - generateWorkspaceName, workspaceGitService, + workspaceAutoName, daemonConfigStore, stt, sttLanguage, @@ -675,13 +669,13 @@ export class Session { this.filesystem = filesystem ?? nodeSessionFileSystem; this.github = github ?? createGitHubService(); this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; - this.generateWorkspaceName = generateWorkspaceName ?? generateBranchNameFromFirstAgentContext; this.workspaceGitService = workspaceGitService; this.gitMutation = createGitMutationService({ workspaceGitService: this.workspaceGitService, github: this.github, logger: this.sessionLogger, }); + this.workspaceAutoName = workspaceAutoName; this.workspaceProvisioning = createWorkspaceProvisioningService({ workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, @@ -2459,11 +2453,14 @@ export class Session { createdAgentId = snapshot.id; await this.agentUpdates.forwardLiveAgent(snapshot); if (createdDirectoryWorkspaceForAgent && trimmedPrompt) { - await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({ - workspaceId, - cwd: createAgentConfig.cwd, - firstAgentContext, - }); + this.workspaceAutoName.scheduleForDirectory( + { + workspaceId, + cwd: createAgentConfig.cwd, + firstAgentContext, + }, + { currentSelection: this.getFocusedAgentSelectionForCwd(createAgentConfig.cwd) }, + ); } this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({ autoArchive, @@ -2844,140 +2841,6 @@ export class Session { ); } - private scheduleAutoNameWorkspaceBranchForFirstAgent(input: { - workspace: PersistedWorkspaceRecord; - firstAgentContext: FirstAgentContext; - }): void { - this.scheduleWorkspaceNaming(() => this.maybeAutoNameWorkspaceBranchForFirstAgent(input), { - cwd: input.workspace.cwd, - message: "Failed to auto-name worktree branch", - }); - } - - private async maybeAutoNameWorkspaceBranchForFirstAgent(input: { - workspace: PersistedWorkspaceRecord; - firstAgentContext: FirstAgentContext; - }): Promise { - // Capture the generated title from the generator callback so we can write - // title := generated title after the branch rename completes. - let generatedTitle: string | null = null; - const result = await attemptFirstAgentBranchAutoName({ - cwd: input.workspace.cwd, - firstAgentContext: input.firstAgentContext, - generateBranchNameFromContext: ({ cwd, firstAgentContext }) => { - return this.generateWorkspaceName({ - agentManager: this.agentManager, - cwd, - workspaceGitService: this.workspaceGitService, - providerSnapshotManager: this.providerSnapshotManager, - daemonConfig: this.readStructuredGenerationDaemonConfig(), - currentSelection: this.getFocusedAgentSelectionForCwd(cwd), - firstAgentContext, - logger: this.sessionLogger, - }).then((r) => { - generatedTitle = r?.title ?? null; - return r?.branch ?? null; - }); - }, - }); - if (!result.renamed || !generatedTitle) { - return; - } - - // K4: re-read from the registry before writing so any concurrent upsert - // that happened between workspace creation and this async path is not clobbered. - // The first-agent rename renamed the git branch too, so persist the new branch - // alongside the title — both are this path's own fields. - await this.applyGeneratedWorkspaceTitle(input.workspace.workspaceId, { - title: generatedTitle, - branch: result.branchName, - promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext), - }); - await this.gitMutation.notifyGitMutation(input.workspace.cwd, "rename-branch"); - await this.emitWorkspaceUpdateForCwd(input.workspace.cwd); - } - - // Generated names may replace the prompt title set at creation, but not a user - // rename that landed while the async generator was running. - private async applyGeneratedWorkspaceTitle( - workspaceId: string, - input: { title: string; branch?: string | null; promptTitle?: string | null }, - ): Promise { - const current = await this.workspaceRegistry.get(workspaceId); - if (!current) { - return; - } - let title = current.title; - if (!title || (input.promptTitle && title === input.promptTitle)) { - title = input.title; - } - await this.workspaceRegistry.upsert({ - ...current, - title, - ...(input.branch ? { branch: input.branch } : {}), - updatedAt: new Date().toISOString(), - }); - } - - // Wraps the injected workspace-name generator for a directory workspace. - private async generateWorkspaceTitleFromContext(input: { - cwd: string; - firstAgentContext: FirstAgentContext; - }): Promise { - return this.generateWorkspaceName({ - agentManager: this.agentManager, - cwd: input.cwd, - workspaceGitService: this.workspaceGitService, - providerSnapshotManager: this.providerSnapshotManager, - daemonConfig: this.readStructuredGenerationDaemonConfig(), - currentSelection: this.getFocusedAgentSelectionForCwd(input.cwd), - firstAgentContext: input.firstAgentContext, - logger: this.sessionLogger, - }); - } - - // Generates a human title for a directory workspace from the firstAgentContext - // prompt. No branch rename — directory workspaces have no worktree git state. - // TODO(K7): same-dir directory-workspace display disambiguation not yet implemented. - private async maybeAutoNameDirectoryWorkspaceTitle(input: { - workspaceId: string; - cwd: string; - firstAgentContext: FirstAgentContext; - }): Promise { - const generated = await this.generateWorkspaceTitleFromContext({ - cwd: input.cwd, - firstAgentContext: input.firstAgentContext, - }); - const title = generated?.title ?? null; - if (!title) { - return; - } - // K4: applyGeneratedWorkspaceTitle re-reads from the registry before writing. - // Directory workspaces have no branch — write only the title. - await this.applyGeneratedWorkspaceTitle(input.workspaceId, { - title, - promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext), - }); - await this.emitWorkspaceUpdateForWorkspaceId(input.workspaceId); - } - - private async scheduleAutoNameLocalWorkspaceTitleForFirstAgent(input: { - workspaceId: string; - cwd: string; - firstAgentContext: FirstAgentContext; - }): Promise { - const workspaceId = input.workspaceId; - this.scheduleWorkspaceNaming( - () => - this.maybeAutoNameDirectoryWorkspaceTitle({ - workspaceId, - cwd: input.cwd, - firstAgentContext: input.firstAgentContext, - }), - { cwd: input.cwd, message: "Failed to auto-name local workspace title" }, - ); - } - private isPathWithinRoot(rootPath: string, candidatePath: string): boolean { const resolvedRoot = resolve(rootPath); const resolvedCandidate = resolve(candidatePath); @@ -4567,31 +4430,17 @@ export class Session { }); if (request.firstAgentContext) { const firstAgentContext = request.firstAgentContext; - this.scheduleWorkspaceNaming( - () => - this.maybeAutoNameDirectoryWorkspaceTitle({ - workspaceId: workspace.workspaceId, - cwd: workspace.cwd, - firstAgentContext, - }), - { cwd: workspace.cwd, message: "Failed to auto-name directory workspace title" }, + this.workspaceAutoName.scheduleForDirectory( + { + workspaceId: workspace.workspaceId, + cwd: workspace.cwd, + firstAgentContext, + }, + { currentSelection: this.getFocusedAgentSelectionForCwd(workspace.cwd) }, ); } } - // Schedules a background workspace-naming write off the request path. The - // setTimeout(0) keeps the LLM call off the hot path. - private scheduleWorkspaceNaming( - run: () => Promise, - context: { cwd: string; message: string }, - ): void { - setTimeout(() => { - void run().catch((error) => { - this.sessionLogger.warn({ err: error, cwd: context.cwd }, context.message); - }); - }, 0); - } - private async handleWorkspaceCreateWorktree( request: Extract, ): Promise { @@ -4896,7 +4745,9 @@ export class Session { this.createPaseoWorktree(workflowInput, serviceOptions), warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace), autoNameWorkspaceBranchForFirstAgent: (autoNameInput) => - this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput), + this.workspaceAutoName.scheduleForWorktree(autoNameInput, { + currentSelection: this.getFocusedAgentSelectionForCwd(autoNameInput.workspace.cwd), + }), emitWorkspaceUpdateForWorkspaceId: (workspaceId) => this.emitWorkspaceUpdateForWorkspaceId(workspaceId), cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 8c911fcab..6d5bb4c14 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -13,6 +13,7 @@ import path from "node:path"; import { afterEach, expect, test, vi } from "vitest"; import { z } from "zod"; +import { createTestLogger } from "../test-utils/test-logger.js"; import { Session } from "./session.js"; import type { SessionOptions } from "./session.js"; import type { AgentUpdatesService } from "./session/agent-updates/agent-updates-service.js"; @@ -32,9 +33,15 @@ import type { AgentStreamEvent, } from "./agent/agent-sdk-types.js"; import { createWorktree, UnknownBranchError } from "../utils/worktree.js"; +import { + readPaseoWorktreeMetadata, + writePaseoWorktreeFirstAgentBranchAutoNameMetadata, + writePaseoWorktreeMetadata, +} from "../utils/worktree-metadata.js"; import { WorktreeRequestError, toWorktreeRequestError } from "./worktree-errors.js"; import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js"; +import { WorkspaceAutoName } from "./workspace-auto-name.js"; import type { GitHubService } from "../services/github-service.js"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; import { @@ -146,10 +153,6 @@ interface SessionTestAccess { clearWorkspaceArchiving(workspaceIds: Iterable): void; emitWorkspaceUpdateForCwd(...args: unknown[]): Promise; emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; - applyGeneratedWorkspaceTitle( - workspaceId: string, - input: { title: string; branch?: string | null; promptTitle?: string | null }, - ): Promise; emit(message: unknown): void; onMessage(message: unknown): void; paseoHome: string; @@ -545,6 +548,48 @@ function createSessionForWorkspaceTests( warn: vi.fn(), error: vi.fn(), }; + const agentManager = asAgentManager({ + subscribe: () => () => {}, + listAgents: () => [], + getAgent: () => null, + archiveAgent: async () => ({ archivedAt: new Date().toISOString() }), + archiveSnapshot: async () => ({}), + unarchiveSnapshot: async () => true, + clearAgentAttention: async () => {}, + notifyAgentState: () => {}, + }); + const workspaceRegistry = options.workspaceRegistry ?? { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [ + createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-running", + projectId: "proj-repo-running", + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ], + get: async (workspaceId: string) => + workspaceId === "ws-repo-running" + ? createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-running", + projectId: "proj-repo-running", + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }) + : null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + const workspaceGitService = options.workspaceGitService ?? createNoopWorkspaceGitService(); + const providerSnapshotManager = createProviderSnapshotManagerStub().manager; const session = asTestSession( new Session({ @@ -556,16 +601,7 @@ function createSessionForWorkspaceTests( pushTokenStore: asPushTokenStore(), paseoHome: options.paseoHome ?? "/tmp/paseo-test", worktreesRoot: options.worktreesRoot, - agentManager: asAgentManager({ - subscribe: () => () => {}, - listAgents: () => [], - getAgent: () => null, - archiveAgent: async () => ({ archivedAt: new Date().toISOString() }), - archiveSnapshot: async () => ({}), - unarchiveSnapshot: async () => true, - clearAgentAttention: async () => {}, - notifyAgentState: () => {}, - }), + agentManager, agentStorage: asAgentStorage({ list: async () => [ createPersistedWorkspaceRecord({ @@ -601,36 +637,7 @@ function createSessionForWorkspaceTests( archive: async () => {}, remove: async () => {}, }, - workspaceRegistry: options.workspaceRegistry ?? { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [ - createPersistedWorkspaceRecord({ - workspaceId: "ws-repo-running", - projectId: "proj-repo-running", - cwd: REPO_CWD, - kind: "directory", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ], - get: async (workspaceId: string) => - workspaceId === "ws-repo-running" - ? createPersistedWorkspaceRecord({ - workspaceId: "ws-repo-running", - projectId: "proj-repo-running", - cwd: REPO_CWD, - kind: "directory", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }) - : null, - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - }, + workspaceRegistry, filesystem: { isDirectory: async () => true }, chatService: asChatService(), scheduleService: asScheduleService(), @@ -651,9 +658,20 @@ function createSessionForWorkspaceTests( dispose: () => {}, }), github: options.github, - workspaceGitService: options.workspaceGitService ?? createNoopWorkspaceGitService(), + workspaceGitService, + workspaceAutoName: new WorkspaceAutoName({ + agentManager, + workspaceRegistry, + workspaceGitService, + providerSnapshotManager, + readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }), + gitMutation: { notifyGitMutation: async () => {} }, + emitWorkspaceUpdateForCwd: async () => {}, + emitWorkspaceUpdateForWorkspaceId: async () => {}, + logger: asSessionLogger(logger), + generateWorkspaceName: options.generateWorkspaceName, + }), renameCurrentBranch: options.renameCurrentBranch, - generateWorkspaceName: options.generateWorkspaceName, daemonConfigStore: asDaemonConfigStore({ get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), onChange: () => () => {}, @@ -661,7 +679,7 @@ function createSessionForWorkspaceTests( mcpBaseUrl: null, stt: null, tts: null, - providerSnapshotManager: createProviderSnapshotManagerStub().manager, + providerSnapshotManager, terminalManager: options.terminalManager ?? null, }), ); @@ -7269,57 +7287,62 @@ test("failed local create_agent_request does not schedule workspace title genera } }); -// K4: applyGeneratedWorkspaceTitle re-reads from the registry before writing so a -// concurrent upsert that happened between workspace creation and the async name -// write is not clobbered. -test("applyGeneratedWorkspaceTitle writes branch metadata and does not clobber concurrent title writes", async () => { - const session = createSessionForWorkspaceTests(); - - // The record at create-time: no title override. - const recordAtCreateTime = createPersistedWorkspaceRecord({ - workspaceId: "ws-worktree-1", - projectId: "proj-1", - cwd: `${REPO_CWD}/worktrees/task-branch`, - kind: "worktree", - displayName: "task-branch", +test("workspace auto-name keeps a manual title written before the scheduled title lands", async () => { + vi.useFakeTimers(); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-manual-title", + projectId: "proj-manual-title", + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + title: "Fix login bug", createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - - // Simulate a concurrent write that happened AFTER the workspace was created - // but BEFORE the async name generation completes — e.g. the user set a title. - const recordAfterConcurrentWrite = { - ...recordAtCreateTime, - title: "User-set title", - updatedAt: "2026-03-01T12:01:00.000Z", - }; - - const stored = new Map([[recordAfterConcurrentWrite.workspaceId, recordAfterConcurrentWrite]]); - session.workspaceRegistry.get = async (id: string) => stored.get(id) ?? null; - session.workspaceRegistry.upsert = async (record: unknown) => { - const parsed = record as typeof recordAtCreateTime; - stored.set(parsed.workspaceId, parsed); - }; - // Silence notification side-effects. - session.emitWorkspaceUpdateForCwd = async () => {}; - session.emitWorkspaceUpdatesForWorkspaceIds = async () => {}; - - await session.applyGeneratedWorkspaceTitle("ws-worktree-1", { - title: "Generated Task Title", - branch: "task-branch-renamed", + const stored = new Map([[workspace.workspaceId, workspace]]); + const emittedWorkspaceIds: string[] = []; + const workspaceAutoName = new WorkspaceAutoName({ + agentManager: asAgentManager({}), + workspaceRegistry: { + get: async (workspaceId) => stored.get(workspaceId) ?? null, + upsert: async (record) => { + stored.set(record.workspaceId, record); + }, + }, + workspaceGitService: createNoopWorkspaceGitService(), + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }), + gitMutation: { notifyGitMutation: async () => {} }, + emitWorkspaceUpdateForCwd: async () => {}, + emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { + emittedWorkspaceIds.push(workspaceId); + }, + logger: asSessionLogger(createTestLogger()), + generateWorkspaceName: async () => ({ title: "Generated login fix", branch: null }), }); - const saved = stored.get("ws-worktree-1"); - // The branch-shaped display name stays branch-shaped. - expect(saved?.displayName).toBe("task-branch"); - // The renamed branch is persisted into the dedicated branch field. - expect(saved?.branch).toBe("task-branch-renamed"); - // The concurrent user-set title is NOT clobbered. - expect(saved?.title).toBe("User-set title"); + try { + workspaceAutoName.scheduleForDirectory({ + workspaceId: workspace.workspaceId, + cwd: workspace.cwd, + firstAgentContext: { prompt: "Fix login bug" }, + }); + stored.set(workspace.workspaceId, { + ...workspace, + title: "User rename", + updatedAt: "2026-03-01T12:01:00.000Z", + }); + await vi.runAllTimersAsync(); + + expect(stored.get(workspace.workspaceId)?.title).toBe("User rename"); + expect(emittedWorkspaceIds).toEqual([workspace.workspaceId]); + } finally { + vi.useRealTimers(); + } }); -test("applyGeneratedWorkspaceTitle replaces the unchanged prompt title", async () => { - const session = createSessionForWorkspaceTests(); +test("workspace auto-name replaces the unchanged prompt title", async () => { + vi.useFakeTimers(); const workspace = createPersistedWorkspaceRecord({ workspaceId: "ws-prompt-title", projectId: "proj-prompt-title", @@ -7331,31 +7354,131 @@ test("applyGeneratedWorkspaceTitle replaces the unchanged prompt title", async ( updatedAt: "2026-03-01T12:00:00.000Z", }); const stored = new Map([[workspace.workspaceId, workspace]]); - session.workspaceRegistry.get = async (id: string) => stored.get(id) ?? null; - session.workspaceRegistry.upsert = async (record: unknown) => { - const parsed = record as typeof workspace; - stored.set(parsed.workspaceId, parsed); - }; - - await session.applyGeneratedWorkspaceTitle(workspace.workspaceId, { - title: "Generated login fix", - promptTitle: "Fix login bug", + const workspaceAutoName = new WorkspaceAutoName({ + agentManager: asAgentManager({}), + workspaceRegistry: { + get: async (workspaceId) => stored.get(workspaceId) ?? null, + upsert: async (record) => { + stored.set(record.workspaceId, record); + }, + }, + workspaceGitService: createNoopWorkspaceGitService(), + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }), + gitMutation: { notifyGitMutation: async () => {} }, + emitWorkspaceUpdateForCwd: async () => {}, + emitWorkspaceUpdateForWorkspaceId: async () => {}, + logger: asSessionLogger(createTestLogger()), + generateWorkspaceName: async () => ({ title: "Generated login fix", branch: null }), }); - expect(stored.get(workspace.workspaceId)?.title).toBe("Generated login fix"); + try { + workspaceAutoName.scheduleForDirectory({ + workspaceId: workspace.workspaceId, + cwd: workspace.cwd, + firstAgentContext: { prompt: "Fix login bug" }, + }); + await vi.runAllTimersAsync(); - stored.set(workspace.workspaceId, { - ...workspace, - title: "User rename", - updatedAt: "2026-03-01T12:01:00.000Z", + expect(stored.get(workspace.workspaceId)?.title).toBe("Generated login fix"); + } finally { + vi.useRealTimers(); + } +}); + +test("workspace auto-name applies title once when branch auto-name is rejected", async () => { + vi.useFakeTimers(); + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-auto-name-rejected-"))); + const repoDir = path.join(tempDir, "repo"); + mkdirSync(repoDir); + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-M", "placeholder-branch"], { cwd: repoDir, stdio: "pipe" }); + writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" }); + writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, { + placeholderBranchName: "placeholder-branch", }); - await session.applyGeneratedWorkspaceTitle(workspace.workspaceId, { - title: "Generated login fix", - promptTitle: "Fix login bug", + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-rejected-branch-title", + projectId: "proj-rejected-branch-title", + cwd: repoDir, + kind: "worktree", + displayName: "Fix checkout title", + title: "Fix checkout title", + branch: "placeholder-branch", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const stored = new Map([[workspace.workspaceId, workspace]]); + let generateCalls = 0; + const gitMutations: string[] = []; + const emittedCwds: string[] = []; + const workspaceAutoName = new WorkspaceAutoName({ + agentManager: asAgentManager({}), + workspaceRegistry: { + get: async (workspaceId) => stored.get(workspaceId) ?? null, + upsert: async (record) => { + stored.set(record.workspaceId, record); + }, + }, + workspaceGitService: createNoopWorkspaceGitService(), + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }), + gitMutation: { + notifyGitMutation: async (_cwd, reason) => { + gitMutations.push(reason); + }, + }, + emitWorkspaceUpdateForCwd: async (cwd) => { + emittedCwds.push(cwd); + }, + emitWorkspaceUpdateForWorkspaceId: async () => {}, + logger: asSessionLogger(createTestLogger()), + generateWorkspaceName: async () => { + generateCalls += 1; + return { title: "Generated Invalid Branch Title", branch: "Invalid Branch Name" }; + }, }); - expect(stored.get(workspace.workspaceId)?.title).toBe("User rename"); + try { + workspaceAutoName.scheduleForWorktree({ + workspace, + firstAgentContext: { prompt: "Fix checkout title" }, + }); + await vi.runAllTimersAsync(); + + expect(generateCalls).toBe(1); + expect(stored.get(workspace.workspaceId)).toMatchObject({ + title: "Generated Invalid Branch Title", + branch: "placeholder-branch", + }); + expect( + execFileSync("git", ["branch", "--show-current"], { cwd: repoDir, stdio: "pipe" }) + .toString() + .trim(), + ).toBe("placeholder-branch"); + expect(readPaseoWorktreeMetadata(repoDir)).toMatchObject({ + version: 2, + firstAgentBranchAutoName: { + status: "attempted", + placeholderBranchName: "placeholder-branch", + }, + }); + expect(gitMutations).toEqual([]); + expect(emittedCwds).toEqual([repoDir]); + } finally { + vi.useRealTimers(); + rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } }); // Phase 7: branch is a git fact derived per-descriptor from each workspace's own diff --git a/packages/server/src/server/websocket-server.browser-tools.test.ts b/packages/server/src/server/websocket-server.browser-tools.test.ts index dd4b1d1e3..c2cb630d9 100644 --- a/packages/server/src/server/websocket-server.browser-tools.test.ts +++ b/packages/server/src/server/websocket-server.browser-tools.test.ts @@ -25,6 +25,7 @@ import { createStub } from "./test-utils/class-mocks.js"; import { DaemonClient } from "./test-utils/daemon-client.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; +import type { WorkspaceAutoName } from "./workspace-auto-name.js"; interface BrowserToolsDaemonHarness { broker: BrowserToolsBroker; @@ -70,6 +71,13 @@ function browserHostCapabilities( }; } +function createWorkspaceAutoNameStub(): WorkspaceAutoName { + return createStub({ + scheduleForWorktree: () => {}, + scheduleForDirectory: () => {}, + }); +} + describe("WebSocketServer browser tools wiring", () => { it("registers capable clients and dispatches broker requests over the real WebSocket path", async () => { const harness = await startBrowserToolsDaemonHarness(); @@ -292,6 +300,7 @@ function createVoiceAssistantWebSocketServer(params: { createStub(daemonConfigStore), null, { allowedOrigins: new Set(["*"]) }, + createWorkspaceAutoNameStub(), undefined, undefined, undefined, diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 7f3d490d9..e0eba9362 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -12,6 +12,7 @@ import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import type { PushNotificationSender, PushPayload } from "./push/notifications.js"; +import type { WorkspaceAutoName } from "./workspace-auto-name.js"; const wsModuleMock = vi.hoisted(() => { class MockWebSocketServer { @@ -65,6 +66,13 @@ function createLogger() { return logger; } +function createWorkspaceAutoNameStub(): WorkspaceAutoName { + return createStub({ + scheduleForWorktree: () => {}, + scheduleForDirectory: () => {}, + }); +} + class RecordingPushNotificationSender implements PushNotificationSender { readonly sent: PushPayload[] = []; @@ -106,6 +114,7 @@ function createServer(agentManagerOverrides?: Record) { createStub(daemonConfigStore), null, { allowedOrigins: new Set() }, + createWorkspaceAutoNameStub(), undefined, undefined, undefined, diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 22e7787bf..64e2918fe 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -9,6 +9,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js"; import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; +import type { WorkspaceAutoName } from "./workspace-auto-name.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { @@ -215,6 +216,13 @@ function createLogger() { return logger; } +function createWorkspaceAutoNameStub(): WorkspaceAutoName { + return createStub({ + scheduleForWorktree: () => {}, + scheduleForDirectory: () => {}, + }); +} + function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | null; logger?: ReturnType; @@ -246,6 +254,7 @@ function createServer(options?: { createStub(daemonConfigStore), null, { allowedOrigins: new Set() }, + createWorkspaceAutoNameStub(), undefined, speechReadiness ? { diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 9056d06e6..3862f5b4f 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -18,6 +18,7 @@ import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-re import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import type { PushNotificationSender, PushPayload } from "./push/notifications.js"; +import type { WorkspaceAutoName } from "./workspace-auto-name.js"; const wsModuleMock = vi.hoisted(() => { class MockWebSocketServer { @@ -68,6 +69,13 @@ function createLogger() { return logger; } +function createWorkspaceAutoNameStub(): WorkspaceAutoName { + return createStub({ + scheduleForWorktree: () => {}, + scheduleForDirectory: () => {}, + }); +} + function createTerminalManager() { let listener: TerminalActivityListener | null = null; @@ -139,6 +147,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work createStub(daemonConfigStore), null, { allowedOrigins: new Set() }, + createWorkspaceAutoNameStub(), undefined, undefined, terminalManager, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index cb6f0f1ff..3c2143b9b 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -34,6 +34,7 @@ import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from import type { AgentProvider } from "./agent/agent-sdk-types.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import type { WorkspaceAutoName } from "./workspace-auto-name.js"; import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js"; import { PushTokenStore } from "./push/token-store.js"; import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js"; @@ -424,6 +425,7 @@ export class VoiceAssistantWebSocketServer { private readonly checkoutDiffManager: CheckoutDiffManager; private readonly github: GitHubService; private readonly workspaceGitService: WorkspaceGitService; + private readonly workspaceAutoName: WorkspaceAutoName; private readonly downloadTokenStore: DownloadTokenStore; private readonly paseoHome: string; private readonly worktreesRoot: string | undefined; @@ -473,6 +475,7 @@ export class VoiceAssistantWebSocketServer { daemonConfigStore: DaemonConfigStore, mcpBaseUrl: string | null, wsConfig: WebSocketServerConfig, + workspaceAutoName: WorkspaceAutoName, auth?: DaemonAuthConfig, speech?: SpeechService | null, terminalManager?: TerminalManager | null, @@ -540,6 +543,7 @@ export class VoiceAssistantWebSocketServer { this.checkoutDiffManager = requiredServices.checkoutDiffManager; this.github = github ?? createGitHubService(); this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService(); + this.workspaceAutoName = workspaceAutoName; this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; @@ -1019,6 +1023,7 @@ export class VoiceAssistantWebSocketServer { checkoutDiffManager: this.checkoutDiffManager, github: this.github, workspaceGitService: this.workspaceGitService, + workspaceAutoName: this.workspaceAutoName, daemonConfigStore: this.daemonConfigStore, mcpBaseUrl: this.mcpBaseUrl, stt: () => this.speech?.resolveStt() ?? null, diff --git a/packages/server/src/server/workspace-auto-name.ts b/packages/server/src/server/workspace-auto-name.ts new file mode 100644 index 000000000..2bf573620 --- /dev/null +++ b/packages/server/src/server/workspace-auto-name.ts @@ -0,0 +1,222 @@ +import type pino from "pino"; +import type { FirstAgentContext } from "@getpaseo/protocol/messages"; + +import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js"; +import type { AgentManager } from "./agent/agent-manager.js"; +import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js"; +import { + attemptFirstAgentBranchAutoName, + type AttemptFirstAgentBranchAutoNameResult, +} from "./paseo-worktree-service.js"; +import type { GitMutationService } from "./session/git-mutation/git-mutation-service.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; +import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js"; +import { + generateBranchNameFromFirstAgentContext, + type GeneratedWorkspaceName, + type GenerateBranchNameFromFirstAgentContextOptions, +} from "./worktree-branch-name-generator.js"; + +type WorkspaceNameGenerator = typeof generateBranchNameFromFirstAgentContext; + +type CurrentSelection = GenerateBranchNameFromFirstAgentContextOptions["currentSelection"] | null; + +interface WorkspaceAutoNameOptions { + agentManager: AgentManager; + workspaceRegistry: Pick; + workspaceGitService: WorkspaceGitService; + providerSnapshotManager: ProviderSnapshotManager; + readDaemonConfig: () => StructuredGenerationDaemonConfig; + gitMutation: Pick; + emitWorkspaceUpdateForCwd: (cwd: string) => Promise; + emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise; + logger: pino.Logger; + generateWorkspaceName?: WorkspaceNameGenerator; +} + +interface ScheduleContext { + currentSelection?: CurrentSelection; +} + +export class WorkspaceAutoName { + private readonly agentManager: AgentManager; + private readonly workspaceRegistry: Pick; + private readonly workspaceGitService: WorkspaceGitService; + private readonly providerSnapshotManager: ProviderSnapshotManager; + private readonly readDaemonConfig: () => StructuredGenerationDaemonConfig; + private readonly gitMutation: Pick; + private readonly emitWorkspaceUpdateForCwd: (cwd: string) => Promise; + private readonly emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise; + private readonly logger: pino.Logger; + private readonly generateWorkspaceName: WorkspaceNameGenerator; + + constructor(options: WorkspaceAutoNameOptions) { + this.agentManager = options.agentManager; + this.workspaceRegistry = options.workspaceRegistry; + this.workspaceGitService = options.workspaceGitService; + this.providerSnapshotManager = options.providerSnapshotManager; + this.readDaemonConfig = options.readDaemonConfig; + this.gitMutation = options.gitMutation; + this.emitWorkspaceUpdateForCwd = options.emitWorkspaceUpdateForCwd; + this.emitWorkspaceUpdateForWorkspaceId = options.emitWorkspaceUpdateForWorkspaceId; + this.logger = options.logger; + this.generateWorkspaceName = + options.generateWorkspaceName ?? generateBranchNameFromFirstAgentContext; + } + + scheduleForWorktree( + input: { + workspace: PersistedWorkspaceRecord; + firstAgentContext: FirstAgentContext; + }, + context: ScheduleContext = {}, + ): void { + this.schedule( + () => + this.maybeAutoNameWorkspaceBranchForFirstAgent({ + ...input, + currentSelection: context.currentSelection ?? null, + }), + { + cwd: input.workspace.cwd, + message: "Failed to auto-name worktree branch", + }, + ); + } + + scheduleForDirectory( + input: { + workspaceId: string; + cwd: string; + firstAgentContext: FirstAgentContext; + }, + context: ScheduleContext = {}, + ): void { + this.schedule( + () => + this.maybeAutoNameDirectoryWorkspaceTitle({ + ...input, + currentSelection: context.currentSelection ?? null, + }), + { cwd: input.cwd, message: "Failed to auto-name directory workspace title" }, + ); + } + + private async maybeAutoNameWorkspaceBranchForFirstAgent(input: { + workspace: PersistedWorkspaceRecord; + firstAgentContext: FirstAgentContext; + currentSelection: CurrentSelection; + }): Promise { + let generated: GeneratedWorkspaceName | null = null; + const result: AttemptFirstAgentBranchAutoNameResult = await attemptFirstAgentBranchAutoName({ + cwd: input.workspace.cwd, + firstAgentContext: input.firstAgentContext, + generateBranchNameFromContext: ({ cwd, firstAgentContext }) => { + return this.generateFromContext({ + cwd, + firstAgentContext, + currentSelection: input.currentSelection, + }).then((nextGenerated) => { + generated = nextGenerated; + return nextGenerated?.branch ?? null; + }); + }, + }); + + if (!generated) { + generated = await this.generateFromContext({ + cwd: input.workspace.cwd, + firstAgentContext: input.firstAgentContext, + currentSelection: input.currentSelection, + }); + } + const generatedTitle = generated?.title ?? null; + if (!generatedTitle) { + return; + } + + // K4: re-read from the registry before writing so any concurrent upsert + // that happened between workspace creation and this async path is not clobbered. + // When the first-agent rename changed the git branch too, persist that branch + // alongside the title — both are this path's own fields. + await this.applyGeneratedWorkspaceTitle(input.workspace.workspaceId, { + title: generatedTitle, + ...(result.renamed ? { branch: result.branchName } : {}), + promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext), + }); + if (result.renamed) { + await this.gitMutation.notifyGitMutation(input.workspace.cwd, "rename-branch"); + } + await this.emitWorkspaceUpdateForCwd(input.workspace.cwd); + } + + private async maybeAutoNameDirectoryWorkspaceTitle(input: { + workspaceId: string; + cwd: string; + firstAgentContext: FirstAgentContext; + currentSelection: CurrentSelection; + }): Promise { + const generated = await this.generateFromContext({ + cwd: input.cwd, + firstAgentContext: input.firstAgentContext, + currentSelection: input.currentSelection, + }); + const title = generated?.title ?? null; + if (!title) { + return; + } + // K4: applyGeneratedWorkspaceTitle re-reads from the registry before writing. + // Directory workspaces have no branch — write only the title. + await this.applyGeneratedWorkspaceTitle(input.workspaceId, { + title, + promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext), + }); + await this.emitWorkspaceUpdateForWorkspaceId(input.workspaceId); + } + + private async applyGeneratedWorkspaceTitle( + workspaceId: string, + input: { title: string; branch?: string | null; promptTitle?: string | null }, + ): Promise { + const current = await this.workspaceRegistry.get(workspaceId); + if (!current) { + return; + } + let title = current.title; + if (!title || (input.promptTitle && title === input.promptTitle)) { + title = input.title; + } + await this.workspaceRegistry.upsert({ + ...current, + title, + ...(input.branch ? { branch: input.branch } : {}), + updatedAt: new Date().toISOString(), + }); + } + + private generateFromContext(input: { + cwd: string; + firstAgentContext: FirstAgentContext; + currentSelection: CurrentSelection; + }): Promise { + return this.generateWorkspaceName({ + agentManager: this.agentManager, + cwd: input.cwd, + workspaceGitService: this.workspaceGitService, + providerSnapshotManager: this.providerSnapshotManager, + daemonConfig: this.readDaemonConfig(), + currentSelection: input.currentSelection ?? undefined, + firstAgentContext: input.firstAgentContext, + logger: this.logger, + }); + } + + private schedule(run: () => Promise, context: { cwd: string; message: string }): void { + setTimeout(() => { + void run().catch((error) => { + this.logger.warn({ err: error, cwd: context.cwd }, context.message); + }); + }, 0); + } +} diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index c661c017e..970771823 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -99,6 +99,7 @@ function createWorkflowForRequestTest(options: { paseoHome: options.paseoHome, createPaseoWorktree, warmWorkspaceGitData: options.warmWorkspaceGitData ?? (async () => {}), + autoNameWorkspaceBranchForFirstAgent: () => {}, emitWorkspaceUpdateForWorkspaceId: async () => {}, cacheWorkspaceSetupSnapshot: () => {}, emit: () => {}, @@ -382,6 +383,7 @@ describe("create-agent worktree setup boundary", () => { paseoHome, createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), warmWorkspaceGitData: async () => {}, + autoNameWorkspaceBranchForFirstAgent: () => {}, emitWorkspaceUpdateForWorkspaceId: async () => {}, cacheWorkspaceSetupSnapshot: () => {}, emit: (message) => workspaceSetupEvents.push(message), diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 758da42f0..fc1a60823 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -119,7 +119,7 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB }, ) => Promise; warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise; - autoNameWorkspaceBranchForFirstAgent?: (input: { + autoNameWorkspaceBranchForFirstAgent: (input: { workspace: PersistedWorkspaceRecord; firstAgentContext: FirstAgentContext; }) => void; @@ -596,7 +596,7 @@ export async function createPaseoWorktreeWorkflow( setTimeout(() => { if (input.firstAgentContext) { - dependencies.autoNameWorkspaceBranchForFirstAgent?.({ + dependencies.autoNameWorkspaceBranchForFirstAgent({ workspace, firstAgentContext: input.firstAgentContext, });