mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Update workspace model ownership flows
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
101
packages/app/e2e/workspace-model-regressions.spec.ts
Normal file
101
packages/app/e2e/workspace-model-regressions.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 { 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user