Auto-title workspaces created by agents (#1887)

* Auto-name workspaces created by agents

* Require workspace auto-namer in websocket harnesses

* Auto-title checkout worktrees without branch renames
This commit is contained in:
Mohamed Boudra
2026-07-04 09:52:00 +02:00
committed by GitHub
parent 9c220c2451
commit e98d8c7a36
14 changed files with 1112 additions and 327 deletions

View File

@@ -47,7 +47,10 @@ interface CreateAgentCommandDependencies {
providerSnapshotManager: ProviderSnapshotManager;
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh directory workspace for a cwd and returns its id.
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
ensureWorkspaceForCreate?: (
cwd: string,
firstAgentContext?: FirstAgentContext,
) => Promise<string>;
}
export interface CreateAgentFromSessionInput {
@@ -253,7 +256,7 @@ async function resolveMcpCreateAgent(
? createdWorkspaceId
: (input.workspaceId ??
parentAgent?.workspaceId ??
(await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd)));
(await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt)));
const { modeId: resolvedMode, featureValues: resolvedFeatures } =
await dependencies.providerSnapshotManager.resolveCreateConfig({
@@ -300,11 +303,12 @@ async function resolveMcpCreateAgent(
async function ensureWorkspaceForMcpCreate(
dependencies: CreateAgentCommandDependencies,
cwd: string,
initialPrompt: string,
): Promise<string | undefined> {
if (!dependencies.ensureWorkspaceForCreate) {
return undefined;
}
return dependencies.ensureWorkspaceForCreate(cwd);
return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt });
}
async function sendInitialPrompt(
@@ -424,7 +428,7 @@ async function resolveMcpCwd(params: {
refName: worktree.refName,
action: worktree.action,
githubPrNumber: worktree.githubPrNumber,
...(params.initialPrompt ? { firstAgentContext: { prompt: params.initialPrompt } } : {}),
firstAgentContext: { prompt: params.initialPrompt },
runSetup: false,
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,

View File

@@ -39,13 +39,20 @@ import {
createPaseoWorktree as createPaseoWorktreeService,
type CreatePaseoWorktreeInput,
} from "../paseo-worktree-service.js";
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
import {
createPaseoWorktreeWorkflow,
type CreatePaseoWorktreeWorkflowFn,
} from "../worktree-session.js";
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
import { WorkspaceAutoName } from "../workspace-auto-name.js";
import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js";
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
import type { GitHubService } from "../../services/github-service.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
const REPO_CWD = resolvePath("/tmp/repo");
const TARGET_CWD = resolvePath("/tmp/target");
@@ -147,6 +154,34 @@ function agentsOf(response: {
return z.array(z.record(z.string(), z.unknown())).parse(response.structuredContent.agents);
}
async function waitForWorkspaceTitle(
workspaceRecords: Map<string, PersistedWorkspaceRecord>,
workspaceId: string,
title: string,
): Promise<void> {
await vi.waitFor(() => expect(workspaceRecords.get(workspaceId)?.title).toBe(title), {
timeout: 5_000,
});
}
async function waitForWorkspaceBranch(
workspaceRecords: Map<string, PersistedWorkspaceRecord>,
workspaceId: string,
branch: string,
): Promise<void> {
await vi.waitFor(() => expect(workspaceRecords.get(workspaceId)?.branch).toBe(branch), {
timeout: 5_000,
});
}
async function waitForUnexpectedWorkspaceNamingSideEffects(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 25));
}
async function removeTempDir(path: string): Promise<void> {
await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
type AgentManagerSpies = ReturnType<typeof buildAgentManagerSpies>;
type AgentStorageSpies = ReturnType<typeof buildAgentStorageSpies>;
@@ -616,40 +651,88 @@ function createPaseoWorktreeForMcpTest(options: {
paseoHome: string;
broadcasts: string[];
createdWorkspaceIds?: string[];
workspaceRecords?: Map<string, PersistedWorkspaceRecord>;
generateWorkspaceName?: () => Promise<GeneratedWorkspaceName | null>;
setupContinuations?: Array<"workspace" | "agent" | undefined>;
startedAgentSetupIds?: string[];
}): CreatePaseoWorktreeWorkflowFn {
const projects = new Map<string, PersistedProjectRecord>();
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const workspaces = options.workspaceRecords ?? new Map<string, PersistedWorkspaceRecord>();
const github = createGitHubServiceStub();
const workspaceGitService = new WorkspaceGitServiceImpl({
logger: createTestLogger(),
paseoHome: options.paseoHome,
deps: { github },
});
const workspaceRegistry = {
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
list: async () => Array.from(workspaces.values()),
upsert: async (record: PersistedWorkspaceRecord) => {
workspaces.set(record.workspaceId, record);
},
};
const workspaceAutoName = new WorkspaceAutoName({
agentManager: buildAgentManagerSpies() as unknown as AgentManager,
workspaceRegistry,
workspaceGitService,
providerSnapshotManager: createOpenCodeManager().manager,
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
gitMutation: createGitMutationService({
workspaceGitService,
github,
logger: createTestLogger(),
}),
emitWorkspaceUpdateForCwd: async (cwd) => {
const workspace = Array.from(workspaces.values()).find((record) => record.cwd === cwd);
options.broadcasts.push(z.string().parse(workspace?.workspaceId));
},
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
options.broadcasts.push(workspaceId);
},
logger: createTestLogger(),
generateWorkspaceName: options.generateWorkspaceName ?? (async () => null),
});
return async (input, serviceOptions) => {
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
const result = await createPaseoWorktreeService(input, {
github,
...(serviceOptions?.resolveDefaultBranch
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
: {}),
projectRegistry: {
get: async (projectId) => projects.get(projectId) ?? null,
upsert: async (record) => {
projects.set(record.projectId, record);
const result = await createPaseoWorktreeWorkflow(
{
paseoHome: options.paseoHome,
createPaseoWorktree: (workflowInput, workflowOptions) =>
createPaseoWorktreeService(workflowInput, {
github,
...(workflowOptions?.resolveDefaultBranch
? { resolveDefaultBranch: workflowOptions.resolveDefaultBranch }
: {}),
projectRegistry: {
get: async (projectId) => projects.get(projectId) ?? null,
upsert: async (record) => {
projects.set(record.projectId, record);
},
},
workspaceRegistry,
workspaceGitService,
}),
warmWorkspaceGitData: async () => {},
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
workspaceAutoName.scheduleForWorktree(autoNameInput),
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
options.broadcasts.push(workspaceId);
},
cacheWorkspaceSetupSnapshot: () => {},
emit: () => {},
sessionLogger: createTestLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
getDaemonTcpHost: null,
onScriptsChanged: null,
},
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
list: async () => Array.from(workspaces.values()),
upsert: async (record) => {
workspaces.set(record.workspaceId, record);
},
},
workspaceGitService,
});
input,
serviceOptions,
);
options.broadcasts.push(result.workspace.workspaceId);
options.createdWorkspaceIds?.push(result.workspace.workspaceId);
if (serviceOptions?.setupContinuation?.kind === "agent") {
@@ -1629,7 +1712,7 @@ describe("create_agent MCP tool", () => {
{ workspaceId: createdWorkspaceIds[0] },
);
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
@@ -1703,20 +1786,406 @@ describe("create_agent MCP tool", () => {
.trim();
expect(initialBranch).not.toBe("");
expect(initialBranch).not.toBe("main");
await new Promise((resolve) => setTimeout(resolve, 0));
await waitForUnexpectedWorkspaceNamingSideEffects();
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
it("does not auto-rename a create_agent checkout worktree from the initial prompt", async () => {
it("auto-titles and renames an agent-created branch-off worktree from the initial prompt", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-auto-title-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-auto-titled-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Agent title" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
workspaceRecords,
generateWorkspaceName: async () => ({
title: "Workspace Auto Title Flow",
branch: "workspace-auto-title-flow",
}),
}),
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
branchName: "feat/placeholder-auto-title",
baseBranch: "main",
}),
title: "Agent title",
provider: "codex/gpt-5.4",
initialPrompt: "Build a workspace auto title flow",
background: true,
});
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Workspace Auto Title Flow");
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const workspace = workspaceRecords.get(workspaceId);
const branchName = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
const metadata = readPaseoWorktreeMetadata(agentCwd);
expect(metadata).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: "feat/placeholder-auto-title",
},
});
expect(branchName).toBe("workspace-auto-title-flow");
expect(workspace).toMatchObject({
title: "Workspace Auto Title Flow",
branch: "workspace-auto-title-flow",
});
} finally {
await removeTempDir(tempDir);
}
});
it("keeps a manual workspace title when agent-created worktree naming finishes later", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-manual-title-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-manual-title-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Agent title" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
workspaceRecords,
generateWorkspaceName: async () => ({
title: "Generated Manual Race Title",
branch: "generated-manual-race-title",
}),
}),
workspaceRegistry: {
get: async (workspaceId) => workspaceRecords.get(workspaceId) ?? null,
upsert: async (record) => {
workspaceRecords.set(record.workspaceId, record);
},
},
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
broadcasts.push(...workspaceIds);
},
logger,
});
const createAgentTool = registeredTool(server, "create_agent");
await createAgentTool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
branchName: "feat/manual-title-placeholder",
baseBranch: "main",
}),
title: "Agent title",
provider: "codex/gpt-5.4",
initialPrompt: "Keep the manually renamed workspace title",
background: true,
});
const renameWorkspaceTool = registeredTool(server, "rename_workspace");
await renameWorkspaceTool.handler({
workspaceId: createdWorkspaceIds[0],
title: "Manual Workspace Title",
});
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceBranch(workspaceRecords, workspaceId, "generated-manual-race-title");
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const workspace = workspaceRecords.get(workspaceId);
const branchName = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
expect(branchName).toBe("generated-manual-race-title");
expect(workspace).toMatchObject({
title: "Manual Workspace Title",
branch: "generated-manual-race-title",
});
} finally {
await removeTempDir(tempDir);
}
});
it("uses create_agent title for the agent while still auto-titling the worktree workspace", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-title-workspace-title-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-explicit-title-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Explicit Agent Title" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
workspaceRecords,
generateWorkspaceName: async () => ({
title: "Generated Workspace Title",
branch: "generated-workspace-title",
}),
}),
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
branchName: "feat/agent-title-placeholder",
baseBranch: "main",
}),
title: "Explicit Agent Title",
provider: "codex/gpt-5.4",
initialPrompt: "Generate the workspace title anyway",
background: true,
});
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Generated Workspace Title");
const workspace = workspaceRecords.get(workspaceId);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
title: "Explicit Agent Title",
}),
undefined,
{ workspaceId },
);
expect(workspace).toMatchObject({
title: "Generated Workspace Title",
branch: "generated-workspace-title",
});
} finally {
await removeTempDir(tempDir);
}
});
it("auto-titles an agent-created directory workspace from the initial prompt", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-directory-auto-title-"));
const workspaceDir = join(tempDir, "workspace");
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
const broadcasts: string[] = [];
const workspaceGitService = new WorkspaceGitServiceImpl({
logger: createTestLogger(),
paseoHome: join(tempDir, ".paseo"),
deps: { github: createGitHubServiceStub() },
});
const workspaceAutoName = new WorkspaceAutoName({
agentManager,
workspaceRegistry: {
get: async (workspaceId) => workspaceRecords.get(workspaceId) ?? null,
upsert: async (record) => {
workspaceRecords.set(record.workspaceId, record);
},
},
workspaceGitService,
providerSnapshotManager: createOpenCodeManager().manager,
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
gitMutation: createGitMutationService({
workspaceGitService,
github: createGitHubServiceStub(),
logger: createTestLogger(),
}),
emitWorkspaceUpdateForCwd: async () => {},
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
broadcasts.push(workspaceId);
},
logger: createTestLogger(),
generateWorkspaceName: async () => ({
title: "Directory Workspace Title",
branch: "directory-workspace-title",
}),
});
try {
await mkdir(workspaceDir, { recursive: true });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-directory-auto-title",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Directory agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate: async (cwd, firstAgentContext) => {
const workspace = createPersistedWorkspaceRecord({
workspaceId: "workspace-directory-auto-title",
projectId: "project-directory-auto-title",
cwd,
kind: "directory",
displayName: "workspace",
title: firstAgentContext?.prompt ?? null,
createdAt: "2026-07-03T00:00:00.000Z",
updatedAt: "2026-07-03T00:00:00.000Z",
});
workspaceRecords.set(workspace.workspaceId, workspace);
if (firstAgentContext) {
workspaceAutoName.scheduleForDirectory({
workspaceId: workspace.workspaceId,
cwd: workspace.cwd,
firstAgentContext,
});
}
return workspace.workspaceId;
},
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(workspaceDir),
title: "Directory agent",
provider: "codex/gpt-5.4",
initialPrompt: "Name a directory workspace from the prompt",
background: true,
});
await waitForWorkspaceTitle(
workspaceRecords,
"workspace-directory-auto-title",
"Directory Workspace Title",
);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: workspaceDir,
title: "Directory agent",
}),
undefined,
{ workspaceId: "workspace-directory-auto-title" },
);
expect(workspaceRecords.get("workspace-directory-auto-title")).toMatchObject({
title: "Directory Workspace Title",
branch: null,
});
expect(broadcasts).toEqual(["workspace-directory-auto-title"]);
} finally {
await removeTempDir(tempDir);
}
});
it("auto-titles without renaming a create_agent checkout worktree from the initial prompt", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-checkout-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
let generateCalls = 0;
const workspaceGitService = {
getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run");
@@ -1761,7 +2230,19 @@ describe("create_agent MCP tool", () => {
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
workspaceRecords,
generateWorkspaceName: async () => {
generateCalls += 1;
return {
title: "Generated Checkout Workspace Title",
branch: "generated-checkout-workspace-title",
};
},
}),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
@@ -1781,16 +2262,30 @@ describe("create_agent MCP tool", () => {
});
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceTitle(
workspaceRecords,
workspaceId,
"Generated Checkout Workspace Title",
);
expect(
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
.toString()
.trim(),
).toBe("existing-feature");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceRecords.get(workspaceId)).toMatchObject({
title: "Generated Checkout Workspace Title",
branch: "existing-feature",
});
expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({
version: 1,
baseRefName: "existing-feature",
});
expect(generateCalls).toBe(1);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
expect(broadcasts).toEqual([workspaceId, workspaceId]);
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
@@ -1888,7 +2383,7 @@ describe("create_agent MCP tool", () => {
undefined,
{ workspaceId: "ws-pr-123" },
);
await new Promise((resolve) => setTimeout(resolve, 0));
await waitForUnexpectedWorkspaceNamingSideEffects();
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
});
@@ -1960,7 +2455,7 @@ describe("create_agent MCP tool", () => {
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
@@ -2056,7 +2551,7 @@ describe("create_agent MCP tool", () => {
"ws-archive-tool-worktree",
]);
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
@@ -2143,7 +2638,7 @@ describe("create_agent MCP tool", () => {
expect(archivedWorkspaceIds).toContain("ws-mcp-B");
await expect(access(worktreePath)).rejects.toThrow();
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
@@ -2221,7 +2716,7 @@ describe("create_agent MCP tool", () => {
access(z.string().parse(created.structuredContent.worktreePath)),
).rejects.toThrow();
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});

View File

@@ -29,6 +29,7 @@ import {
import { WaitForAgentTracker } from "../wait-for-agent-tracker.js";
import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js";
import type { FirstAgentContext } from "../../messages.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js";
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js";
@@ -105,7 +106,10 @@ export interface PaseoToolHostDependencies {
clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"];
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh directory workspace for a cwd and returns its id.
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
ensureWorkspaceForCreate?: (
cwd: string,
firstAgentContext?: FirstAgentContext,
) => Promise<string>;
browserToolsBroker?: BrowserToolsBroker | null;
paseoHome?: string;
worktreesRoot?: string;
@@ -1181,7 +1185,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
async function resolveCreateAgentToolArgs(args: unknown): Promise<ResolvedCreateAgentToolArgs> {
if (callerAgentId) {
const parsed = agentToAgentCreateAgentArgsSchema.parse(args);
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace);
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, {
prompt: parsed.initialPrompt,
});
return {
kind: "agent-scoped",
parsedArgs: parsed,
@@ -1195,7 +1201,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
if (parsedArgs.relationship.kind === "subagent") {
throw new Error("relationship subagent requires an agent-scoped tool session");
}
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsedArgs.workspace);
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsedArgs.workspace, {
prompt: parsedArgs.initialPrompt,
});
return {
kind: "top-level",
parsedArgs,
@@ -1309,6 +1317,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
async function resolveCreateAgentWorkspace(
workspace: AgentToAgentCreateAgentArgs["workspace"] | TopLevelCreateAgentArgs["workspace"],
firstAgentContext: FirstAgentContext | undefined,
): Promise<{
cwd: string | undefined;
workspaceId: string | undefined;
@@ -1360,7 +1369,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}
return {
cwd,
workspaceId: await options.ensureWorkspaceForCreate(cwd),
workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext),
worktree: undefined,
};
}

View File

@@ -139,7 +139,7 @@ import type { PushNotificationSender } from "./push/notifications.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import type { FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
@@ -162,6 +162,10 @@ import {
type DaemonAuthConfig,
} from "./auth.js";
import { createWebUiMiddleware } from "./web-ui.js";
import { WorkspaceAutoName } from "./workspace-auto-name.js";
import { createGitMutationService } from "./session/git-mutation/git-mutation-service.js";
import { workspaceIdsOnCheckout } from "./workspace-directory.js";
import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js";
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]";
@@ -854,11 +858,21 @@ export async function createPaseoDaemon(
const findWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
return resolveWorkspaceIdForPath(cwd, await workspaceRegistry.list());
};
const ensureWorkspaceForCreateExternal = async (cwd: string): Promise<string> => {
const ensureWorkspaceForCreateExternal = async (
cwd: string,
firstAgentContext?: FirstAgentContext,
): Promise<string> => {
const workspace = await createLocalCheckoutWorkspace(
{ cwd },
{ cwd, title: resolveFirstAgentPromptTitle(firstAgentContext) },
{ projectRegistry, workspaceRegistry, workspaceGitService },
);
if (firstAgentContext) {
workspaceAutoName.scheduleForDirectory({
workspaceId: workspace.workspaceId,
cwd: workspace.cwd,
firstAgentContext,
});
}
return workspace.workspaceId;
};
const listActiveWorkspacesExternal = async (): Promise<ActiveWorkspaceRef[]> => {
@@ -891,9 +905,30 @@ export async function createPaseoDaemon(
),
);
};
const emitWorkspaceUpdateForCwdExternal = async (cwd: string) => {
const workspaceIds = workspaceIdsOnCheckout(await workspaceRegistry.list(), cwd);
await emitWorkspaceUpdatesExternal(workspaceIds);
};
const emitExternalSessionMessage = (message: SessionOutboundMessage) => {
wsServer?.broadcast(wrapSessionMessage(message));
};
const workspaceAutoName = new WorkspaceAutoName({
agentManager,
workspaceRegistry,
workspaceGitService,
providerSnapshotManager,
readDaemonConfig: () => ({ metadataGeneration: daemonConfigStore.get().metadataGeneration }),
gitMutation: createGitMutationService({
workspaceGitService,
github,
logger,
}),
emitWorkspaceUpdateForCwd: emitWorkspaceUpdateForCwdExternal,
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
await emitWorkspaceUpdatesExternal([workspaceId]);
},
logger,
});
setupAutoArchiveOnMerge({
paseoHome: config.paseoHome,
@@ -941,6 +976,8 @@ export async function createPaseoDaemon(
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
);
},
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
workspaceAutoName.scheduleForWorktree(autoNameInput),
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
await emitWorkspaceUpdatesExternal([workspaceId]);
},
@@ -1208,6 +1245,7 @@ export async function createPaseoDaemon(
daemonConfigStore,
mcpBaseUrl,
{ allowedOrigins, hostnames: configuredHostnames },
workspaceAutoName,
config.auth,
speechService,
terminalManager,

View File

@@ -192,16 +192,12 @@ import {
} from "./workspace-directory.js";
import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js";
import {
attemptFirstAgentBranchAutoName,
createLocalCheckoutWorkspace,
createPaseoWorktree,
type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import {
generateBranchNameFromFirstAgentContext,
type GeneratedWorkspaceName,
} from "./worktree-branch-name-generator.js";
import { WorkspaceAutoName } from "./workspace-auto-name.js";
import {
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
createPaseoWorktreeWorkflow as createWorktreeWorkflow,
@@ -427,10 +423,8 @@ export interface SessionOptions {
// 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;
workspaceAutoName: WorkspaceAutoName;
daemonConfigStore: DaemonConfigStore;
mcpBaseUrl?: string | null;
stt: Resolvable<SpeechToTextProvider | null>;
@@ -549,8 +543,8 @@ export class Session {
private readonly filesystem: SessionFileSystem;
private readonly github: GitHubService;
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext;
private readonly workspaceGitService: WorkspaceGitService;
private readonly workspaceAutoName: WorkspaceAutoName;
private readonly gitMutation: GitMutationService;
private readonly workspaceProvisioning: WorkspaceProvisioningService;
private readonly daemonConfigStore: DaemonConfigStore;
@@ -617,8 +611,8 @@ export class Session {
checkoutDiffManager,
github,
renameCurrentBranch,
generateWorkspaceName,
workspaceGitService,
workspaceAutoName,
daemonConfigStore,
stt,
sttLanguage,
@@ -675,13 +669,13 @@ export class Session {
this.filesystem = filesystem ?? nodeSessionFileSystem;
this.github = github ?? createGitHubService();
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
this.generateWorkspaceName = generateWorkspaceName ?? generateBranchNameFromFirstAgentContext;
this.workspaceGitService = workspaceGitService;
this.gitMutation = createGitMutationService({
workspaceGitService: this.workspaceGitService,
github: this.github,
logger: this.sessionLogger,
});
this.workspaceAutoName = workspaceAutoName;
this.workspaceProvisioning = createWorkspaceProvisioningService({
workspaceRegistry: this.workspaceRegistry,
projectRegistry: this.projectRegistry,
@@ -2459,11 +2453,14 @@ export class Session {
createdAgentId = snapshot.id;
await this.agentUpdates.forwardLiveAgent(snapshot);
if (createdDirectoryWorkspaceForAgent && trimmedPrompt) {
await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({
workspaceId,
cwd: createAgentConfig.cwd,
firstAgentContext,
});
this.workspaceAutoName.scheduleForDirectory(
{
workspaceId,
cwd: createAgentConfig.cwd,
firstAgentContext,
},
{ currentSelection: this.getFocusedAgentSelectionForCwd(createAgentConfig.cwd) },
);
}
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
autoArchive,
@@ -2844,140 +2841,6 @@ export class Session {
);
}
private scheduleAutoNameWorkspaceBranchForFirstAgent(input: {
workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext;
}): void {
this.scheduleWorkspaceNaming(() => this.maybeAutoNameWorkspaceBranchForFirstAgent(input), {
cwd: input.workspace.cwd,
message: "Failed to auto-name worktree branch",
});
}
private async maybeAutoNameWorkspaceBranchForFirstAgent(input: {
workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext;
}): Promise<void> {
// Capture the generated title from the generator callback so we can write
// title := generated title after the branch rename completes.
let generatedTitle: string | null = null;
const result = await attemptFirstAgentBranchAutoName({
cwd: input.workspace.cwd,
firstAgentContext: input.firstAgentContext,
generateBranchNameFromContext: ({ cwd, firstAgentContext }) => {
return this.generateWorkspaceName({
agentManager: this.agentManager,
cwd,
workspaceGitService: this.workspaceGitService,
providerSnapshotManager: this.providerSnapshotManager,
daemonConfig: this.readStructuredGenerationDaemonConfig(),
currentSelection: this.getFocusedAgentSelectionForCwd(cwd),
firstAgentContext,
logger: this.sessionLogger,
}).then((r) => {
generatedTitle = r?.title ?? null;
return r?.branch ?? null;
});
},
});
if (!result.renamed || !generatedTitle) {
return;
}
// K4: re-read from the registry before writing so any concurrent upsert
// that happened between workspace creation and this async path is not clobbered.
// The first-agent rename renamed the git branch too, so persist the new branch
// alongside the title — both are this path's own fields.
await this.applyGeneratedWorkspaceTitle(input.workspace.workspaceId, {
title: generatedTitle,
branch: result.branchName,
promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext),
});
await this.gitMutation.notifyGitMutation(input.workspace.cwd, "rename-branch");
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
}
// Generated names may replace the prompt title set at creation, but not a user
// rename that landed while the async generator was running.
private async applyGeneratedWorkspaceTitle(
workspaceId: string,
input: { title: string; branch?: string | null; promptTitle?: string | null },
): Promise<void> {
const current = await this.workspaceRegistry.get(workspaceId);
if (!current) {
return;
}
let title = current.title;
if (!title || (input.promptTitle && title === input.promptTitle)) {
title = input.title;
}
await this.workspaceRegistry.upsert({
...current,
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. 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,
promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext),
});
await this.emitWorkspaceUpdateForWorkspaceId(input.workspaceId);
}
private async scheduleAutoNameLocalWorkspaceTitleForFirstAgent(input: {
workspaceId: string;
cwd: string;
firstAgentContext: FirstAgentContext;
}): Promise<void> {
const workspaceId = input.workspaceId;
this.scheduleWorkspaceNaming(
() =>
this.maybeAutoNameDirectoryWorkspaceTitle({
workspaceId,
cwd: input.cwd,
firstAgentContext: input.firstAgentContext,
}),
{ cwd: input.cwd, message: "Failed to auto-name local workspace title" },
);
}
private isPathWithinRoot(rootPath: string, candidatePath: string): boolean {
const resolvedRoot = resolve(rootPath);
const resolvedCandidate = resolve(candidatePath);
@@ -4567,31 +4430,17 @@ export class Session {
});
if (request.firstAgentContext) {
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" },
this.workspaceAutoName.scheduleForDirectory(
{
workspaceId: workspace.workspaceId,
cwd: workspace.cwd,
firstAgentContext,
},
{ currentSelection: this.getFocusedAgentSelectionForCwd(workspace.cwd) },
);
}
}
// Schedules a background workspace-naming write off the request path. The
// setTimeout(0) keeps the LLM call off the hot path.
private scheduleWorkspaceNaming(
run: () => Promise<void>,
context: { cwd: string; message: string },
): void {
setTimeout(() => {
void run().catch((error) => {
this.sessionLogger.warn({ err: error, cwd: context.cwd }, context.message);
});
}, 0);
}
private async handleWorkspaceCreateWorktree(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> {
@@ -4896,7 +4745,9 @@ export class Session {
this.createPaseoWorktree(workflowInput, serviceOptions),
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput),
this.workspaceAutoName.scheduleForWorktree(autoNameInput, {
currentSelection: this.getFocusedAgentSelectionForCwd(autoNameInput.workspace.cwd),
}),
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {

View File

@@ -13,6 +13,7 @@ import path from "node:path";
import { afterEach, expect, test, vi } from "vitest";
import { z } from "zod";
import { createTestLogger } from "../test-utils/test-logger.js";
import { Session } from "./session.js";
import type { SessionOptions } from "./session.js";
import type { AgentUpdatesService } from "./session/agent-updates/agent-updates-service.js";
@@ -32,9 +33,15 @@ import type {
AgentStreamEvent,
} from "./agent/agent-sdk-types.js";
import { createWorktree, UnknownBranchError } from "../utils/worktree.js";
import {
readPaseoWorktreeMetadata,
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
writePaseoWorktreeMetadata,
} from "../utils/worktree-metadata.js";
import { WorktreeRequestError, toWorktreeRequestError } from "./worktree-errors.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
import { WorkspaceAutoName } from "./workspace-auto-name.js";
import type { GitHubService } from "../services/github-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import {
@@ -146,10 +153,6 @@ interface SessionTestAccess {
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void;
emitWorkspaceUpdateForCwd(...args: unknown[]): Promise<unknown>;
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
applyGeneratedWorkspaceTitle(
workspaceId: string,
input: { title: string; branch?: string | null; promptTitle?: string | null },
): Promise<void>;
emit(message: unknown): void;
onMessage(message: unknown): void;
paseoHome: string;
@@ -545,6 +548,48 @@ function createSessionForWorkspaceTests(
warn: vi.fn(),
error: vi.fn(),
};
const agentManager = asAgentManager({
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
archiveAgent: async () => ({ archivedAt: new Date().toISOString() }),
archiveSnapshot: async () => ({}),
unarchiveSnapshot: async () => true,
clearAgentAttention: async () => {},
notifyAgentState: () => {},
});
const workspaceRegistry = options.workspaceRegistry ?? {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [
createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
],
get: async (workspaceId: string) =>
workspaceId === "ws-repo-running"
? createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
})
: null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
};
const workspaceGitService = options.workspaceGitService ?? createNoopWorkspaceGitService();
const providerSnapshotManager = createProviderSnapshotManagerStub().manager;
const session = asTestSession(
new Session({
@@ -556,16 +601,7 @@ function createSessionForWorkspaceTests(
pushTokenStore: asPushTokenStore(),
paseoHome: options.paseoHome ?? "/tmp/paseo-test",
worktreesRoot: options.worktreesRoot,
agentManager: asAgentManager({
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
archiveAgent: async () => ({ archivedAt: new Date().toISOString() }),
archiveSnapshot: async () => ({}),
unarchiveSnapshot: async () => true,
clearAgentAttention: async () => {},
notifyAgentState: () => {},
}),
agentManager,
agentStorage: asAgentStorage({
list: async () => [
createPersistedWorkspaceRecord({
@@ -601,36 +637,7 @@ function createSessionForWorkspaceTests(
archive: async () => {},
remove: async () => {},
},
workspaceRegistry: options.workspaceRegistry ?? {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [
createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
],
get: async (workspaceId: string) =>
workspaceId === "ws-repo-running"
? createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
})
: null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
},
workspaceRegistry,
filesystem: { isDirectory: async () => true },
chatService: asChatService(),
scheduleService: asScheduleService(),
@@ -651,9 +658,20 @@ function createSessionForWorkspaceTests(
dispose: () => {},
}),
github: options.github,
workspaceGitService: options.workspaceGitService ?? createNoopWorkspaceGitService(),
workspaceGitService,
workspaceAutoName: new WorkspaceAutoName({
agentManager,
workspaceRegistry,
workspaceGitService,
providerSnapshotManager,
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
gitMutation: { notifyGitMutation: async () => {} },
emitWorkspaceUpdateForCwd: async () => {},
emitWorkspaceUpdateForWorkspaceId: async () => {},
logger: asSessionLogger(logger),
generateWorkspaceName: options.generateWorkspaceName,
}),
renameCurrentBranch: options.renameCurrentBranch,
generateWorkspaceName: options.generateWorkspaceName,
daemonConfigStore: asDaemonConfigStore({
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
onChange: () => () => {},
@@ -661,7 +679,7 @@ function createSessionForWorkspaceTests(
mcpBaseUrl: null,
stt: null,
tts: null,
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
providerSnapshotManager,
terminalManager: options.terminalManager ?? null,
}),
);
@@ -7269,57 +7287,62 @@ test("failed local create_agent_request does not schedule workspace title genera
}
});
// 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 branch metadata 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",
test("workspace auto-name keeps a manual title written before the scheduled title lands", async () => {
vi.useFakeTimers();
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-manual-title",
projectId: "proj-manual-title",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
title: "Fix login bug",
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 stored = new Map([[workspace.workspaceId, workspace]]);
const emittedWorkspaceIds: string[] = [];
const workspaceAutoName = new WorkspaceAutoName({
agentManager: asAgentManager({}),
workspaceRegistry: {
get: async (workspaceId) => stored.get(workspaceId) ?? null,
upsert: async (record) => {
stored.set(record.workspaceId, record);
},
},
workspaceGitService: createNoopWorkspaceGitService(),
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
gitMutation: { notifyGitMutation: async () => {} },
emitWorkspaceUpdateForCwd: async () => {},
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
emittedWorkspaceIds.push(workspaceId);
},
logger: asSessionLogger(createTestLogger()),
generateWorkspaceName: async () => ({ title: "Generated login fix", branch: null }),
});
const saved = stored.get("ws-worktree-1");
// The branch-shaped display name stays branch-shaped.
expect(saved?.displayName).toBe("task-branch");
// 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");
try {
workspaceAutoName.scheduleForDirectory({
workspaceId: workspace.workspaceId,
cwd: workspace.cwd,
firstAgentContext: { prompt: "Fix login bug" },
});
stored.set(workspace.workspaceId, {
...workspace,
title: "User rename",
updatedAt: "2026-03-01T12:01:00.000Z",
});
await vi.runAllTimersAsync();
expect(stored.get(workspace.workspaceId)?.title).toBe("User rename");
expect(emittedWorkspaceIds).toEqual([workspace.workspaceId]);
} finally {
vi.useRealTimers();
}
});
test("applyGeneratedWorkspaceTitle replaces the unchanged prompt title", async () => {
const session = createSessionForWorkspaceTests();
test("workspace auto-name replaces the unchanged prompt title", async () => {
vi.useFakeTimers();
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-prompt-title",
projectId: "proj-prompt-title",
@@ -7331,31 +7354,131 @@ test("applyGeneratedWorkspaceTitle replaces the unchanged prompt title", async (
updatedAt: "2026-03-01T12:00:00.000Z",
});
const stored = new Map([[workspace.workspaceId, workspace]]);
session.workspaceRegistry.get = async (id: string) => stored.get(id) ?? null;
session.workspaceRegistry.upsert = async (record: unknown) => {
const parsed = record as typeof workspace;
stored.set(parsed.workspaceId, parsed);
};
await session.applyGeneratedWorkspaceTitle(workspace.workspaceId, {
title: "Generated login fix",
promptTitle: "Fix login bug",
const workspaceAutoName = new WorkspaceAutoName({
agentManager: asAgentManager({}),
workspaceRegistry: {
get: async (workspaceId) => stored.get(workspaceId) ?? null,
upsert: async (record) => {
stored.set(record.workspaceId, record);
},
},
workspaceGitService: createNoopWorkspaceGitService(),
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
gitMutation: { notifyGitMutation: async () => {} },
emitWorkspaceUpdateForCwd: async () => {},
emitWorkspaceUpdateForWorkspaceId: async () => {},
logger: asSessionLogger(createTestLogger()),
generateWorkspaceName: async () => ({ title: "Generated login fix", branch: null }),
});
expect(stored.get(workspace.workspaceId)?.title).toBe("Generated login fix");
try {
workspaceAutoName.scheduleForDirectory({
workspaceId: workspace.workspaceId,
cwd: workspace.cwd,
firstAgentContext: { prompt: "Fix login bug" },
});
await vi.runAllTimersAsync();
stored.set(workspace.workspaceId, {
...workspace,
title: "User rename",
updatedAt: "2026-03-01T12:01:00.000Z",
expect(stored.get(workspace.workspaceId)?.title).toBe("Generated login fix");
} finally {
vi.useRealTimers();
}
});
test("workspace auto-name applies title once when branch auto-name is rejected", async () => {
vi.useFakeTimers();
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-auto-name-rejected-")));
const repoDir = path.join(tempDir, "repo");
mkdirSync(repoDir);
execFileSync("git", ["init", 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", ["config", "commit.gpgsign", "false"], { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "placeholder-branch"], { cwd: repoDir, stdio: "pipe" });
writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" });
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
placeholderBranchName: "placeholder-branch",
});
await session.applyGeneratedWorkspaceTitle(workspace.workspaceId, {
title: "Generated login fix",
promptTitle: "Fix login bug",
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-rejected-branch-title",
projectId: "proj-rejected-branch-title",
cwd: repoDir,
kind: "worktree",
displayName: "Fix checkout title",
title: "Fix checkout title",
branch: "placeholder-branch",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const stored = new Map([[workspace.workspaceId, workspace]]);
let generateCalls = 0;
const gitMutations: string[] = [];
const emittedCwds: string[] = [];
const workspaceAutoName = new WorkspaceAutoName({
agentManager: asAgentManager({}),
workspaceRegistry: {
get: async (workspaceId) => stored.get(workspaceId) ?? null,
upsert: async (record) => {
stored.set(record.workspaceId, record);
},
},
workspaceGitService: createNoopWorkspaceGitService(),
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
gitMutation: {
notifyGitMutation: async (_cwd, reason) => {
gitMutations.push(reason);
},
},
emitWorkspaceUpdateForCwd: async (cwd) => {
emittedCwds.push(cwd);
},
emitWorkspaceUpdateForWorkspaceId: async () => {},
logger: asSessionLogger(createTestLogger()),
generateWorkspaceName: async () => {
generateCalls += 1;
return { title: "Generated Invalid Branch Title", branch: "Invalid Branch Name" };
},
});
expect(stored.get(workspace.workspaceId)?.title).toBe("User rename");
try {
workspaceAutoName.scheduleForWorktree({
workspace,
firstAgentContext: { prompt: "Fix checkout title" },
});
await vi.runAllTimersAsync();
expect(generateCalls).toBe(1);
expect(stored.get(workspace.workspaceId)).toMatchObject({
title: "Generated Invalid Branch Title",
branch: "placeholder-branch",
});
expect(
execFileSync("git", ["branch", "--show-current"], { cwd: repoDir, stdio: "pipe" })
.toString()
.trim(),
).toBe("placeholder-branch");
expect(readPaseoWorktreeMetadata(repoDir)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: "placeholder-branch",
},
});
expect(gitMutations).toEqual([]);
expect(emittedCwds).toEqual([repoDir]);
} finally {
vi.useRealTimers();
rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
// Phase 7: branch is a git fact derived per-descriptor from each workspace's own

View File

@@ -25,6 +25,7 @@ import { createStub } from "./test-utils/class-mocks.js";
import { DaemonClient } from "./test-utils/daemon-client.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
interface BrowserToolsDaemonHarness {
broker: BrowserToolsBroker;
@@ -70,6 +71,13 @@ function browserHostCapabilities(
};
}
function createWorkspaceAutoNameStub(): WorkspaceAutoName {
return createStub<WorkspaceAutoName>({
scheduleForWorktree: () => {},
scheduleForDirectory: () => {},
});
}
describe("WebSocketServer browser tools wiring", () => {
it("registers capable clients and dispatches broker requests over the real WebSocket path", async () => {
const harness = await startBrowserToolsDaemonHarness();
@@ -292,6 +300,7 @@ function createVoiceAssistantWebSocketServer(params: {
createStub<DaemonConfigStore>(daemonConfigStore),
null,
{ allowedOrigins: new Set(["*"]) },
createWorkspaceAutoNameStub(),
undefined,
undefined,
undefined,

View File

@@ -12,6 +12,7 @@ import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
const wsModuleMock = vi.hoisted(() => {
class MockWebSocketServer {
@@ -65,6 +66,13 @@ function createLogger() {
return logger;
}
function createWorkspaceAutoNameStub(): WorkspaceAutoName {
return createStub<WorkspaceAutoName>({
scheduleForWorktree: () => {},
scheduleForDirectory: () => {},
});
}
class RecordingPushNotificationSender implements PushNotificationSender {
readonly sent: PushPayload[] = [];
@@ -106,6 +114,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
createStub<DaemonConfigStore>(daemonConfigStore),
null,
{ allowedOrigins: new Set() },
createWorkspaceAutoNameStub(),
undefined,
undefined,
undefined,

View File

@@ -9,6 +9,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import {
@@ -215,6 +216,13 @@ function createLogger() {
return logger;
}
function createWorkspaceAutoNameStub(): WorkspaceAutoName {
return createStub<WorkspaceAutoName>({
scheduleForWorktree: () => {},
scheduleForDirectory: () => {},
});
}
function createServer(options?: {
speechReadiness?: SpeechReadinessSnapshot | null;
logger?: ReturnType<typeof createLogger>;
@@ -246,6 +254,7 @@ function createServer(options?: {
createStub<DaemonConfigStore>(daemonConfigStore),
null,
{ allowedOrigins: new Set() },
createWorkspaceAutoNameStub(),
undefined,
speechReadiness
? {

View File

@@ -18,6 +18,7 @@ import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-re
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
const wsModuleMock = vi.hoisted(() => {
class MockWebSocketServer {
@@ -68,6 +69,13 @@ function createLogger() {
return logger;
}
function createWorkspaceAutoNameStub(): WorkspaceAutoName {
return createStub<WorkspaceAutoName>({
scheduleForWorktree: () => {},
scheduleForDirectory: () => {},
});
}
function createTerminalManager() {
let listener: TerminalActivityListener | null = null;
@@ -139,6 +147,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work
createStub<DaemonConfigStore>(daemonConfigStore),
null,
{ allowedOrigins: new Set() },
createWorkspaceAutoNameStub(),
undefined,
undefined,
terminalManager,

View File

@@ -34,6 +34,7 @@ import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
import { PushTokenStore } from "./push/token-store.js";
import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js";
@@ -424,6 +425,7 @@ export class VoiceAssistantWebSocketServer {
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly github: GitHubService;
private readonly workspaceGitService: WorkspaceGitService;
private readonly workspaceAutoName: WorkspaceAutoName;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
@@ -473,6 +475,7 @@ export class VoiceAssistantWebSocketServer {
daemonConfigStore: DaemonConfigStore,
mcpBaseUrl: string | null,
wsConfig: WebSocketServerConfig,
workspaceAutoName: WorkspaceAutoName,
auth?: DaemonAuthConfig,
speech?: SpeechService | null,
terminalManager?: TerminalManager | null,
@@ -540,6 +543,7 @@ export class VoiceAssistantWebSocketServer {
this.checkoutDiffManager = requiredServices.checkoutDiffManager;
this.github = github ?? createGitHubService();
this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
this.workspaceAutoName = workspaceAutoName;
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
@@ -1019,6 +1023,7 @@ export class VoiceAssistantWebSocketServer {
checkoutDiffManager: this.checkoutDiffManager,
github: this.github,
workspaceGitService: this.workspaceGitService,
workspaceAutoName: this.workspaceAutoName,
daemonConfigStore: this.daemonConfigStore,
mcpBaseUrl: this.mcpBaseUrl,
stt: () => this.speech?.resolveStt() ?? null,

View File

@@ -0,0 +1,222 @@
import type pino from "pino";
import type { FirstAgentContext } from "@getpaseo/protocol/messages";
import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js";
import type { AgentManager } from "./agent/agent-manager.js";
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js";
import {
attemptFirstAgentBranchAutoName,
type AttemptFirstAgentBranchAutoNameResult,
} from "./paseo-worktree-service.js";
import type { GitMutationService } from "./session/git-mutation/git-mutation-service.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js";
import {
generateBranchNameFromFirstAgentContext,
type GeneratedWorkspaceName,
type GenerateBranchNameFromFirstAgentContextOptions,
} from "./worktree-branch-name-generator.js";
type WorkspaceNameGenerator = typeof generateBranchNameFromFirstAgentContext;
type CurrentSelection = GenerateBranchNameFromFirstAgentContextOptions["currentSelection"] | null;
interface WorkspaceAutoNameOptions {
agentManager: AgentManager;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert">;
workspaceGitService: WorkspaceGitService;
providerSnapshotManager: ProviderSnapshotManager;
readDaemonConfig: () => StructuredGenerationDaemonConfig;
gitMutation: Pick<GitMutationService, "notifyGitMutation">;
emitWorkspaceUpdateForCwd: (cwd: string) => Promise<void>;
emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise<void>;
logger: pino.Logger;
generateWorkspaceName?: WorkspaceNameGenerator;
}
interface ScheduleContext {
currentSelection?: CurrentSelection;
}
export class WorkspaceAutoName {
private readonly agentManager: AgentManager;
private readonly workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert">;
private readonly workspaceGitService: WorkspaceGitService;
private readonly providerSnapshotManager: ProviderSnapshotManager;
private readonly readDaemonConfig: () => StructuredGenerationDaemonConfig;
private readonly gitMutation: Pick<GitMutationService, "notifyGitMutation">;
private readonly emitWorkspaceUpdateForCwd: (cwd: string) => Promise<void>;
private readonly emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise<void>;
private readonly logger: pino.Logger;
private readonly generateWorkspaceName: WorkspaceNameGenerator;
constructor(options: WorkspaceAutoNameOptions) {
this.agentManager = options.agentManager;
this.workspaceRegistry = options.workspaceRegistry;
this.workspaceGitService = options.workspaceGitService;
this.providerSnapshotManager = options.providerSnapshotManager;
this.readDaemonConfig = options.readDaemonConfig;
this.gitMutation = options.gitMutation;
this.emitWorkspaceUpdateForCwd = options.emitWorkspaceUpdateForCwd;
this.emitWorkspaceUpdateForWorkspaceId = options.emitWorkspaceUpdateForWorkspaceId;
this.logger = options.logger;
this.generateWorkspaceName =
options.generateWorkspaceName ?? generateBranchNameFromFirstAgentContext;
}
scheduleForWorktree(
input: {
workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext;
},
context: ScheduleContext = {},
): void {
this.schedule(
() =>
this.maybeAutoNameWorkspaceBranchForFirstAgent({
...input,
currentSelection: context.currentSelection ?? null,
}),
{
cwd: input.workspace.cwd,
message: "Failed to auto-name worktree branch",
},
);
}
scheduleForDirectory(
input: {
workspaceId: string;
cwd: string;
firstAgentContext: FirstAgentContext;
},
context: ScheduleContext = {},
): void {
this.schedule(
() =>
this.maybeAutoNameDirectoryWorkspaceTitle({
...input,
currentSelection: context.currentSelection ?? null,
}),
{ cwd: input.cwd, message: "Failed to auto-name directory workspace title" },
);
}
private async maybeAutoNameWorkspaceBranchForFirstAgent(input: {
workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext;
currentSelection: CurrentSelection;
}): Promise<void> {
let generated: GeneratedWorkspaceName | null = null;
const result: AttemptFirstAgentBranchAutoNameResult = await attemptFirstAgentBranchAutoName({
cwd: input.workspace.cwd,
firstAgentContext: input.firstAgentContext,
generateBranchNameFromContext: ({ cwd, firstAgentContext }) => {
return this.generateFromContext({
cwd,
firstAgentContext,
currentSelection: input.currentSelection,
}).then((nextGenerated) => {
generated = nextGenerated;
return nextGenerated?.branch ?? null;
});
},
});
if (!generated) {
generated = await this.generateFromContext({
cwd: input.workspace.cwd,
firstAgentContext: input.firstAgentContext,
currentSelection: input.currentSelection,
});
}
const generatedTitle = generated?.title ?? null;
if (!generatedTitle) {
return;
}
// K4: re-read from the registry before writing so any concurrent upsert
// that happened between workspace creation and this async path is not clobbered.
// When the first-agent rename changed the git branch too, persist that branch
// alongside the title — both are this path's own fields.
await this.applyGeneratedWorkspaceTitle(input.workspace.workspaceId, {
title: generatedTitle,
...(result.renamed ? { branch: result.branchName } : {}),
promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext),
});
if (result.renamed) {
await this.gitMutation.notifyGitMutation(input.workspace.cwd, "rename-branch");
}
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
}
private async maybeAutoNameDirectoryWorkspaceTitle(input: {
workspaceId: string;
cwd: string;
firstAgentContext: FirstAgentContext;
currentSelection: CurrentSelection;
}): Promise<void> {
const generated = await this.generateFromContext({
cwd: input.cwd,
firstAgentContext: input.firstAgentContext,
currentSelection: input.currentSelection,
});
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,
promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext),
});
await this.emitWorkspaceUpdateForWorkspaceId(input.workspaceId);
}
private async applyGeneratedWorkspaceTitle(
workspaceId: string,
input: { title: string; branch?: string | null; promptTitle?: string | null },
): Promise<void> {
const current = await this.workspaceRegistry.get(workspaceId);
if (!current) {
return;
}
let title = current.title;
if (!title || (input.promptTitle && title === input.promptTitle)) {
title = input.title;
}
await this.workspaceRegistry.upsert({
...current,
title,
...(input.branch ? { branch: input.branch } : {}),
updatedAt: new Date().toISOString(),
});
}
private generateFromContext(input: {
cwd: string;
firstAgentContext: FirstAgentContext;
currentSelection: CurrentSelection;
}): Promise<GeneratedWorkspaceName | null> {
return this.generateWorkspaceName({
agentManager: this.agentManager,
cwd: input.cwd,
workspaceGitService: this.workspaceGitService,
providerSnapshotManager: this.providerSnapshotManager,
daemonConfig: this.readDaemonConfig(),
currentSelection: input.currentSelection ?? undefined,
firstAgentContext: input.firstAgentContext,
logger: this.logger,
});
}
private schedule(run: () => Promise<void>, context: { cwd: string; message: string }): void {
setTimeout(() => {
void run().catch((error) => {
this.logger.warn({ err: error, cwd: context.cwd }, context.message);
});
}, 0);
}
}

View File

@@ -99,6 +99,7 @@ function createWorkflowForRequestTest(options: {
paseoHome: options.paseoHome,
createPaseoWorktree,
warmWorkspaceGitData: options.warmWorkspaceGitData ?? (async () => {}),
autoNameWorkspaceBranchForFirstAgent: () => {},
emitWorkspaceUpdateForWorkspaceId: async () => {},
cacheWorkspaceSetupSnapshot: () => {},
emit: () => {},
@@ -382,6 +383,7 @@ describe("create-agent worktree setup boundary", () => {
paseoHome,
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
warmWorkspaceGitData: async () => {},
autoNameWorkspaceBranchForFirstAgent: () => {},
emitWorkspaceUpdateForWorkspaceId: async () => {},
cacheWorkspaceSetupSnapshot: () => {},
emit: (message) => workspaceSetupEvents.push(message),

View File

@@ -119,7 +119,7 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB
},
) => Promise<CreatePaseoWorktreeResult>;
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
autoNameWorkspaceBranchForFirstAgent?: (input: {
autoNameWorkspaceBranchForFirstAgent: (input: {
workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext;
}) => void;
@@ -596,7 +596,7 @@ export async function createPaseoWorktreeWorkflow(
setTimeout(() => {
if (input.firstAgentContext) {
dependencies.autoNameWorkspaceBranchForFirstAgent?.({
dependencies.autoNameWorkspaceBranchForFirstAgent({
workspace,
firstAgentContext: input.firstAgentContext,
});