diff --git a/docs/data-model.md b/docs/data-model.md index 048e51160..ea7916074 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -392,7 +392,9 @@ Array of workspace records. A workspace is a specific working directory within a | `projectId` | `string` | FK to Project.projectId | | `cwd` | `string` | Filesystem path | | `kind` | `"local_checkout" \| "worktree" \| "directory"` | | -| `displayName` | `string` | | +| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. | +| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | +| `branch` | `string \| null` | The worktree's git branch. Separate from `displayName`/`title`; only worktree workspaces set it. A branch rename writes this and never the name. | | `createdAt` | `string` (ISO 8601) | | | `updatedAt` | `string` (ISO 8601) | | | `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index be77997fe..c171200ef 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -25,11 +25,18 @@ export interface SeedDaemonClient { error: string | null; }>; createWorkspace(input: { - backing: "local" | "worktree"; - cwd?: string; - projectId?: string; - branch?: string; - baseBranch?: string; + source: + | { kind: "directory"; path: string; projectId?: string } + | { + kind: "worktree"; + cwd?: string; + projectId?: string; + action?: "branch-off" | "checkout"; + refName?: string; + baseBranch?: string; + githubPrNumber?: number; + worktreeSlug?: string; + }; title?: string; }): Promise<{ workspace: { id: string; name: string } | null; @@ -64,6 +71,7 @@ export interface SeedDaemonClient { createAgent(options: { provider: string; cwd: string; + workspaceId?: string; title?: string; modeId?: string; model?: string; diff --git a/packages/app/e2e/same-directory-workspaces.spec.ts b/packages/app/e2e/same-directory-workspaces.spec.ts index 3946d2f90..967d274ef 100644 --- a/packages/app/e2e/same-directory-workspaces.spec.ts +++ b/packages/app/e2e/same-directory-workspaces.spec.ts @@ -28,9 +28,7 @@ async function createSecondWorkspaceOnSameDir( title: string, ): Promise { const created = await seeded.client.createWorkspace({ - backing: "local", - cwd: seeded.repoPath, - projectId: seeded.projectId, + source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId }, title, }); if (!created.workspace) { diff --git a/packages/app/e2e/sidebar-model-b.spec.ts b/packages/app/e2e/sidebar-model-b.spec.ts index 327952c7e..0b6bd0c1e 100644 --- a/packages/app/e2e/sidebar-model-b.spec.ts +++ b/packages/app/e2e/sidebar-model-b.spec.ts @@ -26,9 +26,7 @@ function projectNewWorktreeIcon(page: Page, projectKey: string) { async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise { const created = await seeded.client.createWorkspace({ - backing: "local", - cwd: seeded.repoPath, - projectId: seeded.projectId, + source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId }, title, }); if (!created.workspace) { diff --git a/packages/app/e2e/workspace-model-regressions.spec.ts b/packages/app/e2e/workspace-model-regressions.spec.ts new file mode 100644 index 000000000..0fe1dca6b --- /dev/null +++ b/packages/app/e2e/workspace-model-regressions.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from "./fixtures"; +import { + expectComposerEditable, + expectComposerVisible, + fillComposerDraft, +} from "./helpers/composer"; +import { clickNewChat, gotoWorkspace } from "./helpers/launcher"; +import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; + +type NewWorkspaceDaemonClient = Awaited>; + +test.describe("Workspace model regressions", () => { + let client: NewWorkspaceDaemonClient; + + test.describe.configure({ timeout: 240_000 }); + + test.beforeEach(async () => { + client = await connectNewWorkspaceDaemonClient(); + }); + + test.afterEach(async () => { + await client?.close().catch(() => undefined); + }); + + test("same-directory workspace does not inherit legacy cwd-only agent tabs", async ({ page }) => { + const seeded = await seedWorkspace({ repoPrefix: "workspace-legacy-agents-" }); + + try { + const legacyAgent = await seeded.client.createAgent({ + provider: "mock", + cwd: seeded.repoPath, + title: "Legacy cwd-only agent", + modeId: "load-test", + model: "ten-second-stream", + }); + const secondWorkspace = await seeded.client.createWorkspace({ + source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId }, + title: "Fresh workspace", + }); + if (!secondWorkspace.workspace) { + throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace"); + } + + await gotoWorkspace(page, secondWorkspace.workspace.id); + + await expect + .poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 }) + .toEqual([]); + + await gotoWorkspace(page, seeded.workspaceId); + await expect + .poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 }) + .toContain(`workspace-tab-agent_${legacyAgent.id}`); + } finally { + await seeded.cleanup(); + } + }); + + test("new agent tab in a same-directory workspace picks a default model for the saved provider", async ({ + page, + }) => { + const seeded: SeededWorkspace = await seedWorkspace({ + repoPrefix: "workspace-new-agent-model-", + }); + + try { + const secondWorkspace = await seeded.client.createWorkspace({ + source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId }, + title: "Fresh workspace", + }); + if (!secondWorkspace.workspace) { + throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace"); + } + + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:create-agent-preferences", + JSON.stringify({ + provider: "codex", + providerPreferences: { + codex: { mode: "full-access" }, + }, + }), + ); + }); + await gotoWorkspace(page, secondWorkspace.workspace.id); + await clickNewChat(page); + + await expectComposerVisible(page); + await expectComposerEditable(page); + await fillComposerDraft(page, "d"); + await expect(page.getByText("No model is available for the selected provider")).toHaveCount( + 0, + ); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 5fab3dede..db103cc4b 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -791,11 +791,19 @@ async function createMultiplicityWorkspace(input: { ? (resolveCheckoutRequest(input.selectedItem, input.currentBranch)?.refName ?? undefined) : undefined; const payload = await input.client.createWorkspace({ - backing: input.backing, - cwd: input.project.iconWorkingDir, - projectId: input.project.projectKey, - ...(isWorktree ? { branch: createNameId() } : {}), - ...(baseBranch ? { baseBranch } : {}), + source: isWorktree + ? { + kind: "worktree", + cwd: input.project.iconWorkingDir, + projectId: input.project.projectKey, + worktreeSlug: createNameId(), + ...(baseBranch ? { baseBranch } : {}), + } + : { + kind: "directory", + path: input.project.iconWorkingDir, + projectId: input.project.projectKey, + }, }); if (payload.error || !payload.workspace) { throw new Error(payload.error ?? input.createFailedMessage); diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index b127e64dc..60f30abc3 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -436,14 +436,18 @@ async function resolveRunWorkspace( return { id: explicit, cwd }; } + // TODO: thread the run `prompt` as firstAgentContext so workspace-level + // title/branch generation picks up the task description (U8/U6 deferred). const result = options.worktree ? await client.createWorkspace({ - backing: "worktree", - cwd, - branch: options.worktree, - baseBranch: options.base, + source: { + kind: "worktree", + cwd, + worktreeSlug: options.worktree, + baseBranch: options.base, + }, }) - : await client.createWorkspace({ backing: "local", cwd }); + : await client.createWorkspace({ source: { kind: "directory", path: cwd } }); if (!result.workspace) { throw { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index aec8fbc50..26ce138a7 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -79,6 +79,7 @@ import type { SendAgentMessageRequest, PaseoConfigRaw, PaseoConfigRevision, + WorkspaceCreateRequest, } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest, @@ -3257,12 +3258,9 @@ export class DaemonClient { async createWorkspace( input: { - backing: "local" | "worktree"; - cwd?: string; - projectId?: string; - branch?: string; - baseBranch?: string; + source: WorkspaceCreateRequest["source"]; title?: string; + firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"]; }, requestId?: string, ): Promise { @@ -3270,12 +3268,11 @@ export class DaemonClient { requestId, message: { type: "workspace.create.request", - backing: input.backing, - ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), - ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), - ...(input.branch !== undefined ? { branch: input.branch } : {}), - ...(input.baseBranch !== undefined ? { baseBranch: input.baseBranch } : {}), + source: input.source, ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.firstAgentContext !== undefined + ? { firstAgentContext: input.firstAgentContext } + : {}), }, responseType: "workspace.create.response", timeout: 60000, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 4ff30b344..402dc5c97 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1676,23 +1676,35 @@ export const ArchiveWorkspaceRequestSchema = z.object({ }); // Create a new workspace record. Unlike open_project, this never deduplicates by -// directory: it always produces a fresh workspace. The backing directory is a -// choice — an existing local checkout/directory, or a newly created worktree. +// directory: it always produces a fresh workspace. The source discriminates +// between an existing local directory and a newly created paseo worktree. export const WorkspaceCreateRequestSchema = z.object({ type: z.literal("workspace.create.request"), - backing: z.enum(["local", "worktree"]), - // local: path of the existing checkout/directory to back the workspace. - cwd: z.string().optional(), - // worktree: the project whose repo the worktree is cut from. local may also - // pass projectId, but the directory governs placement there. - projectId: z.string().optional(), - // worktree only: branch is the new/checked-out branch (slug); baseBranch is - // the branch to cut from. - branch: z.string().optional(), - baseBranch: z.string().optional(), + requestId: z.string(), // Optional user-set title applied to the created workspace. title: z.string().optional(), - requestId: z.string(), + // Optional prompt context for workspace-level name/branch generation. + firstAgentContext: FirstAgentContextSchema.optional(), + source: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("directory"), + // Path of the existing checkout/directory to back the workspace. + path: z.string(), + projectId: z.string().optional(), + }), + z.object({ + kind: z.literal("worktree"), + // The project whose repo the worktree is cut from. + cwd: z.string().optional(), + projectId: z.string().optional(), + action: z.enum(["branch-off", "checkout"]).optional(), + // Target branch name for checkout, or new branch name for branch-off. + refName: z.string().min(1).optional(), + baseBranch: z.string().optional(), + githubPrNumber: z.number().int().positive().optional(), + worktreeSlug: z.string().optional(), + }), + ]), }); export const WorkspaceClearAttentionRequestSchema = z.object({ diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index c126f37ad..43a4ee69f 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -4,6 +4,7 @@ import { RecentProviderSessionDescriptorPayloadSchema, SessionInboundMessageSchema, SessionOutboundMessageSchema, + WorkspaceCreateRequestSchema, WorkspaceDescriptorPayloadSchema, WorkspaceScriptPayloadSchema, } from "./messages.js"; @@ -866,4 +867,42 @@ describe("workspace message schemas", () => { const checkout = result.data.payload.entries[0]?.project.checkout; expect(checkout?.worktreeRoot).toBe("C:\\repo"); }); + + test("workspace.create.request rejects old flat backing shape and accepts new source envelope", () => { + // Old flat shape with backing enum must be rejected. + const oldFlat = WorkspaceCreateRequestSchema.safeParse({ + type: "workspace.create.request", + requestId: "req-old", + backing: "worktree", + cwd: "/tmp/repo", + branch: "feat/my-feature", + }); + expect(oldFlat.success).toBe(false); + + // New envelope shape with source discriminated union must be accepted. + const newWorktree = WorkspaceCreateRequestSchema.parse({ + type: "workspace.create.request", + requestId: "req-worktree", + source: { + kind: "worktree", + cwd: "/tmp/repo", + action: "checkout", + refName: "feat/my-feature", + }, + }); + expect(newWorktree.type).toBe("workspace.create.request"); + expect(newWorktree.source.kind).toBe("worktree"); + + // Directory source must also be accepted. + const newDirectory = WorkspaceCreateRequestSchema.parse({ + type: "workspace.create.request", + requestId: "req-dir", + source: { + kind: "directory", + path: "/tmp/repo", + }, + }); + expect(newDirectory.type).toBe("workspace.create.request"); + expect(newDirectory.source.kind).toBe("directory"); + }); }); diff --git a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts index 7e8eaddf6..2f42d69dd 100644 --- a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts +++ b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts @@ -21,7 +21,7 @@ async function workspaceIds(client: DaemonClient): Promise> { } async function mintLocalWorkspace(client: DaemonClient, cwd: string): Promise { - const result = await client.createWorkspace({ backing: "local", cwd }); + const result = await client.createWorkspace({ source: { kind: "directory", path: cwd } }); if (!result.workspace) { throw new Error(result.error ?? "Failed to create workspace"); } diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 73d3cdfb5..59ce61c48 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -240,6 +240,7 @@ async function upsertWorkspaceForWorktree(options: { cwd: normalizedCwd, kind: "worktree", displayName: options.worktree.branchName || normalizedCwd, + branch: options.worktree.branchName || null, createdAt: now, updatedAt: now, archivedAt: null, @@ -274,12 +275,19 @@ export async function createLocalCheckoutWorkspace( await deps.projectRegistry.upsert(projectRecord); const trimmedTitle = options.title?.trim(); + // Persist the live git branch into the dedicated `branch` field so + // buildWorkspaceCheckout reports the real branch for directory/local_checkout + // workspaces too (it reads workspace.branch). Same source deriveWorkspaceDisplayName + // reads. HEAD/detached resolves to null — there is no branch to report. + const currentBranch = checkout.currentBranch?.trim() ?? null; + const branch = currentBranch && currentBranch.toUpperCase() !== "HEAD" ? currentBranch : null; const workspace = createPersistedWorkspaceRecord({ workspaceId: generateWorkspaceId(), projectId: projectRecord.projectId, cwd: normalizedCwd, kind: membership.workspaceKind, displayName: membership.workspaceDisplayName, + branch, title: trimmedTitle ? trimmedTitle : null, createdAt: now, updatedAt: now, diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 2ae3b6af9..9e44adeff 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -2717,6 +2717,7 @@ describe("session workspace descriptors", () => { cwd: "/repo/app", kind: "local_checkout" as const, displayName: "app", + branch: "app", archivedAt: null, }; const project = { @@ -2787,6 +2788,7 @@ describe("session workspace descriptors", () => { cwd: "/repo/local", kind: "local_checkout" as const, displayName: "local", + branch: "local", archivedAt: null, }; const project = { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 7b1703f5a..32060de35 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -207,7 +207,7 @@ import { pullCurrentBranch, pushCurrentBranch, createPullRequest, - renameCurrentBranch, + renameCurrentBranch as renameCurrentBranchDefault, } from "../utils/checkout-git.js"; import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; import { getProjectIcon } from "../utils/project-icon.js"; @@ -252,7 +252,10 @@ import { type CreatePaseoWorktreeInput, type CreatePaseoWorktreeResult, } from "./paseo-worktree-service.js"; -import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js"; +import { + generateBranchNameFromFirstAgentContext, + type GeneratedWorkspaceName, +} from "./worktree-branch-name-generator.js"; import { assertSafeGitRef as assertWorktreeSafeGitRef, buildAgentSessionConfig as buildWorktreeAgentSessionConfig, @@ -375,6 +378,11 @@ function diffChangeTypeFor(file: { isNew?: boolean; isDeleted?: boolean }): "A" function buildWorkspaceCheckout( workspace: PersistedWorkspaceRecord, project: PersistedProjectRecord, + // The persisted `branch` field is the source of truth, but it is null for + // records created before branch was lifted to its own field (no migrations, + // per data-model.md) and for any path that didn't backfill it. Fall back to + // the live git branch so checkout.currentBranch never regresses to null. + fallbackBranch?: string | null, ): ProjectPlacementPayload["checkout"] { if (project.kind !== "git") { return { @@ -387,11 +395,12 @@ function buildWorkspaceCheckout( mainRepoRoot: null, }; } + const currentBranch = workspace.branch ?? fallbackBranch ?? null; if (workspace.kind === "worktree") { return { cwd: workspace.cwd, isGit: true, - currentBranch: workspace.displayName, + currentBranch, remoteUrl: null, worktreeRoot: workspace.cwd, isPaseoOwnedWorktree: true, @@ -401,7 +410,7 @@ function buildWorkspaceCheckout( return { cwd: workspace.cwd, isGit: true, - currentBranch: workspace.displayName, + currentBranch, remoteUrl: null, worktreeRoot: workspace.cwd, isPaseoOwnedWorktree: false, @@ -597,6 +606,12 @@ export interface SessionOptions { checkoutDiffManager: CheckoutDiffManager; github?: GitHubService; createAgentMcpTransport?: AgentMcpTransportFactory; + // 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; daemonConfigStore: DaemonConfigStore; mcpBaseUrl?: string | null; @@ -818,6 +833,11 @@ export class Session { private readonly loopService: LoopService; private readonly checkoutDiffManager: CheckoutDiffManager; private readonly github: GitHubService; + private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; + private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext; + // Resolves when the most recently scheduled background workspace-naming write + // completes. Tests await this (via the getter below) instead of sleeping. + private pendingWorkspaceNaming: Promise = Promise.resolve(); private readonly workspaceGitService: WorkspaceGitService; private readonly daemonConfigStore: DaemonConfigStore; private readonly mcpBaseUrl: string | null; @@ -896,6 +916,8 @@ export class Session { loopService, checkoutDiffManager, github, + renameCurrentBranch, + generateWorkspaceName, workspaceGitService, daemonConfigStore, mcpBaseUrl, @@ -947,6 +969,8 @@ export class Session { this.loopService = loopService; this.checkoutDiffManager = checkoutDiffManager; this.github = github ?? createGitHubService(); + this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; + this.generateWorkspaceName = generateWorkspaceName ?? generateBranchNameFromFirstAgentContext; this.workspaceGitService = workspaceGitService; this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl ?? null; @@ -1674,7 +1698,9 @@ export class Session { if (!project) { throw new Error(`Project not found for workspace ${workspace.workspaceId}`); } - const checkout = buildWorkspaceCheckout(workspace, project); + const liveBranch = + this.workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? null; + const checkout = buildWorkspaceCheckout(workspace, project, liveBranch); return { projectKey: project.projectId, projectName: resolveProjectDisplayName(project), @@ -3667,25 +3693,24 @@ export class Session { workspace: PersistedWorkspaceRecord; firstAgentContext: FirstAgentContext; }): void { - setTimeout(() => { - void this.maybeAutoNameWorkspaceBranchForFirstAgent(input).catch((error) => { - this.sessionLogger.warn( - { err: error, cwd: input.workspace.cwd }, - "Failed to auto-name worktree branch", - ); - }); - }, 0); + 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 { + }): Promise { + // Capture the generated title from the generator callback so we can write + // displayName := 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 generateBranchNameFromFirstAgentContext({ + return this.generateWorkspaceName({ agentManager: this.agentManager, cwd, workspaceGitService: this.workspaceGitService, @@ -3694,22 +3719,86 @@ export class Session { currentSelection: this.getFocusedAgentSelectionForCwd(cwd), firstAgentContext, logger: this.sessionLogger, + }).then((r) => { + generatedTitle = r?.title ?? null; + return r?.branch ?? null; }); }, }); - if (!result.renamed || !result.branchName) { - return input.workspace; + if (!result.renamed || !generatedTitle) { + return; } - const updatedWorkspace: PersistedWorkspaceRecord = { - ...input.workspace, - displayName: result.branchName, - updatedAt: new Date().toISOString(), - }; - await this.workspaceRegistry.upsert(updatedWorkspace); + // 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, + }); await this.notifyGitMutation(input.workspace.cwd, "rename-branch"); await this.emitWorkspaceUpdateForCwd(input.workspace.cwd); - return updatedWorkspace; + } + + // applyGeneratedWorkspaceTitle writes displayName := title (and, for a worktree + // rename, the new branch) for a workspace. It re-reads the current record from + // the registry so concurrent upserts that happened after workspace creation are + // not clobbered, and writes only its own fields (K4 fix). + private async applyGeneratedWorkspaceTitle( + workspaceId: string, + input: { title: string; branch?: string | null }, + ): Promise { + const current = await this.workspaceRegistry.get(workspaceId); + if (!current) { + return; + } + await this.workspaceRegistry.upsert({ + ...current, + displayName: input.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 and writes it as displayName. 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 }); + await this.emitWorkspaceUpdateForCwd(input.cwd); } private emitProviderDisabledResponse( @@ -5220,11 +5309,35 @@ export class Session { } try { - const result = await renameCurrentBranch(cwd, branch); + const result = await this.renameCurrentBranch(cwd, branch); await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true }); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); + // K3: persist the new git branch for all workspaces at this cwd. We write + // only branch+updatedAt — displayName holds the human title and title is the + // user override; a branch rename must touch neither (name and branch are + // decoupled fields by construction). + // TODO(K10): PR-binding on branch rename is deferred — see plan K10. + if (result.currentBranch) { + const allWorkspaces = await this.workspaceRegistry.list(); + const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd( + cwd, + allWorkspaces, + ); + await Promise.all( + workspaceIds.map(async (workspaceId) => { + const current = await this.workspaceRegistry.get(workspaceId); + if (!current) return; + await this.workspaceRegistry.upsert({ + ...current, + branch: result.currentBranch as string, + updatedAt: new Date().toISOString(), + }); + }), + ); + } + // Push a workspace_update immediately so the sidebar/header reflect // the new branch name without waiting for the background git watcher. await this.emitWorkspaceUpdateForCwd(cwd); @@ -7201,11 +7314,14 @@ export class Session { }, ): Promise { const workspaces = await this.workspaceRegistry.list(); - const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); - if (!workspaceId) { + const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd( + cwd, + workspaces, + ); + if (workspaceIds.length === 0) { return; } - await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options); + await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, options); } private async handleFetchAgents( @@ -7452,7 +7568,7 @@ export class Session { request: Extract, ): Promise { try { - if (request.backing === "local") { + if (request.source.kind === "directory") { await this.handleWorkspaceCreateLocal(request); return; } @@ -7460,7 +7576,7 @@ export class Session { } catch (error) { const message = error instanceof Error ? error.message : "Failed to create workspace"; this.sessionLogger.error( - { err: error, backing: request.backing, requestId: request.requestId }, + { err: error, sourceKind: request.source.kind, requestId: request.requestId }, "Failed to create workspace", ); this.emit({ @@ -7478,21 +7594,11 @@ export class Session { private async handleWorkspaceCreateLocal( request: Extract, ): Promise { - if (!request.cwd) { - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: "cwd is required for a local-backed workspace", - errorCode: "cwd_required", - }, - }); + if (request.source.kind !== "directory") { return; } - const cwd = expandTilde(request.cwd); + const cwd = expandTilde(request.source.path); const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false); if (!directoryExists) { this.emit({ @@ -7536,12 +7642,54 @@ export class Session { "Background snapshot refresh failed after workspace.create", ); }); + if (request.firstAgentContext?.prompt) { + 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" }, + ); + } + } + + // Schedules a background workspace-naming write off the request path and + // records the resulting promise so tests can await completion deterministically + // (no wall-clock sleeps). The setTimeout(0) keeps the LLM call off the hot path. + private scheduleWorkspaceNaming( + run: () => Promise, + context: { cwd: string; message: string }, + ): void { + this.pendingWorkspaceNaming = new Promise((resolveNaming) => { + setTimeout(() => { + void run() + .catch((error) => { + this.sessionLogger.warn({ err: error, cwd: context.cwd }, context.message); + }) + .finally(resolveNaming); + }, 0); + }); + } + + // Test-only handle: resolves when the most recent scheduled workspace-naming + // write finishes, so tests await real completion instead of a wall-clock sleep. + get pendingWorkspaceNamingForTests(): Promise { + return this.pendingWorkspaceNaming; } private async handleWorkspaceCreateWorktree( request: Extract, ): Promise { - if (!request.cwd && !request.projectId) { + if (request.source.kind !== "worktree") { + return; + } + + const source = request.source; + + if (!source.cwd && !source.projectId) { this.emit({ type: "workspace.create.response", payload: { @@ -7556,18 +7704,22 @@ export class Session { } const sourceCwd = await this.resolveWorktreeSourceCwd({ - cwd: request.cwd, - projectId: request.projectId, + cwd: source.cwd, + projectId: source.projectId, }); const result = await this.createPaseoWorktreeWorkflow( { cwd: sourceCwd, - projectId: request.projectId, - worktreeSlug: request.branch, + projectId: source.projectId, + worktreeSlug: source.worktreeSlug, + action: source.action, + refName: source.refName, + githubPrNumber: source.githubPrNumber, + firstAgentContext: request.firstAgentContext, }, - request.baseBranch - ? { resolveDefaultBranch: async () => request.baseBranch as string } + source.baseBranch + ? { resolveDefaultBranch: async () => source.baseBranch as string } : undefined, ); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 9bd998034..a740dd8b4 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -4,6 +4,7 @@ import { homedir, tmpdir } from "node:os"; import path from "node:path"; import { expect, test, vi } from "vitest"; import { z } from "zod"; + import { Session } from "./session.js"; import type { SessionOptions } from "./session.js"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; @@ -22,6 +23,7 @@ import type { AgentStreamEvent, } from "./agent/agent-sdk-types.js"; import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; +import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; import { asSessionLogger, @@ -128,6 +130,11 @@ interface SessionTestAccess { clearWorkspaceArchiving(workspaceIds: Iterable): void; emitWorkspaceUpdateForCwd(...args: unknown[]): Promise; emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; + applyGeneratedWorkspaceTitle( + workspaceId: string, + input: { title: string; branch?: string | null }, + ): Promise; + pendingWorkspaceNamingForTests: Promise; emit(message: unknown): void; onMessage(message: unknown): void; paseoHome: string; @@ -162,6 +169,7 @@ const AgentIdEntrySchema = z.object({ agent: z.object({ id: z.string() }) }); function makeAgent(input: { id: string; cwd: string; + workspaceId?: string; status: AgentSnapshotPayload["status"]; updatedAt: string; pendingPermissions?: number; @@ -174,6 +182,7 @@ function makeAgent(input: { id: input.id, provider: "codex", cwd: input.cwd, + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), model: null, thinkingOptionId: null, effectiveThinkingOptionId: null, @@ -479,6 +488,11 @@ function createSessionForWorkspaceTests( terminalManager?: TerminalManager | null; projectRegistry?: SessionOptions["projectRegistry"]; workspaceRegistry?: SessionOptions["workspaceRegistry"]; + renameCurrentBranch?: ( + cwd: string, + newName: string, + ) => Promise<{ previousBranch: string | null; currentBranch: string | null }>; + generateWorkspaceName?: () => Promise; } = {}, ): TestSession { const logger = { @@ -593,6 +607,8 @@ function createSessionForWorkspaceTests( dispose: () => {}, }), workspaceGitService: options.workspaceGitService ?? createNoopWorkspaceGitService(), + renameCurrentBranch: options.renameCurrentBranch, + generateWorkspaceName: options.generateWorkspaceName, daemonConfigStore: asDaemonConfigStore({ get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), onChange: () => () => {}, @@ -767,6 +783,142 @@ test("create_agent_request keeps requested child cwd when grouped under an exist } }); +test("create_agent_request with an initial prompt renames the local workspace title", async () => { + const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-local-title-")); + try { + const repoDir = path.join(workdir, "repo"); + mkdirSync(repoDir, { recursive: true }); + + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); + const agentManager = new AgentManager({ + clients: { codex: new CreateAgentTestClient() }, + registry: agentStorage, + logger: asSessionLogger(logger), + idFactory: () => "00000000-0000-4000-8000-000000000552", + }); + const projectRegistry = new FileBackedProjectRegistry( + path.join(workdir, "projects.json"), + asSessionLogger(logger), + ); + const workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(workdir, "workspaces.json"), + asSessionLogger(logger), + ); + const workspaceGitService = createNoopWorkspaceGitService({ + getCheckout: async (cwd: string) => ({ + cwd, + isGit: true, + currentBranch: "main", + remoteUrl: null, + worktreeRoot: repoDir, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }), + }); + + await projectRegistry.upsert( + createPersistedProjectRecord({ + projectId: "proj-local-title", + rootPath: repoDir, + kind: "git", + displayName: "repo", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }), + ); + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "ws-local-title", + projectId: "proj-local-title", + cwd: repoDir, + kind: "local_checkout", + displayName: "main", + branch: "main", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }), + ); + + const emitted: SessionOutboundMessage[] = []; + const session = asTestSession( + new Session({ + clientId: "test-client", + appVersion: null, + onMessage: (message) => emitted.push(message), + logger: asSessionLogger(logger), + downloadTokenStore: asDownloadTokenStore(), + pushTokenStore: asPushTokenStore(), + paseoHome: path.join(workdir, "paseo-home"), + agentManager, + agentStorage, + projectRegistry, + workspaceRegistry, + chatService: asChatService(), + scheduleService: asScheduleService(), + loopService: asLoopService(), + checkoutDiffManager: asCheckoutDiffManager({ + subscribe: async () => ({ + initial: { cwd: repoDir, files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + }), + workspaceGitService, + generateWorkspaceName: async () => ({ + title: "Investigate Agent Health", + branch: null, + }), + daemonConfigStore: asDaemonConfigStore({ + get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), + onChange: () => () => {}, + }), + mcpBaseUrl: null, + stt: null, + tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + terminalManager: null, + }), + ); + + await session.handleMessage({ + type: "create_agent_request", + requestId: "req-create-local-title", + workspaceId: "ws-local-title", + config: { provider: "codex", cwd: repoDir }, + initialPrompt: "Investigate the agent health dashboard", + attachments: [], + }); + await session.pendingWorkspaceNamingForTests; + + expect(findByType(emitted, "status")?.payload).toMatchObject({ + status: "agent_created", + agent: { workspaceId: "ws-local-title" }, + }); + await expect(workspaceRegistry.get("ws-local-title")).resolves.toMatchObject({ + displayName: "Investigate Agent Health", + branch: "main", + }); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => { const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" }); const storedRecord = { @@ -4564,6 +4716,67 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi } }); +test("same-cwd workspace descriptors scope agent status by workspace id", async () => { + const session = createSessionForWorkspaceTests(); + const project = createPersistedProjectRecord({ + projectId: "proj-same-cwd-status", + rootPath: REPO_CWD, + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspaceA = createPersistedWorkspaceRecord({ + workspaceId: "ws-same-cwd-a", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspaceB = createPersistedWorkspaceRecord({ + workspaceId: "ws-same-cwd-b", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "second view", + createdAt: "2026-03-01T12:00:01.000Z", + updatedAt: "2026-03-01T12:00:01.000Z", + }); + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspaceA, workspaceB]; + + session.listAgentPayloads = async () => [ + makeAgent({ + id: "agent-running-a", + cwd: REPO_CWD, + workspaceId: workspaceA.workspaceId, + status: "running", + updatedAt: "2026-05-12T10:00:00.000Z", + }), + ]; + const runningDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false }); + expect(runningDescriptors.get(workspaceA.workspaceId)?.status).toBe("running"); + expect(runningDescriptors.get(workspaceB.workspaceId)?.status).toBe("done"); + + session.listAgentPayloads = async () => [ + makeAgent({ + id: "agent-attention-b", + cwd: REPO_CWD, + workspaceId: workspaceB.workspaceId, + status: "idle", + updatedAt: "2026-05-12T11:00:00.000Z", + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: "2026-05-12T11:00:00.000Z", + }), + ]; + const attentionDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false }); + expect(attentionDescriptors.get(workspaceA.workspaceId)?.status).toBe("done"); + expect(attentionDescriptors.get(workspaceB.workspaceId)?.status).toBe("attention"); +}); + test("buildWorkspaceDescriptorMap keeps a done workspace recent after its agents are archived", async () => { const session = createSessionForWorkspaceTests(); const project = createPersistedProjectRecord({ @@ -5783,3 +5996,162 @@ test("removing a contributing terminal clears workspace status", async () => { }, }); }); + +// Worktree-source forwarding (action/refName/githubPrNumber/worktreeSlug) is +// covered end-to-end against a real git repo in +// workspace-create-worktree-source.e2e.test.ts, where the created worktree's +// observable branch proves the request fields reached createWorktreeCore. We do +// not intercept the private workflow here. + +// 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 only displayName 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", + 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 saved = stored.get("ws-worktree-1"); + // The generated title is written as the displayName. + expect(saved?.displayName).toBe("Generated Task Title"); + // 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"); +}); + +// U8: Directory workspaces get a generated title when firstAgentContext has a prompt. +// The generation is workspace-level (title only — no branch rename for directory workspaces). +test("workspace.create.request directory source with firstAgentContext schedules title generation and writes displayName", async () => { + const emitted: SessionOutboundMessage[] = []; + // Inject the workspace-name generator so no LLM is called and we control the + // returned title. Directory workspaces generate a title only (no branch). + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + generateWorkspaceName: async () => ({ title: "Fix login bug", branch: null }), + }); + + // Track workspaceRegistry upserts so we can read the final stored displayName. + const stored = new Map>(); + session.workspaceRegistry.upsert = async (record: unknown) => { + const r = record as Record; + stored.set(r.workspaceId as string, r); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + (stored.get(workspaceId) ?? null) as unknown; + + // Silence workspace update side-effects. + session.emitWorkspaceUpdateForCwd = async () => {}; + session.emitWorkspaceUpdatesForWorkspaceIds = async () => {}; + + await session.handleMessage({ + type: "workspace.create.request", + requestId: "req-dir-title", + source: { kind: "directory", path: REPO_CWD }, + firstAgentContext: { prompt: "Fix the login bug so users can sign in", attachments: [] }, + }); + + // Await the background title-generation write deterministically (no sleep): + // scheduleWorkspaceNaming records the pending write the test awaits here. + await session.pendingWorkspaceNamingForTests; + + const response = emitted.find((m) => m.type === "workspace.create.response"); + expect(response?.payload).toMatchObject({ requestId: "req-dir-title", error: null }); + + // displayName must be the generated human title, NOT the directory basename. + const saved = [...stored.values()].find((r) => r.kind === "directory") as + | Record + | undefined; + expect(saved?.displayName).toBe("Fix login bug"); +}); + +// K3: handleCheckoutRenameBranchRequest must persist the new git branch into the +// dedicated branch field and must NOT touch displayName (the human title) or the +// user-set title. branch and name are decoupled fields by construction. +test("checkout.rename_branch.request persists the new branch and leaves name/title untouched", async () => { + const emitted: SessionOutboundMessage[] = []; + const renameCalls: Array<{ cwd: string; newName: string }> = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + renameCurrentBranch: async (cwd: string, newName: string) => { + renameCalls.push({ cwd, newName }); + return { previousBranch: "feature/old-name", currentBranch: newName }; + }, + }); + + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-worktree-rename", + projectId: "proj-rename", + cwd: REPO_CWD, + kind: "worktree", + displayName: "Refactor auth flow", + branch: "feature/old-name", + title: "Refactor auth flow", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + const workspaces = new Map([[workspace.workspaceId, workspace]]); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; + session.workspaceRegistry.upsert = async (record: unknown) => { + const parsed = record as typeof workspace; + workspaces.set(parsed.workspaceId, parsed); + }; + + await session.handleMessage({ + type: "checkout.rename_branch.request", + cwd: REPO_CWD, + branch: "feature/new-name", + requestId: "req-rename-k3", + }); + + expect(renameCalls).toEqual([{ cwd: REPO_CWD, newName: "feature/new-name" }]); + + const response = findByType(emitted, "checkout.rename_branch.response"); + expect(response?.payload).toMatchObject({ + success: true, + currentBranch: "feature/new-name", + requestId: "req-rename-k3", + }); + + // K3: the new git branch must be persisted into the dedicated branch field. + const persisted = workspaces.get(workspace.workspaceId); + expect(persisted?.branch).toBe("feature/new-name"); + + // K3: rename must NOT touch displayName (the human name) or the user-set title. + expect(persisted?.displayName).toBe("Refactor auth flow"); + expect(persisted?.title).toBe("Refactor auth flow"); +}); diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index 971886338..4d20d5144 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -50,7 +50,10 @@ function createGitRepo(): string { } async function createLocalWorkspace(cwd: string, title: string): Promise { - const result = await ctx.client.createWorkspace({ backing: "local", cwd, title }); + const result = await ctx.client.createWorkspace({ + source: { kind: "directory", path: cwd }, + title, + }); if (!result.workspace) { throw new Error(result.error ?? "Failed to create local workspace"); } @@ -161,10 +164,7 @@ test("archiving the last reference to a worktree honors deleteWorktreeFromDisk", const repoDir = createGitRepo(); const keepResult = await ctx.client.createWorkspace({ - backing: "worktree", - cwd: repoDir, - branch: "keep-on-disk", - baseBranch: "main", + source: { kind: "worktree", cwd: repoDir, worktreeSlug: "keep-on-disk", baseBranch: "main" }, }); const keepWorkspace = keepResult.workspace; if (!keepWorkspace?.workspaceDirectory) { @@ -185,10 +185,12 @@ test("archiving the last reference to a worktree honors deleteWorktreeFromDisk", expect(existsSync(keepDir)).toBe(true); const deleteResult = await ctx.client.createWorkspace({ - backing: "worktree", - cwd: repoDir, - branch: "delete-from-disk", - baseBranch: "main", + source: { + kind: "worktree", + cwd: repoDir, + worktreeSlug: "delete-from-disk", + baseBranch: "main", + }, }); const deleteWorkspace = deleteResult.workspace; if (!deleteWorkspace?.workspaceDirectory) { @@ -216,10 +218,12 @@ test("worktree archive targets the explicit workspaceId when a directory backs m const repoDir = createGitRepo(); const worktreeResult = await ctx.client.createWorkspace({ - backing: "worktree", - cwd: repoDir, - branch: "targeted-worktree", - baseBranch: "main", + source: { + kind: "worktree", + cwd: repoDir, + worktreeSlug: "targeted-worktree", + baseBranch: "main", + }, }); const worktreeWorkspace = worktreeResult.workspace; if (!worktreeWorkspace?.workspaceDirectory) { @@ -260,10 +264,12 @@ test("deleteWorktreeFromDisk keeps the worktree when a sibling workspace still r const repoDir = createGitRepo(); const worktreeResult = await ctx.client.createWorkspace({ - backing: "worktree", - cwd: repoDir, - branch: "shared-worktree", - baseBranch: "main", + source: { + kind: "worktree", + cwd: repoDir, + worktreeSlug: "shared-worktree", + baseBranch: "main", + }, }); const worktreeWorkspace = worktreeResult.workspace; if (!worktreeWorkspace?.workspaceDirectory) { diff --git a/packages/server/src/server/workspace-create-errors.e2e.test.ts b/packages/server/src/server/workspace-create-errors.e2e.test.ts index 1752cf2ca..bc42b7759 100644 --- a/packages/server/src/server/workspace-create-errors.e2e.test.ts +++ b/packages/server/src/server/workspace-create-errors.e2e.test.ts @@ -21,31 +21,25 @@ test("workspace.create surfaces each early-reject error branch", async () => { try { await client.connect(); - // local backing without a cwd -> cwd_required - const cwdRequired = await client.createWorkspace({ backing: "local" }); - expect(cwdRequired.workspace).toBeNull(); - expect(cwdRequired.errorCode).toBe("cwd_required"); - - // local backing pointed at a path that does not exist -> directory_not_found + // directory source pointed at a path that does not exist -> directory_not_found const directoryNotFound = await client.createWorkspace({ - backing: "local", - cwd: missingDir, + source: { kind: "directory", path: missingDir }, }); expect(directoryNotFound.workspace).toBeNull(); expect(directoryNotFound.errorCode).toBe("directory_not_found"); expect(directoryNotFound.error).toContain(missingDir); - // worktree backing without a cwd or projectId -> source_required - const sourceRequired = await client.createWorkspace({ backing: "worktree", branch: "feat" }); + // worktree source without a cwd or projectId -> source_required + const sourceRequired = await client.createWorkspace({ + source: { kind: "worktree", worktreeSlug: "feat" }, + }); expect(sourceRequired.workspace).toBeNull(); expect(sourceRequired.errorCode).toBe("source_required"); - // worktree backing with an unknown projectId -> project-not-found message + // worktree source with an unknown projectId -> project-not-found message // (surfaced via the generic catch, so it carries an error but no errorCode) const projectNotFound = await client.createWorkspace({ - backing: "worktree", - projectId: "proj-does-not-exist", - branch: "feat", + source: { kind: "worktree", projectId: "proj-does-not-exist", worktreeSlug: "feat" }, }); expect(projectNotFound.workspace).toBeNull(); expect(projectNotFound.error).toContain("Project not found: proj-does-not-exist"); diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts new file mode 100644 index 000000000..f2b2b299e --- /dev/null +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -0,0 +1,97 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; + +import { DaemonClient } from "./test-utils/index.js"; +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; + +// The reshaped workspace.create.request forwards its worktree `source` +// (action/refName/githubPrNumber/worktreeSlug) verbatim into createWorktreeCore. +// The regression these tests guard against is the daemon dropping action/refName +// while subsetting the request. We prove forwarding through the real workflow: +// the created worktree's observable branch is the only honest evidence the +// request fields reached git. + +function createGitRepoWithBranch(): { repoDir: string; tempRoot: string } { + const tempRoot = mkdtempSync(path.join(tmpdir(), "workspace-create-worktree-source-")); + const repoDir = path.join(tempRoot, "repo"); + execFileSync("git", ["init", "-b", "main", 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", ["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); + // An existing branch the checkout action must target by refName. + execFileSync("git", ["branch", "feature/existing-branch"], { cwd: repoDir, stdio: "pipe" }); + return { repoDir, tempRoot }; +} + +test("workspace.create worktree source forwards action=checkout + refName into the real worktree", async () => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + + const result = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "checkout", + refName: "feature/existing-branch", + }, + }); + + expect(result.error).toBeNull(); + // If action/refName were dropped, the daemon would branch-off a generated + // slug instead of checking out the named branch. The created worktree being + // on feature/existing-branch is the observable proof both fields forwarded. + expect(result.workspace?.gitRuntime?.currentBranch).toBe("feature/existing-branch"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(tempRoot, { recursive: true, force: true }); + } +}, 180000); + +test("workspace.create worktree source forwards branch-off + refName as the new branch", async () => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + + const result = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "branch-off", + worktreeSlug: "brand-new-branch", + baseBranch: "main", + }, + }); + + expect(result.error).toBeNull(); + // branch-off cuts a new branch named after worktreeSlug from baseBranch; the + // worktree landing on that branch proves worktreeSlug/baseBranch forwarded. + expect(result.workspace?.gitRuntime?.currentBranch).toBe("brand-new-branch"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(tempRoot, { recursive: true, force: true }); + } +}, 180000); diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 9e51023fe..9d9d3a02d 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { createTestLogger } from "../test-utils/test-logger.js"; import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./messages.js"; -import { WorkspaceDirectory } from "./workspace-directory.js"; +import { WorkspaceDirectory, resolveRegisteredWorkspaceIdsForCwd } from "./workspace-directory.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; @@ -505,3 +505,71 @@ describe("WorkspaceDirectory empty projects", () => { expect(result.emptyProjects.map((p) => p.projectId)).toEqual(["empty"]); }); }); + +describe("resolveRegisteredWorkspaceIdsForCwd", () => { + const sharedCwd = "/workspace/project"; + + const workspace1: PersistedWorkspaceRecord = { + workspaceId: "ws-1", + projectId: "proj-1", + cwd: sharedCwd, + kind: "local_checkout", + displayName: "main", + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + }; + + const workspace2: PersistedWorkspaceRecord = { + workspaceId: "ws-2", + projectId: "proj-1", + cwd: sharedCwd, + kind: "local_checkout", + displayName: "main-2", + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + }; + + const workspace3: PersistedWorkspaceRecord = { + workspaceId: "ws-3", + projectId: "proj-2", + cwd: "/workspace/other", + kind: "local_checkout", + displayName: "other", + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + }; + + test("returns both ids when two workspaces share the same cwd", () => { + const result = resolveRegisteredWorkspaceIdsForCwd(sharedCwd, [ + workspace1, + workspace2, + workspace3, + ]); + expect(result.sort()).toEqual(["ws-1", "ws-2"]); + }); + + test("returns a single id when only one workspace matches the cwd", () => { + const result = resolveRegisteredWorkspaceIdsForCwd("/workspace/other", [ + workspace1, + workspace2, + workspace3, + ]); + expect(result).toEqual(["ws-3"]); + }); + + test("returns prefix-matched id when no exact cwd match exists", () => { + const subdir = `${sharedCwd}/packages/app`; + // workspace1 and workspace2 both have sharedCwd as a prefix; the plural + // resolver must return both when multiple workspaces share the best prefix. + const result = resolveRegisteredWorkspaceIdsForCwd(subdir, [workspace1, workspace2]); + expect(result.sort()).toEqual(["ws-1", "ws-2"]); + }); + + test("returns empty array when no workspace matches", () => { + const result = resolveRegisteredWorkspaceIdsForCwd("/unrelated/path", [workspace1, workspace2]); + expect(result).toEqual([]); + }); +}); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 9fa846cf1..a7b31c58d 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -67,14 +67,23 @@ export function resolveRegisteredWorkspaceIdForCwd( cwd: string, workspaces: PersistedWorkspaceRecord[], ): string | null { + const ids = resolveRegisteredWorkspaceIdsForCwd(cwd, workspaces); + return ids[0] ?? null; +} + +export function resolveRegisteredWorkspaceIdsForCwd( + cwd: string, + workspaces: PersistedWorkspaceRecord[], +): string[] { const resolvedCwd = resolve(cwd); - const exact = workspaces.find((workspace) => workspace.cwd === resolvedCwd); - if (exact) { - return exact.workspaceId; + const exactMatches = workspaces.filter((workspace) => workspace.cwd === resolvedCwd); + if (exactMatches.length > 0) { + return exactMatches.map((workspace) => workspace.workspaceId); } const userHome = homedir(); - let bestMatch: PersistedWorkspaceRecord | null = null; + let bestMatchLength = 0; + const prefixMatches: PersistedWorkspaceRecord[] = []; for (const workspace of workspaces) { if (workspace.cwd === userHome) continue; if (workspace.archivedAt) continue; @@ -82,12 +91,16 @@ export function resolveRegisteredWorkspaceIdForCwd( if (!resolvedCwd.startsWith(prefix)) { continue; } - if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) { - bestMatch = workspace; + if (workspace.cwd.length > bestMatchLength) { + bestMatchLength = workspace.cwd.length; + prefixMatches.length = 0; + prefixMatches.push(workspace); + } else if (workspace.cwd.length === bestMatchLength) { + prefixMatches.push(workspace); } } - return bestMatch?.workspaceId ?? null; + return prefixMatches.map((workspace) => workspace.workspaceId); } export interface WorkspaceDirectoryDeps { @@ -495,6 +508,13 @@ export class WorkspaceDirectory { return resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); } + resolveRegisteredWorkspaceIdsForCwd( + cwd: string, + workspaces: PersistedWorkspaceRecord[], + ): string[] { + return resolveRegisteredWorkspaceIdsForCwd(cwd, workspaces); + } + // Project parents that have no active workspaces. These persist as first-class // empty projects so the sidebar can render an empty project row with a // "+ New workspace" affordance. diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 3890e93b8..507b08e37 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -37,6 +37,14 @@ const PersistedWorkspaceRecordSchema = z.object({ .nullable() .optional() .transform((value) => value ?? null), + // The worktree's git branch. Decoupled from displayName/title by construction: + // displayName holds the human name (title), branch holds the git branch. Only + // worktree workspaces carry a branch; directory/local_checkout leave it null. + branch: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), @@ -236,6 +244,7 @@ export function createPersistedWorkspaceRecord(input: { kind: PersistedWorkspaceKind; displayName: string; title?: string | null; + branch?: string | null; createdAt: string; updatedAt: string; archivedAt?: string | null; @@ -243,6 +252,7 @@ export function createPersistedWorkspaceRecord(input: { return PersistedWorkspaceRecordSchema.parse({ ...input, title: input.title ?? null, + branch: input.branch ?? null, archivedAt: input.archivedAt ?? null, }); } diff --git a/packages/server/src/server/worktree-branch-name-generator.test.ts b/packages/server/src/server/worktree-branch-name-generator.test.ts index 84ac4074c..10ccb729b 100644 --- a/packages/server/src/server/worktree-branch-name-generator.test.ts +++ b/packages/server/src/server/worktree-branch-name-generator.test.ts @@ -17,10 +17,12 @@ import { } from "../utils/worktree-metadata.js"; const cleanupPaths: string[] = []; -const PRE_CHANGE_BRANCH_PROMPT = `Generate a git branch name for a coding agent based on the user prompt and attachments. +const BRANCH_PROMPT_BASELINE = `Generate a git branch name for a coding agent based on the user prompt and attachments. +Title: a short human-readable sentence-case label for the task (no slug rules, max 80 characters). Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only. No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens. -Return JSON only with a single field 'branch'. +The branch is generated directly from the prompt — it is NEVER derived from or slugified from the title. +Return JSON only with fields 'title' and 'branch'. User context: Fix the login flow`; @@ -39,7 +41,7 @@ function createLogger() { }; } -function createStructuredGenerator(result: { branch: string }) { +function createStructuredGenerator(result: { title: string; branch: string }) { const calls: StructuredAgentGenerationWithFallbackOptions[] = []; async function generateStructured( @@ -53,10 +55,35 @@ function createStructuredGenerator(result: { branch: string }) { } describe("generateBranchNameFromFirstAgentContext", () => { - test("calls the structured generator with first-agent prompt text", async () => { - const structured = createStructuredGenerator({ branch: "fix-login-flow" }); + test("returns title and branch independently — branch is not a slug of the title", async () => { + const structured = createStructuredGenerator({ + title: "Add payments flow", + branch: "pay/checkout", + }); - const branch = await generateBranchNameFromFirstAgentContext({ + const result = await generateBranchNameFromFirstAgentContext({ + agentManager: {} as AgentManager, + cwd: "/tmp/repo", + firstAgentContext: { prompt: "Add a payments flow with Stripe checkout" }, + logger: createLogger(), + deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, + }); + + expect(result).not.toBeNull(); + expect(result?.title).toBe("Add payments flow"); + expect(result?.branch).toBe("pay/checkout"); + // Branch is not a kebab-slug of the title — they are independently generated + expect(result?.branch).not.toBe("add-payments-flow"); + expect(structured.calls).toHaveLength(1); + }); + + test("calls the structured generator with first-agent prompt text", async () => { + const structured = createStructuredGenerator({ + title: "Fix login flow", + branch: "fix-login-flow", + }); + + const result = await generateBranchNameFromFirstAgentContext({ agentManager: {} as AgentManager, cwd: "/tmp/repo", firstAgentContext: { prompt: "Fix the login flow" }, @@ -64,7 +91,7 @@ describe("generateBranchNameFromFirstAgentContext", () => { deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, }); - expect(branch).toBe("fix-login-flow"); + expect(result?.branch).toBe("fix-login-flow"); expect(structured.calls).toHaveLength(1); const firstCall = structured.calls[0]; if (!firstCall) { @@ -83,9 +110,12 @@ describe("generateBranchNameFromFirstAgentContext", () => { }); test("uses attachment-only context", async () => { - const structured = createStructuredGenerator({ branch: "review-flaky-checkout" }); + const structured = createStructuredGenerator({ + title: "Review flaky checkout", + branch: "review-flaky-checkout", + }); - const branch = await generateBranchNameFromFirstAgentContext({ + const result = await generateBranchNameFromFirstAgentContext({ agentManager: {} as AgentManager, cwd: "/tmp/repo", firstAgentContext: { @@ -103,7 +133,7 @@ describe("generateBranchNameFromFirstAgentContext", () => { deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, }); - expect(branch).toBe("review-flaky-checkout"); + expect(result?.branch).toBe("review-flaky-checkout"); const firstCall = structured.calls[0]; if (!firstCall) { throw new Error("expected structured generation call"); @@ -112,9 +142,12 @@ describe("generateBranchNameFromFirstAgentContext", () => { }); test("uses the current selection as the final provider fallback", async () => { - const structured = createStructuredGenerator({ branch: "focused-branch" }); + const structured = createStructuredGenerator({ + title: "Focused task", + branch: "focused-branch", + }); - const branch = await generateBranchNameFromFirstAgentContext({ + const result = await generateBranchNameFromFirstAgentContext({ agentManager: {} as AgentManager, cwd: "/tmp/repo", providerSnapshotManager: { @@ -144,7 +177,7 @@ describe("generateBranchNameFromFirstAgentContext", () => { deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, }); - expect(branch).toBe("focused-branch"); + expect(result?.branch).toBe("focused-branch"); const firstCall = structured.calls[0]; if (!firstCall) { throw new Error("expected structured generation call"); @@ -174,7 +207,7 @@ describe("generateBranchNameFromFirstAgentContext", () => { ])("keeps the pre-change prompt byte-identical when %s", async (_name, config) => { const { prompt } = await generateBranchPromptWithConfig(config); - expect(prompt).toBe(PRE_CHANGE_BRANCH_PROMPT); + expect(prompt).toBe(BRANCH_PROMPT_BASELINE); }); test("injects project instructions between the default rules and JSON contract", async () => { @@ -218,7 +251,10 @@ describe("generateBranchNameFromFirstAgentContext", () => { }, }, }); - const structured = createStructuredGenerator({ branch: "Invalid Branch Name" }); + const structured = createStructuredGenerator({ + title: "Invalid title", + branch: "Invalid Branch Name", + }); const renameCurrentBranch = vi.fn(async () => ({ previousBranch: "dazzling-yak", currentBranch: "Invalid Branch Name", @@ -237,7 +273,7 @@ describe("generateBranchNameFromFirstAgentContext", () => { firstAgentContext, logger: createLogger(), deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, - }), + }).then((r) => r?.branch ?? null), getCurrentBranch: async () => "dazzling-yak", renameCurrentBranch, }); @@ -255,7 +291,10 @@ async function generateBranchPromptWithConfig(config: unknown): Promise<{ prompt writeConfig(repoRoot, config); } - const structured = createStructuredGenerator({ branch: "fix-login-flow" }); + const structured = createStructuredGenerator({ + title: "Fix login flow", + branch: "fix-login-flow", + }); await generateBranchNameFromFirstAgentContext({ agentManager: {} as AgentManager, diff --git a/packages/server/src/server/worktree-branch-name-generator.ts b/packages/server/src/server/worktree-branch-name-generator.ts index e4c3a5485..7c67edd99 100644 --- a/packages/server/src/server/worktree-branch-name-generator.ts +++ b/packages/server/src/server/worktree-branch-name-generator.ts @@ -40,6 +40,7 @@ export interface GenerateBranchNameFromFirstAgentContextOptions { } const BranchNameSchema = z.object({ + title: z.string().min(1).max(80), branch: z.string().min(1).max(100), }); @@ -56,17 +57,24 @@ async function buildPrompt( configKey: "branchName", before: [ "Generate a git branch name for a coding agent based on the user prompt and attachments.", + "Title: a short human-readable sentence-case label for the task (no slug rules, max 80 characters).", "Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.", "No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.", + "The branch is generated directly from the prompt — it is NEVER derived from or slugified from the title.", ].join("\n"), - after: "Return JSON only with a single field 'branch'.", + after: "Return JSON only with fields 'title' and 'branch'.", trailing: `User context:\n${seed}`, }); } +export interface GeneratedWorkspaceName { + title: string | null; + branch: string | null; +} + export async function generateBranchNameFromFirstAgentContext( options: GenerateBranchNameFromFirstAgentContextOptions, -): Promise { +): Promise { const seed = buildAgentBranchNameSeed(options.firstAgentContext); if (!seed) { return null; @@ -103,7 +111,10 @@ export async function generateBranchNameFromFirstAgentContext( internal: true, }, }); - return result.branch.trim() || null; + return { + title: result.title.trim() || null, + branch: result.branch.trim() || null, + }; } catch (error) { const attempts = error instanceof StructuredAgentFallbackError ? error.attempts : undefined; options.logger.error(