Update workspace model ownership flows

This commit is contained in:
Mohamed Boudra
2026-06-15 22:02:18 +07:00
parent af0437e6df
commit a4d4d42873
23 changed files with 1100 additions and 154 deletions

View File

@@ -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 |

View File

@@ -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;

View File

@@ -28,9 +28,7 @@ async function createSecondWorkspaceOnSameDir(
title: string,
): Promise<string> {
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) {

View File

@@ -26,9 +26,7 @@ function projectNewWorktreeIcon(page: Page, projectKey: string) {
async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise<string> {
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) {

View File

@@ -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<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
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();
}
});
});

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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<WorkspaceCreatePayload> {
@@ -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,

View File

@@ -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({

View File

@@ -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");
});
});

View File

@@ -21,7 +21,7 @@ async function workspaceIds(client: DaemonClient): Promise<Set<string>> {
}
async function mintLocalWorkspace(client: DaemonClient, cwd: string): Promise<string> {
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");
}

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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<void> = 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<PersistedWorkspaceRecord> {
}): Promise<void> {
// 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<void> {
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<GeneratedWorkspaceName | null> {
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<void> {
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<void> {
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<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> {
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<void>,
context: { cwd: string; message: string },
): void {
this.pendingWorkspaceNaming = new Promise<void>((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<void> {
return this.pendingWorkspaceNaming;
}
private async handleWorkspaceCreateWorktree(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> {
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,
);

View File

@@ -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<string>): void;
emitWorkspaceUpdateForCwd(...args: unknown[]): Promise<unknown>;
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
applyGeneratedWorkspaceTitle(
workspaceId: string,
input: { title: string; branch?: string | null },
): Promise<void>;
pendingWorkspaceNamingForTests: Promise<void>;
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<GeneratedWorkspaceName | null>;
} = {},
): 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<string, Record<string, unknown>>();
session.workspaceRegistry.upsert = async (record: unknown) => {
const r = record as Record<string, unknown>;
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<string, unknown>
| 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");
});

View File

@@ -50,7 +50,10 @@ function createGitRepo(): string {
}
async function createLocalWorkspace(cwd: string, title: string): Promise<string> {
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) {

View File

@@ -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");

View File

@@ -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);

View File

@@ -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([]);
});
});

View File

@@ -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.

View File

@@ -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,
});
}

View File

@@ -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<unknown>[] = [];
async function generateStructured<T>(
@@ -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,

View File

@@ -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<string | null> {
): Promise<GeneratedWorkspaceName | null> {
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(