Update workspace model ownership flows
This commit is contained in:
+3
-1
@@ -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 |
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user