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
+3 -1
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 | | `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path | | `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | | | `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) | | | `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | | | `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | | `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
+13 -5
View File
@@ -25,11 +25,18 @@ export interface SeedDaemonClient {
error: string | null; error: string | null;
}>; }>;
createWorkspace(input: { createWorkspace(input: {
backing: "local" | "worktree"; source:
cwd?: string; | { kind: "directory"; path: string; projectId?: string }
projectId?: string; | {
branch?: string; kind: "worktree";
baseBranch?: string; cwd?: string;
projectId?: string;
action?: "branch-off" | "checkout";
refName?: string;
baseBranch?: string;
githubPrNumber?: number;
worktreeSlug?: string;
};
title?: string; title?: string;
}): Promise<{ }): Promise<{
workspace: { id: string; name: string } | null; workspace: { id: string; name: string } | null;
@@ -64,6 +71,7 @@ export interface SeedDaemonClient {
createAgent(options: { createAgent(options: {
provider: string; provider: string;
cwd: string; cwd: string;
workspaceId?: string;
title?: string; title?: string;
modeId?: string; modeId?: string;
model?: string; model?: string;
@@ -28,9 +28,7 @@ async function createSecondWorkspaceOnSameDir(
title: string, title: string,
): Promise<string> { ): Promise<string> {
const created = await seeded.client.createWorkspace({ const created = await seeded.client.createWorkspace({
backing: "local", source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
cwd: seeded.repoPath,
projectId: seeded.projectId,
title, title,
}); });
if (!created.workspace) { if (!created.workspace) {
+1 -3
View File
@@ -26,9 +26,7 @@ function projectNewWorktreeIcon(page: Page, projectKey: string) {
async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise<string> { async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise<string> {
const created = await seeded.client.createWorkspace({ const created = await seeded.client.createWorkspace({
backing: "local", source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
cwd: seeded.repoPath,
projectId: seeded.projectId,
title, title,
}); });
if (!created.workspace) { if (!created.workspace) {
@@ -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();
}
});
});
@@ -791,11 +791,19 @@ async function createMultiplicityWorkspace(input: {
? (resolveCheckoutRequest(input.selectedItem, input.currentBranch)?.refName ?? undefined) ? (resolveCheckoutRequest(input.selectedItem, input.currentBranch)?.refName ?? undefined)
: undefined; : undefined;
const payload = await input.client.createWorkspace({ const payload = await input.client.createWorkspace({
backing: input.backing, source: isWorktree
cwd: input.project.iconWorkingDir, ? {
projectId: input.project.projectKey, kind: "worktree",
...(isWorktree ? { branch: createNameId() } : {}), cwd: input.project.iconWorkingDir,
...(baseBranch ? { baseBranch } : {}), projectId: input.project.projectKey,
worktreeSlug: createNameId(),
...(baseBranch ? { baseBranch } : {}),
}
: {
kind: "directory",
path: input.project.iconWorkingDir,
projectId: input.project.projectKey,
},
}); });
if (payload.error || !payload.workspace) { if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? input.createFailedMessage); throw new Error(payload.error ?? input.createFailedMessage);
+9 -5
View File
@@ -436,14 +436,18 @@ async function resolveRunWorkspace(
return { id: explicit, cwd }; 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 const result = options.worktree
? await client.createWorkspace({ ? await client.createWorkspace({
backing: "worktree", source: {
cwd, kind: "worktree",
branch: options.worktree, cwd,
baseBranch: options.base, worktreeSlug: options.worktree,
baseBranch: options.base,
},
}) })
: await client.createWorkspace({ backing: "local", cwd }); : await client.createWorkspace({ source: { kind: "directory", path: cwd } });
if (!result.workspace) { if (!result.workspace) {
throw { throw {
+7 -10
View File
@@ -79,6 +79,7 @@ import type {
SendAgentMessageRequest, SendAgentMessageRequest,
PaseoConfigRaw, PaseoConfigRaw,
PaseoConfigRevision, PaseoConfigRevision,
WorkspaceCreateRequest,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import type { import type {
AgentPermissionRequest, AgentPermissionRequest,
@@ -3257,12 +3258,9 @@ export class DaemonClient {
async createWorkspace( async createWorkspace(
input: { input: {
backing: "local" | "worktree"; source: WorkspaceCreateRequest["source"];
cwd?: string;
projectId?: string;
branch?: string;
baseBranch?: string;
title?: string; title?: string;
firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"];
}, },
requestId?: string, requestId?: string,
): Promise<WorkspaceCreatePayload> { ): Promise<WorkspaceCreatePayload> {
@@ -3270,12 +3268,11 @@ export class DaemonClient {
requestId, requestId,
message: { message: {
type: "workspace.create.request", type: "workspace.create.request",
backing: input.backing, source: input.source,
...(input.cwd !== undefined ? { cwd: input.cwd } : {}),
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
...(input.branch !== undefined ? { branch: input.branch } : {}),
...(input.baseBranch !== undefined ? { baseBranch: input.baseBranch } : {}),
...(input.title !== undefined ? { title: input.title } : {}), ...(input.title !== undefined ? { title: input.title } : {}),
...(input.firstAgentContext !== undefined
? { firstAgentContext: input.firstAgentContext }
: {}),
}, },
responseType: "workspace.create.response", responseType: "workspace.create.response",
timeout: 60000, timeout: 60000,
+25 -13
View File
@@ -1676,23 +1676,35 @@ export const ArchiveWorkspaceRequestSchema = z.object({
}); });
// Create a new workspace record. Unlike open_project, this never deduplicates by // Create a new workspace record. Unlike open_project, this never deduplicates by
// directory: it always produces a fresh workspace. The backing directory is a // directory: it always produces a fresh workspace. The source discriminates
// choice — an existing local checkout/directory, or a newly created worktree. // between an existing local directory and a newly created paseo worktree.
export const WorkspaceCreateRequestSchema = z.object({ export const WorkspaceCreateRequestSchema = z.object({
type: z.literal("workspace.create.request"), type: z.literal("workspace.create.request"),
backing: z.enum(["local", "worktree"]), requestId: z.string(),
// 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(),
// Optional user-set title applied to the created workspace. // Optional user-set title applied to the created workspace.
title: z.string().optional(), 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({ export const WorkspaceClearAttentionRequestSchema = z.object({
@@ -4,6 +4,7 @@ import {
RecentProviderSessionDescriptorPayloadSchema, RecentProviderSessionDescriptorPayloadSchema,
SessionInboundMessageSchema, SessionInboundMessageSchema,
SessionOutboundMessageSchema, SessionOutboundMessageSchema,
WorkspaceCreateRequestSchema,
WorkspaceDescriptorPayloadSchema, WorkspaceDescriptorPayloadSchema,
WorkspaceScriptPayloadSchema, WorkspaceScriptPayloadSchema,
} from "./messages.js"; } from "./messages.js";
@@ -866,4 +867,42 @@ describe("workspace message schemas", () => {
const checkout = result.data.payload.entries[0]?.project.checkout; const checkout = result.data.payload.entries[0]?.project.checkout;
expect(checkout?.worktreeRoot).toBe("C:\\repo"); 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");
});
}); });
@@ -21,7 +21,7 @@ async function workspaceIds(client: DaemonClient): Promise<Set<string>> {
} }
async function mintLocalWorkspace(client: DaemonClient, cwd: string): Promise<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) { if (!result.workspace) {
throw new Error(result.error ?? "Failed to create workspace"); throw new Error(result.error ?? "Failed to create workspace");
} }
@@ -240,6 +240,7 @@ async function upsertWorkspaceForWorktree(options: {
cwd: normalizedCwd, cwd: normalizedCwd,
kind: "worktree", kind: "worktree",
displayName: options.worktree.branchName || normalizedCwd, displayName: options.worktree.branchName || normalizedCwd,
branch: options.worktree.branchName || null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
archivedAt: null, archivedAt: null,
@@ -274,12 +275,19 @@ export async function createLocalCheckoutWorkspace(
await deps.projectRegistry.upsert(projectRecord); await deps.projectRegistry.upsert(projectRecord);
const trimmedTitle = options.title?.trim(); 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({ const workspace = createPersistedWorkspaceRecord({
workspaceId: generateWorkspaceId(), workspaceId: generateWorkspaceId(),
projectId: projectRecord.projectId, projectId: projectRecord.projectId,
cwd: normalizedCwd, cwd: normalizedCwd,
kind: membership.workspaceKind, kind: membership.workspaceKind,
displayName: membership.workspaceDisplayName, displayName: membership.workspaceDisplayName,
branch,
title: trimmedTitle ? trimmedTitle : null, title: trimmedTitle ? trimmedTitle : null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -2717,6 +2717,7 @@ describe("session workspace descriptors", () => {
cwd: "/repo/app", cwd: "/repo/app",
kind: "local_checkout" as const, kind: "local_checkout" as const,
displayName: "app", displayName: "app",
branch: "app",
archivedAt: null, archivedAt: null,
}; };
const project = { const project = {
@@ -2787,6 +2788,7 @@ describe("session workspace descriptors", () => {
cwd: "/repo/local", cwd: "/repo/local",
kind: "local_checkout" as const, kind: "local_checkout" as const,
displayName: "local", displayName: "local",
branch: "local",
archivedAt: null, archivedAt: null,
}; };
const project = { const project = {
+201 -49
View File
@@ -207,7 +207,7 @@ import {
pullCurrentBranch, pullCurrentBranch,
pushCurrentBranch, pushCurrentBranch,
createPullRequest, createPullRequest,
renameCurrentBranch, renameCurrentBranch as renameCurrentBranchDefault,
} from "../utils/checkout-git.js"; } from "../utils/checkout-git.js";
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import { getProjectIcon } from "../utils/project-icon.js"; import { getProjectIcon } from "../utils/project-icon.js";
@@ -252,7 +252,10 @@ import {
type CreatePaseoWorktreeInput, type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult, type CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js"; } from "./paseo-worktree-service.js";
import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js"; import {
generateBranchNameFromFirstAgentContext,
type GeneratedWorkspaceName,
} from "./worktree-branch-name-generator.js";
import { import {
assertSafeGitRef as assertWorktreeSafeGitRef, assertSafeGitRef as assertWorktreeSafeGitRef,
buildAgentSessionConfig as buildWorktreeAgentSessionConfig, buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
@@ -375,6 +378,11 @@ function diffChangeTypeFor(file: { isNew?: boolean; isDeleted?: boolean }): "A"
function buildWorkspaceCheckout( function buildWorkspaceCheckout(
workspace: PersistedWorkspaceRecord, workspace: PersistedWorkspaceRecord,
project: PersistedProjectRecord, 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"] { ): ProjectPlacementPayload["checkout"] {
if (project.kind !== "git") { if (project.kind !== "git") {
return { return {
@@ -387,11 +395,12 @@ function buildWorkspaceCheckout(
mainRepoRoot: null, mainRepoRoot: null,
}; };
} }
const currentBranch = workspace.branch ?? fallbackBranch ?? null;
if (workspace.kind === "worktree") { if (workspace.kind === "worktree") {
return { return {
cwd: workspace.cwd, cwd: workspace.cwd,
isGit: true, isGit: true,
currentBranch: workspace.displayName, currentBranch,
remoteUrl: null, remoteUrl: null,
worktreeRoot: workspace.cwd, worktreeRoot: workspace.cwd,
isPaseoOwnedWorktree: true, isPaseoOwnedWorktree: true,
@@ -401,7 +410,7 @@ function buildWorkspaceCheckout(
return { return {
cwd: workspace.cwd, cwd: workspace.cwd,
isGit: true, isGit: true,
currentBranch: workspace.displayName, currentBranch,
remoteUrl: null, remoteUrl: null,
worktreeRoot: workspace.cwd, worktreeRoot: workspace.cwd,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
@@ -597,6 +606,12 @@ export interface SessionOptions {
checkoutDiffManager: CheckoutDiffManager; checkoutDiffManager: CheckoutDiffManager;
github?: GitHubService; github?: GitHubService;
createAgentMcpTransport?: AgentMcpTransportFactory; 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; workspaceGitService: WorkspaceGitService;
daemonConfigStore: DaemonConfigStore; daemonConfigStore: DaemonConfigStore;
mcpBaseUrl?: string | null; mcpBaseUrl?: string | null;
@@ -818,6 +833,11 @@ export class Session {
private readonly loopService: LoopService; private readonly loopService: LoopService;
private readonly checkoutDiffManager: CheckoutDiffManager; private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly github: GitHubService; 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 workspaceGitService: WorkspaceGitService;
private readonly daemonConfigStore: DaemonConfigStore; private readonly daemonConfigStore: DaemonConfigStore;
private readonly mcpBaseUrl: string | null; private readonly mcpBaseUrl: string | null;
@@ -896,6 +916,8 @@ export class Session {
loopService, loopService,
checkoutDiffManager, checkoutDiffManager,
github, github,
renameCurrentBranch,
generateWorkspaceName,
workspaceGitService, workspaceGitService,
daemonConfigStore, daemonConfigStore,
mcpBaseUrl, mcpBaseUrl,
@@ -947,6 +969,8 @@ export class Session {
this.loopService = loopService; this.loopService = loopService;
this.checkoutDiffManager = checkoutDiffManager; this.checkoutDiffManager = checkoutDiffManager;
this.github = github ?? createGitHubService(); this.github = github ?? createGitHubService();
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
this.generateWorkspaceName = generateWorkspaceName ?? generateBranchNameFromFirstAgentContext;
this.workspaceGitService = workspaceGitService; this.workspaceGitService = workspaceGitService;
this.daemonConfigStore = daemonConfigStore; this.daemonConfigStore = daemonConfigStore;
this.mcpBaseUrl = mcpBaseUrl ?? null; this.mcpBaseUrl = mcpBaseUrl ?? null;
@@ -1674,7 +1698,9 @@ export class Session {
if (!project) { if (!project) {
throw new Error(`Project not found for workspace ${workspace.workspaceId}`); 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 { return {
projectKey: project.projectId, projectKey: project.projectId,
projectName: resolveProjectDisplayName(project), projectName: resolveProjectDisplayName(project),
@@ -3667,25 +3693,24 @@ export class Session {
workspace: PersistedWorkspaceRecord; workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext; firstAgentContext: FirstAgentContext;
}): void { }): void {
setTimeout(() => { this.scheduleWorkspaceNaming(() => this.maybeAutoNameWorkspaceBranchForFirstAgent(input), {
void this.maybeAutoNameWorkspaceBranchForFirstAgent(input).catch((error) => { cwd: input.workspace.cwd,
this.sessionLogger.warn( message: "Failed to auto-name worktree branch",
{ err: error, cwd: input.workspace.cwd }, });
"Failed to auto-name worktree branch",
);
});
}, 0);
} }
private async maybeAutoNameWorkspaceBranchForFirstAgent(input: { private async maybeAutoNameWorkspaceBranchForFirstAgent(input: {
workspace: PersistedWorkspaceRecord; workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext; 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({ const result = await attemptFirstAgentBranchAutoName({
cwd: input.workspace.cwd, cwd: input.workspace.cwd,
firstAgentContext: input.firstAgentContext, firstAgentContext: input.firstAgentContext,
generateBranchNameFromContext: ({ cwd, firstAgentContext }) => { generateBranchNameFromContext: ({ cwd, firstAgentContext }) => {
return generateBranchNameFromFirstAgentContext({ return this.generateWorkspaceName({
agentManager: this.agentManager, agentManager: this.agentManager,
cwd, cwd,
workspaceGitService: this.workspaceGitService, workspaceGitService: this.workspaceGitService,
@@ -3694,22 +3719,86 @@ export class Session {
currentSelection: this.getFocusedAgentSelectionForCwd(cwd), currentSelection: this.getFocusedAgentSelectionForCwd(cwd),
firstAgentContext, firstAgentContext,
logger: this.sessionLogger, logger: this.sessionLogger,
}).then((r) => {
generatedTitle = r?.title ?? null;
return r?.branch ?? null;
}); });
}, },
}); });
if (!result.renamed || !result.branchName) { if (!result.renamed || !generatedTitle) {
return input.workspace; return;
} }
const updatedWorkspace: PersistedWorkspaceRecord = { // K4: re-read from the registry before writing so any concurrent upsert
...input.workspace, // that happened between workspace creation and this async path is not clobbered.
displayName: result.branchName, // The first-agent rename renamed the git branch too, so persist the new branch
updatedAt: new Date().toISOString(), // alongside the title — both are this path's own fields.
}; await this.applyGeneratedWorkspaceTitle(input.workspace.workspaceId, {
await this.workspaceRegistry.upsert(updatedWorkspace); title: generatedTitle,
branch: result.branchName,
});
await this.notifyGitMutation(input.workspace.cwd, "rename-branch"); await this.notifyGitMutation(input.workspace.cwd, "rename-branch");
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd); 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( private emitProviderDisabledResponse(
@@ -5220,11 +5309,35 @@ export class Session {
} }
try { try {
const result = await renameCurrentBranch(cwd, branch); const result = await this.renameCurrentBranch(cwd, branch);
await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true }); await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true });
this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); 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 // Push a workspace_update immediately so the sidebar/header reflect
// the new branch name without waiting for the background git watcher. // the new branch name without waiting for the background git watcher.
await this.emitWorkspaceUpdateForCwd(cwd); await this.emitWorkspaceUpdateForCwd(cwd);
@@ -7201,11 +7314,14 @@ export class Session {
}, },
): Promise<void> { ): Promise<void> {
const workspaces = await this.workspaceRegistry.list(); const workspaces = await this.workspaceRegistry.list();
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd(
if (!workspaceId) { cwd,
workspaces,
);
if (workspaceIds.length === 0) {
return; return;
} }
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options); await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, options);
} }
private async handleFetchAgents( private async handleFetchAgents(
@@ -7452,7 +7568,7 @@ export class Session {
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>, request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> { ): Promise<void> {
try { try {
if (request.backing === "local") { if (request.source.kind === "directory") {
await this.handleWorkspaceCreateLocal(request); await this.handleWorkspaceCreateLocal(request);
return; return;
} }
@@ -7460,7 +7576,7 @@ export class Session {
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to create workspace"; const message = error instanceof Error ? error.message : "Failed to create workspace";
this.sessionLogger.error( this.sessionLogger.error(
{ err: error, backing: request.backing, requestId: request.requestId }, { err: error, sourceKind: request.source.kind, requestId: request.requestId },
"Failed to create workspace", "Failed to create workspace",
); );
this.emit({ this.emit({
@@ -7478,21 +7594,11 @@ export class Session {
private async handleWorkspaceCreateLocal( private async handleWorkspaceCreateLocal(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>, request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> { ): Promise<void> {
if (!request.cwd) { if (request.source.kind !== "directory") {
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",
},
});
return; return;
} }
const cwd = expandTilde(request.cwd); const cwd = expandTilde(request.source.path);
const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false); const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false);
if (!directoryExists) { if (!directoryExists) {
this.emit({ this.emit({
@@ -7536,12 +7642,54 @@ export class Session {
"Background snapshot refresh failed after workspace.create", "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( private async handleWorkspaceCreateWorktree(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>, request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> { ): Promise<void> {
if (!request.cwd && !request.projectId) { if (request.source.kind !== "worktree") {
return;
}
const source = request.source;
if (!source.cwd && !source.projectId) {
this.emit({ this.emit({
type: "workspace.create.response", type: "workspace.create.response",
payload: { payload: {
@@ -7556,18 +7704,22 @@ export class Session {
} }
const sourceCwd = await this.resolveWorktreeSourceCwd({ const sourceCwd = await this.resolveWorktreeSourceCwd({
cwd: request.cwd, cwd: source.cwd,
projectId: request.projectId, projectId: source.projectId,
}); });
const result = await this.createPaseoWorktreeWorkflow( const result = await this.createPaseoWorktreeWorkflow(
{ {
cwd: sourceCwd, cwd: sourceCwd,
projectId: request.projectId, projectId: source.projectId,
worktreeSlug: request.branch, worktreeSlug: source.worktreeSlug,
action: source.action,
refName: source.refName,
githubPrNumber: source.githubPrNumber,
firstAgentContext: request.firstAgentContext,
}, },
request.baseBranch source.baseBranch
? { resolveDefaultBranch: async () => request.baseBranch as string } ? { resolveDefaultBranch: async () => source.baseBranch as string }
: undefined, : undefined,
); );
@@ -4,6 +4,7 @@ import { homedir, tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { expect, test, vi } from "vitest"; import { expect, test, vi } from "vitest";
import { z } from "zod"; import { z } from "zod";
import { Session } from "./session.js"; import { Session } from "./session.js";
import type { SessionOptions } from "./session.js"; import type { SessionOptions } from "./session.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
@@ -22,6 +23,7 @@ import type {
AgentStreamEvent, AgentStreamEvent,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.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 { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import { import {
asSessionLogger, asSessionLogger,
@@ -128,6 +130,11 @@ interface SessionTestAccess {
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void; clearWorkspaceArchiving(workspaceIds: Iterable<string>): void;
emitWorkspaceUpdateForCwd(...args: unknown[]): Promise<unknown>; emitWorkspaceUpdateForCwd(...args: unknown[]): Promise<unknown>;
emitWorkspaceUpdatesForWorkspaceIds(...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; emit(message: unknown): void;
onMessage(message: unknown): void; onMessage(message: unknown): void;
paseoHome: string; paseoHome: string;
@@ -162,6 +169,7 @@ const AgentIdEntrySchema = z.object({ agent: z.object({ id: z.string() }) });
function makeAgent(input: { function makeAgent(input: {
id: string; id: string;
cwd: string; cwd: string;
workspaceId?: string;
status: AgentSnapshotPayload["status"]; status: AgentSnapshotPayload["status"];
updatedAt: string; updatedAt: string;
pendingPermissions?: number; pendingPermissions?: number;
@@ -174,6 +182,7 @@ function makeAgent(input: {
id: input.id, id: input.id,
provider: "codex", provider: "codex",
cwd: input.cwd, cwd: input.cwd,
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
model: null, model: null,
thinkingOptionId: null, thinkingOptionId: null,
effectiveThinkingOptionId: null, effectiveThinkingOptionId: null,
@@ -479,6 +488,11 @@ function createSessionForWorkspaceTests(
terminalManager?: TerminalManager | null; terminalManager?: TerminalManager | null;
projectRegistry?: SessionOptions["projectRegistry"]; projectRegistry?: SessionOptions["projectRegistry"];
workspaceRegistry?: SessionOptions["workspaceRegistry"]; workspaceRegistry?: SessionOptions["workspaceRegistry"];
renameCurrentBranch?: (
cwd: string,
newName: string,
) => Promise<{ previousBranch: string | null; currentBranch: string | null }>;
generateWorkspaceName?: () => Promise<GeneratedWorkspaceName | null>;
} = {}, } = {},
): TestSession { ): TestSession {
const logger = { const logger = {
@@ -593,6 +607,8 @@ function createSessionForWorkspaceTests(
dispose: () => {}, dispose: () => {},
}), }),
workspaceGitService: options.workspaceGitService ?? createNoopWorkspaceGitService(), workspaceGitService: options.workspaceGitService ?? createNoopWorkspaceGitService(),
renameCurrentBranch: options.renameCurrentBranch,
generateWorkspaceName: options.generateWorkspaceName,
daemonConfigStore: asDaemonConfigStore({ daemonConfigStore: asDaemonConfigStore({
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
onChange: () => () => {}, 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 () => { test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => {
const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" }); const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" });
const storedRecord = { 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 () => { test("buildWorkspaceDescriptorMap keeps a done workspace recent after its agents are archived", async () => {
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();
const project = createPersistedProjectRecord({ 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");
});
@@ -50,7 +50,10 @@ function createGitRepo(): string {
} }
async function createLocalWorkspace(cwd: string, title: string): Promise<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) { if (!result.workspace) {
throw new Error(result.error ?? "Failed to create local 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 repoDir = createGitRepo();
const keepResult = await ctx.client.createWorkspace({ const keepResult = await ctx.client.createWorkspace({
backing: "worktree", source: { kind: "worktree", cwd: repoDir, worktreeSlug: "keep-on-disk", baseBranch: "main" },
cwd: repoDir,
branch: "keep-on-disk",
baseBranch: "main",
}); });
const keepWorkspace = keepResult.workspace; const keepWorkspace = keepResult.workspace;
if (!keepWorkspace?.workspaceDirectory) { if (!keepWorkspace?.workspaceDirectory) {
@@ -185,10 +185,12 @@ test("archiving the last reference to a worktree honors deleteWorktreeFromDisk",
expect(existsSync(keepDir)).toBe(true); expect(existsSync(keepDir)).toBe(true);
const deleteResult = await ctx.client.createWorkspace({ const deleteResult = await ctx.client.createWorkspace({
backing: "worktree", source: {
cwd: repoDir, kind: "worktree",
branch: "delete-from-disk", cwd: repoDir,
baseBranch: "main", worktreeSlug: "delete-from-disk",
baseBranch: "main",
},
}); });
const deleteWorkspace = deleteResult.workspace; const deleteWorkspace = deleteResult.workspace;
if (!deleteWorkspace?.workspaceDirectory) { if (!deleteWorkspace?.workspaceDirectory) {
@@ -216,10 +218,12 @@ test("worktree archive targets the explicit workspaceId when a directory backs m
const repoDir = createGitRepo(); const repoDir = createGitRepo();
const worktreeResult = await ctx.client.createWorkspace({ const worktreeResult = await ctx.client.createWorkspace({
backing: "worktree", source: {
cwd: repoDir, kind: "worktree",
branch: "targeted-worktree", cwd: repoDir,
baseBranch: "main", worktreeSlug: "targeted-worktree",
baseBranch: "main",
},
}); });
const worktreeWorkspace = worktreeResult.workspace; const worktreeWorkspace = worktreeResult.workspace;
if (!worktreeWorkspace?.workspaceDirectory) { if (!worktreeWorkspace?.workspaceDirectory) {
@@ -260,10 +264,12 @@ test("deleteWorktreeFromDisk keeps the worktree when a sibling workspace still r
const repoDir = createGitRepo(); const repoDir = createGitRepo();
const worktreeResult = await ctx.client.createWorkspace({ const worktreeResult = await ctx.client.createWorkspace({
backing: "worktree", source: {
cwd: repoDir, kind: "worktree",
branch: "shared-worktree", cwd: repoDir,
baseBranch: "main", worktreeSlug: "shared-worktree",
baseBranch: "main",
},
}); });
const worktreeWorkspace = worktreeResult.workspace; const worktreeWorkspace = worktreeResult.workspace;
if (!worktreeWorkspace?.workspaceDirectory) { if (!worktreeWorkspace?.workspaceDirectory) {
@@ -21,31 +21,25 @@ test("workspace.create surfaces each early-reject error branch", async () => {
try { try {
await client.connect(); await client.connect();
// local backing without a cwd -> cwd_required // directory source pointed at a path that does not exist -> directory_not_found
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
const directoryNotFound = await client.createWorkspace({ const directoryNotFound = await client.createWorkspace({
backing: "local", source: { kind: "directory", path: missingDir },
cwd: missingDir,
}); });
expect(directoryNotFound.workspace).toBeNull(); expect(directoryNotFound.workspace).toBeNull();
expect(directoryNotFound.errorCode).toBe("directory_not_found"); expect(directoryNotFound.errorCode).toBe("directory_not_found");
expect(directoryNotFound.error).toContain(missingDir); expect(directoryNotFound.error).toContain(missingDir);
// worktree backing without a cwd or projectId -> source_required // worktree source without a cwd or projectId -> source_required
const sourceRequired = await client.createWorkspace({ backing: "worktree", branch: "feat" }); const sourceRequired = await client.createWorkspace({
source: { kind: "worktree", worktreeSlug: "feat" },
});
expect(sourceRequired.workspace).toBeNull(); expect(sourceRequired.workspace).toBeNull();
expect(sourceRequired.errorCode).toBe("source_required"); 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) // (surfaced via the generic catch, so it carries an error but no errorCode)
const projectNotFound = await client.createWorkspace({ const projectNotFound = await client.createWorkspace({
backing: "worktree", source: { kind: "worktree", projectId: "proj-does-not-exist", worktreeSlug: "feat" },
projectId: "proj-does-not-exist",
branch: "feat",
}); });
expect(projectNotFound.workspace).toBeNull(); expect(projectNotFound.workspace).toBeNull();
expect(projectNotFound.error).toContain("Project not found: proj-does-not-exist"); expect(projectNotFound.error).toContain("Project not found: proj-does-not-exist");
@@ -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);
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { createTestLogger } from "../test-utils/test-logger.js"; import { createTestLogger } from "../test-utils/test-logger.js";
import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./messages.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 { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; 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"]); 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([]);
});
});
@@ -67,14 +67,23 @@ export function resolveRegisteredWorkspaceIdForCwd(
cwd: string, cwd: string,
workspaces: PersistedWorkspaceRecord[], workspaces: PersistedWorkspaceRecord[],
): string | null { ): string | null {
const ids = resolveRegisteredWorkspaceIdsForCwd(cwd, workspaces);
return ids[0] ?? null;
}
export function resolveRegisteredWorkspaceIdsForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string[] {
const resolvedCwd = resolve(cwd); const resolvedCwd = resolve(cwd);
const exact = workspaces.find((workspace) => workspace.cwd === resolvedCwd); const exactMatches = workspaces.filter((workspace) => workspace.cwd === resolvedCwd);
if (exact) { if (exactMatches.length > 0) {
return exact.workspaceId; return exactMatches.map((workspace) => workspace.workspaceId);
} }
const userHome = homedir(); const userHome = homedir();
let bestMatch: PersistedWorkspaceRecord | null = null; let bestMatchLength = 0;
const prefixMatches: PersistedWorkspaceRecord[] = [];
for (const workspace of workspaces) { for (const workspace of workspaces) {
if (workspace.cwd === userHome) continue; if (workspace.cwd === userHome) continue;
if (workspace.archivedAt) continue; if (workspace.archivedAt) continue;
@@ -82,12 +91,16 @@ export function resolveRegisteredWorkspaceIdForCwd(
if (!resolvedCwd.startsWith(prefix)) { if (!resolvedCwd.startsWith(prefix)) {
continue; continue;
} }
if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) { if (workspace.cwd.length > bestMatchLength) {
bestMatch = workspace; 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 { export interface WorkspaceDirectoryDeps {
@@ -495,6 +508,13 @@ export class WorkspaceDirectory {
return resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); 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 // 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 // empty projects so the sidebar can render an empty project row with a
// "+ New workspace" affordance. // "+ New workspace" affordance.
@@ -37,6 +37,14 @@ const PersistedWorkspaceRecordSchema = z.object({
.nullable() .nullable()
.optional() .optional()
.transform((value) => value ?? null), .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(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
archivedAt: z.string().nullable(), archivedAt: z.string().nullable(),
@@ -236,6 +244,7 @@ export function createPersistedWorkspaceRecord(input: {
kind: PersistedWorkspaceKind; kind: PersistedWorkspaceKind;
displayName: string; displayName: string;
title?: string | null; title?: string | null;
branch?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
archivedAt?: string | null; archivedAt?: string | null;
@@ -243,6 +252,7 @@ export function createPersistedWorkspaceRecord(input: {
return PersistedWorkspaceRecordSchema.parse({ return PersistedWorkspaceRecordSchema.parse({
...input, ...input,
title: input.title ?? null, title: input.title ?? null,
branch: input.branch ?? null,
archivedAt: input.archivedAt ?? null, archivedAt: input.archivedAt ?? null,
}); });
} }
@@ -17,10 +17,12 @@ import {
} from "../utils/worktree-metadata.js"; } from "../utils/worktree-metadata.js";
const cleanupPaths: string[] = []; 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. Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.
No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens. 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: User context:
Fix the login flow`; 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>[] = []; const calls: StructuredAgentGenerationWithFallbackOptions<unknown>[] = [];
async function generateStructured<T>( async function generateStructured<T>(
@@ -53,10 +55,35 @@ function createStructuredGenerator(result: { branch: string }) {
} }
describe("generateBranchNameFromFirstAgentContext", () => { describe("generateBranchNameFromFirstAgentContext", () => {
test("calls the structured generator with first-agent prompt text", async () => { test("returns title and branch independently — branch is not a slug of the title", async () => {
const structured = createStructuredGenerator({ branch: "fix-login-flow" }); 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, agentManager: {} as AgentManager,
cwd: "/tmp/repo", cwd: "/tmp/repo",
firstAgentContext: { prompt: "Fix the login flow" }, firstAgentContext: { prompt: "Fix the login flow" },
@@ -64,7 +91,7 @@ describe("generateBranchNameFromFirstAgentContext", () => {
deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured },
}); });
expect(branch).toBe("fix-login-flow"); expect(result?.branch).toBe("fix-login-flow");
expect(structured.calls).toHaveLength(1); expect(structured.calls).toHaveLength(1);
const firstCall = structured.calls[0]; const firstCall = structured.calls[0];
if (!firstCall) { if (!firstCall) {
@@ -83,9 +110,12 @@ describe("generateBranchNameFromFirstAgentContext", () => {
}); });
test("uses attachment-only context", async () => { 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, agentManager: {} as AgentManager,
cwd: "/tmp/repo", cwd: "/tmp/repo",
firstAgentContext: { firstAgentContext: {
@@ -103,7 +133,7 @@ describe("generateBranchNameFromFirstAgentContext", () => {
deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured },
}); });
expect(branch).toBe("review-flaky-checkout"); expect(result?.branch).toBe("review-flaky-checkout");
const firstCall = structured.calls[0]; const firstCall = structured.calls[0];
if (!firstCall) { if (!firstCall) {
throw new Error("expected structured generation call"); throw new Error("expected structured generation call");
@@ -112,9 +142,12 @@ describe("generateBranchNameFromFirstAgentContext", () => {
}); });
test("uses the current selection as the final provider fallback", async () => { 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, agentManager: {} as AgentManager,
cwd: "/tmp/repo", cwd: "/tmp/repo",
providerSnapshotManager: { providerSnapshotManager: {
@@ -144,7 +177,7 @@ describe("generateBranchNameFromFirstAgentContext", () => {
deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured },
}); });
expect(branch).toBe("focused-branch"); expect(result?.branch).toBe("focused-branch");
const firstCall = structured.calls[0]; const firstCall = structured.calls[0];
if (!firstCall) { if (!firstCall) {
throw new Error("expected structured generation call"); 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) => { ])("keeps the pre-change prompt byte-identical when %s", async (_name, config) => {
const { prompt } = await generateBranchPromptWithConfig(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 () => { 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 () => ({ const renameCurrentBranch = vi.fn(async () => ({
previousBranch: "dazzling-yak", previousBranch: "dazzling-yak",
currentBranch: "Invalid Branch Name", currentBranch: "Invalid Branch Name",
@@ -237,7 +273,7 @@ describe("generateBranchNameFromFirstAgentContext", () => {
firstAgentContext, firstAgentContext,
logger: createLogger(), logger: createLogger(),
deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured },
}), }).then((r) => r?.branch ?? null),
getCurrentBranch: async () => "dazzling-yak", getCurrentBranch: async () => "dazzling-yak",
renameCurrentBranch, renameCurrentBranch,
}); });
@@ -255,7 +291,10 @@ async function generateBranchPromptWithConfig(config: unknown): Promise<{ prompt
writeConfig(repoRoot, config); writeConfig(repoRoot, config);
} }
const structured = createStructuredGenerator({ branch: "fix-login-flow" }); const structured = createStructuredGenerator({
title: "Fix login flow",
branch: "fix-login-flow",
});
await generateBranchNameFromFirstAgentContext({ await generateBranchNameFromFirstAgentContext({
agentManager: {} as AgentManager, agentManager: {} as AgentManager,
@@ -40,6 +40,7 @@ export interface GenerateBranchNameFromFirstAgentContextOptions {
} }
const BranchNameSchema = z.object({ const BranchNameSchema = z.object({
title: z.string().min(1).max(80),
branch: z.string().min(1).max(100), branch: z.string().min(1).max(100),
}); });
@@ -56,17 +57,24 @@ async function buildPrompt(
configKey: "branchName", configKey: "branchName",
before: [ before: [
"Generate a git branch name for a coding agent based on the user prompt and attachments.", "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.", "Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.",
"No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.", "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"), ].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}`, trailing: `User context:\n${seed}`,
}); });
} }
export interface GeneratedWorkspaceName {
title: string | null;
branch: string | null;
}
export async function generateBranchNameFromFirstAgentContext( export async function generateBranchNameFromFirstAgentContext(
options: GenerateBranchNameFromFirstAgentContextOptions, options: GenerateBranchNameFromFirstAgentContextOptions,
): Promise<string | null> { ): Promise<GeneratedWorkspaceName | null> {
const seed = buildAgentBranchNameSeed(options.firstAgentContext); const seed = buildAgentBranchNameSeed(options.firstAgentContext);
if (!seed) { if (!seed) {
return null; return null;
@@ -103,7 +111,10 @@ export async function generateBranchNameFromFirstAgentContext(
internal: true, internal: true,
}, },
}); });
return result.branch.trim() || null; return {
title: result.title.trim() || null,
branch: result.branch.trim() || null,
};
} catch (error) { } catch (error) {
const attempts = error instanceof StructuredAgentFallbackError ? error.attempts : undefined; const attempts = error instanceof StructuredAgentFallbackError ? error.attempts : undefined;
options.logger.error( options.logger.error(